Compare commits
10 Commits
42a853cb0d
...
519a8bf3f9
| Author | SHA1 | Date | |
|---|---|---|---|
| 519a8bf3f9 | |||
| a7d0b40e39 | |||
| 3dc9a180d3 | |||
| 4dba965c75 | |||
| 4d2538cc2c | |||
| d201eacc97 | |||
| 9daa671655 | |||
| 3fbf011fe3 | |||
| a815705fa4 | |||
| 6e44d0d4b5 |
40
AGENTS.md
40
AGENTS.md
@@ -69,9 +69,9 @@ This section explains every markdown file in the repo: what it contains, why it
|
|||||||
- **EnvelopeGenerator.Server** (Server): `@rendermode InteractiveServer` - PDF viewers requiring DevExpress backend
|
- **EnvelopeGenerator.Server** (Server): `@rendermode InteractiveServer` - PDF viewers requiring DevExpress backend
|
||||||
- **EnvelopeGenerator.Server.Client** (WASM): `@rendermode InteractiveWebAssembly` - Login, dashboards, business logic
|
- **EnvelopeGenerator.Server.Client** (WASM): `@rendermode InteractiveWebAssembly` - Login, dashboards, business logic
|
||||||
|
|
||||||
**Backend:** EnvelopeGenerator.API (ASP.NET Core 8.0)
|
**Backend + API Host:** `EnvelopeGenerator.Server` (ASP.NET Core 8.0, merged Blazor + Web API host)
|
||||||
|
|
||||||
**Proxy:** YARP in EnvelopeGenerator.Server routes `/api/*` → `localhost:8088` (API)
|
**Proxy:** YARP in `EnvelopeGenerator.Server` is used for external integrations (for example AuthHub forwarding), not for routing app APIs to `EnvelopeGenerator.API`.
|
||||||
|
|
||||||
### Deprecated Projects - DO NOT USE
|
### Deprecated Projects - DO NOT USE
|
||||||
- `EnvelopeGenerator.ReceiverUI` - Pure WASM (migrated to Server)
|
- `EnvelopeGenerator.ReceiverUI` - Pure WASM (migrated to Server)
|
||||||
@@ -80,18 +80,14 @@ This section explains every markdown file in the repo: what it contains, why it
|
|||||||
|
|
||||||
## Development Commands
|
## Development Commands
|
||||||
|
|
||||||
### Run Both Projects (Required)
|
### Run Active Host
|
||||||
```powershell
|
```powershell
|
||||||
# Terminal 1 - API Backend
|
# Terminal - Blazor + Web API host
|
||||||
cd EnvelopeGenerator.API
|
|
||||||
dotnet run
|
|
||||||
|
|
||||||
# Terminal 2 - Blazor Frontend
|
|
||||||
cd EnvelopeGenerator.Server\EnvelopeGenerator.Server
|
cd EnvelopeGenerator.Server\EnvelopeGenerator.Server
|
||||||
dotnet run
|
dotnet run
|
||||||
```
|
```
|
||||||
|
|
||||||
**Critical:** Both must run simultaneously. EnvelopeGenerator.Server proxy forwards `/api/*` to API.
|
**Critical:** `EnvelopeGenerator.Server` is the active runtime host for both UI and API controllers.
|
||||||
|
|
||||||
### Build
|
### Build
|
||||||
```powershell
|
```powershell
|
||||||
@@ -104,10 +100,10 @@ dotnet build EnvelopeGenerator.sln
|
|||||||
EnvelopeGenerator.Domain/ # Entities (Envelope, Receiver, Document, etc.)
|
EnvelopeGenerator.Domain/ # Entities (Envelope, Receiver, Document, etc.)
|
||||||
EnvelopeGenerator.Application/ # MediatR CQRS (Commands, Queries, Handlers)
|
EnvelopeGenerator.Application/ # MediatR CQRS (Commands, Queries, Handlers)
|
||||||
EnvelopeGenerator.Infrastructure/ # EF Core, SQL executors, repositories
|
EnvelopeGenerator.Infrastructure/ # EF Core, SQL executors, repositories
|
||||||
EnvelopeGenerator.API/ # Controllers, endpoints
|
|
||||||
EnvelopeGenerator.Server/
|
EnvelopeGenerator.Server/
|
||||||
EnvelopeGenerator.Server/ # Server-side Blazor components
|
EnvelopeGenerator.Server/ # Server-side Blazor + API controllers
|
||||||
├─ Components/Pages/ # @rendermode InteractiveServer
|
├─ Components/Pages/ # @rendermode InteractiveServer
|
||||||
|
├─ Controllers/ # Active Web API endpoints
|
||||||
EnvelopeGenerator.Server.Client/ # Client-side WASM components
|
EnvelopeGenerator.Server.Client/ # Client-side WASM components
|
||||||
├─ Pages/ # @rendermode InteractiveWebAssembly
|
├─ Pages/ # @rendermode InteractiveWebAssembly
|
||||||
├─ Services/ # HTTP API clients
|
├─ Services/ # HTTP API clients
|
||||||
@@ -156,15 +152,13 @@ float inches = (pixelX / canvasWidth) * pageWidthInches;
|
|||||||
- **Use case:** External API consumers
|
- **Use case:** External API consumers
|
||||||
- **Not suitable for:** Step-by-step UI workflow (no draft support, no partial updates)
|
- **Not suitable for:** Step-by-step UI workflow (no draft support, no partial updates)
|
||||||
|
|
||||||
### Missing Granular Endpoints (Need to Create)
|
### Sender Workflow Endpoint
|
||||||
```
|
```
|
||||||
POST /api/Envelope/draft # Create draft envelope
|
POST /api/Envelope # Create/update/save/send envelope workflow payload
|
||||||
PUT /api/Envelope/{id} # Update metadata
|
|
||||||
DELETE /api/Envelope/{id} # Delete with reason
|
DELETE /api/Envelope/{id} # Delete with reason
|
||||||
POST /api/Envelope/{id}/document # Upload PDF
|
POST /api/Envelope/{id}/document # Upload PDF
|
||||||
POST /api/Envelope/{id}/receivers # Add receiver
|
POST /api/Envelope/{id}/receivers # Add receiver
|
||||||
POST /api/Envelope/{id}/signature-fields # Place signature field
|
POST /api/Envelope/{id}/signature-fields # Place signature field
|
||||||
POST /api/Envelope/{id}/send # Send to receivers
|
|
||||||
```
|
```
|
||||||
|
|
||||||
See `FORM_APPLICATION_CONTEXT.md` for detailed workflow requirements.
|
See `FORM_APPLICATION_CONTEXT.md` for detailed workflow requirements.
|
||||||
@@ -199,7 +193,7 @@ Routes `/api/*`, `/swagger/*`, `/openapi/*`, `/scalar/*` → `https://localhost:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### API Config (`API/appsettings.json`)
|
### Server Host Config (`EnvelopeGenerator.Server/EnvelopeGenerator.Server/appsettings.json`)
|
||||||
- `ConnectionStrings:Default` - SQL Server DB
|
- `ConnectionStrings:Default` - SQL Server DB
|
||||||
- `AllowedOrigins` - CORS (includes `http://localhost:5131`, `http://localhost:7192`)
|
- `AllowedOrigins` - CORS (includes `http://localhost:5131`, `http://localhost:7192`)
|
||||||
- `Cache:SignatureCacheExpiration` - Signature persistence timeout
|
- `Cache:SignatureCacheExpiration` - Signature persistence timeout
|
||||||
@@ -237,16 +231,15 @@ Routes `/api/*`, `/swagger/*`, `/openapi/*`, `/scalar/*` → `https://localhost:
|
|||||||
**No automated tests exist yet.**
|
**No automated tests exist yet.**
|
||||||
|
|
||||||
Manual testing workflow:
|
Manual testing workflow:
|
||||||
1. Start API (`dotnet run` in `EnvelopeGenerator.API`)
|
1. Start EnvelopeGenerator.Server (`dotnet run` in `EnvelopeGenerator.Server\EnvelopeGenerator.Server`)
|
||||||
2. Start EnvelopeGenerator.Server (`dotnet run` in `EnvelopeGenerator.Server\EnvelopeGenerator.Server`)
|
2. Navigate to `https://localhost:5131` (or check console output for port)
|
||||||
3. Navigate to `https://localhost:5131` (or check console output for port)
|
3. Test sender login at `/sender/login`
|
||||||
4. Test sender login at `/sender/login`
|
4. Test receiver flow at `/envelope/login/{envelopeKey}`
|
||||||
5. Test receiver flow at `/envelope/login/{envelopeKey}`
|
|
||||||
|
|
||||||
## Database
|
## Database
|
||||||
|
|
||||||
**SQL Server** (DD_ECM)
|
**SQL Server** (DD_ECM)
|
||||||
- Connection string in `API/appsettings.json`
|
- Connection string in `EnvelopeGenerator.Server/EnvelopeGenerator.Server/appsettings.json`
|
||||||
- EF Core migrations NOT used (manual SQL scripts)
|
- EF Core migrations NOT used (manual SQL scripts)
|
||||||
- Stored procedures: `PRSIG_*` prefix
|
- Stored procedures: `PRSIG_*` prefix
|
||||||
|
|
||||||
@@ -320,5 +313,4 @@ Each envelope maintains independent authentication state.
|
|||||||
None required. All config in `appsettings.json`.
|
None required. All config in `appsettings.json`.
|
||||||
|
|
||||||
**Local dev ports:**
|
**Local dev ports:**
|
||||||
- API: `https://localhost:8088`
|
- App host (UI + API): `https://localhost:5131` or `http://localhost:7192`
|
||||||
- WebUI: `https://localhost:5131` or `http://localhost:7192`
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ This project contains:
|
|||||||
- `EnvelopeGenerator.Domain` — domain models, constants, shared abstractions
|
- `EnvelopeGenerator.Domain` — domain models, constants, shared abstractions
|
||||||
- `EnvelopeGenerator.Infrastructure` — EF Core and infrastructure services
|
- `EnvelopeGenerator.Infrastructure` — EF Core and infrastructure services
|
||||||
- `EnvelopeGenerator.PdfEditor` — PDF-related backend utilities
|
- `EnvelopeGenerator.PdfEditor` — PDF-related backend utilities
|
||||||
- `EnvelopeGenerator.API` — still exists in the solution, but the current merged app host is `EnvelopeGenerator.Server`
|
- `EnvelopeGenerator.API` — deprecated project (no longer part of the active solution/runtime path)
|
||||||
|
|
||||||
### Legacy / Do Not Touch
|
### Legacy / Do Not Touch
|
||||||
- `EnvelopeGenerator.Service`
|
- `EnvelopeGenerator.Service`
|
||||||
@@ -121,7 +121,7 @@ Current controller set includes:
|
|||||||
- `SignatureController`
|
- `SignatureController`
|
||||||
- `TfaRegistrationController`
|
- `TfaRegistrationController`
|
||||||
|
|
||||||
Do not assume API behavior lives only in `EnvelopeGenerator.API`; the active merged host contains controller endpoints directly.
|
Do not assume API behavior lives in `EnvelopeGenerator.API`; the active merged host contains controller endpoints directly.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
using DigitalData.Core.Abstraction.Application.Repository;
|
using EnvelopeGenerator.Application.Common.Dto;
|
||||||
using EnvelopeGenerator.Application.Common.Dto;
|
|
||||||
using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor;
|
using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
@@ -13,23 +12,29 @@ namespace EnvelopeGenerator.Application.Envelopes.Commands;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public record CreateEnvelopeCommand : IRequest<EnvelopeDto?>
|
public record CreateEnvelopeCommand : IRequest<EnvelopeDto?>
|
||||||
{
|
{
|
||||||
|
public int? EnvelopeId { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Der Titel des Umschlags. Dies ist ein Pflichtfeld.
|
/// Der Titel des Umschlags. Dies ist ein Pflichtfeld.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Required]
|
public string? Title { get; set; }
|
||||||
public required string Title { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Die Nachricht, die im Umschlag enthalten sein soll. Dies ist ein Pflichtfeld.
|
/// Die Nachricht, die im Umschlag enthalten sein soll. Dies ist ein Pflichtfeld.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Required]
|
public string? Message { get; set; }
|
||||||
public required string Message { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gibt an, ob die Zwei-Faktor-Authentifizierung für den Umschlag aktiviert ist. Standardmäßig false.
|
/// Gibt an, ob die Zwei-Faktor-Authentifizierung für den Umschlag aktiviert ist. Standardmäßig false.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool TFAEnabled { get; set; } = false;
|
public bool TFAEnabled { get; set; } = false;
|
||||||
|
|
||||||
|
public bool Send { get; init; }
|
||||||
|
|
||||||
|
public CreateEnvelopeDocumentDto? Document { get; init; }
|
||||||
|
|
||||||
|
public List<CreateEnvelopeReceiverDto> Receivers { get; init; } = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// ID des Absenders
|
/// ID des Absenders
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -56,3 +61,13 @@ public record CreateEnvelopeCommand : IRequest<EnvelopeDto?>
|
|||||||
[NotMapped]
|
[NotMapped]
|
||||||
public bool UseSQLExecutor { get; set; } = true;
|
public bool UseSQLExecutor { get; set; } = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record CreateEnvelopeDocumentDto(string DataAsBase64);
|
||||||
|
|
||||||
|
public record CreateEnvelopeFieldDto(double X, double Y, int Page);
|
||||||
|
|
||||||
|
public record CreateEnvelopeReceiverDto(
|
||||||
|
string EmailAddress,
|
||||||
|
string? Name,
|
||||||
|
string? PhoneNumber,
|
||||||
|
List<CreateEnvelopeFieldDto> Fields);
|
||||||
|
|||||||
@@ -2,9 +2,14 @@
|
|||||||
using MediatR;
|
using MediatR;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using DigitalData.Core.Abstraction.Application.Repository;
|
using DigitalData.Core.Abstraction.Application.Repository;
|
||||||
|
using DigitalData.Core.Exceptions;
|
||||||
using EnvelopeGenerator.Domain.Entities;
|
using EnvelopeGenerator.Domain.Entities;
|
||||||
using EnvelopeGenerator.Application.Common.Dto;
|
using EnvelopeGenerator.Application.Common.Dto;
|
||||||
using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor;
|
using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor;
|
||||||
|
using EnvelopeGenerator.Application.Histories.Commands;
|
||||||
|
using EnvelopeGenerator.Domain.Constants;
|
||||||
|
using EnvelopeGenerator.Domain;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace EnvelopeGenerator.Application.Envelopes.Commands;
|
namespace EnvelopeGenerator.Application.Envelopes.Commands;
|
||||||
|
|
||||||
@@ -19,6 +24,16 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
|
|||||||
|
|
||||||
private IRepository<Envelope> Repository => _provider.GetRequiredService<IRepository<Envelope>>();
|
private IRepository<Envelope> Repository => _provider.GetRequiredService<IRepository<Envelope>>();
|
||||||
|
|
||||||
|
private IRepository<Document> DocumentRepository => _provider.GetRequiredService<IRepository<Document>>();
|
||||||
|
|
||||||
|
private IRepository<EnvelopeReceiver> EnvelopeReceiverRepository => _provider.GetRequiredService<IRepository<EnvelopeReceiver>>();
|
||||||
|
|
||||||
|
private IRepository<Receiver> ReceiverRepository => _provider.GetRequiredService<IRepository<Receiver>>();
|
||||||
|
|
||||||
|
private IRepository<DocReceiverElement> DocReceiverElementRepository => _provider.GetRequiredService<IRepository<DocReceiverElement>>();
|
||||||
|
|
||||||
|
private IMediator Mediator => _provider.GetRequiredService<IMediator>();
|
||||||
|
|
||||||
private readonly IMapper _mapper;
|
private readonly IMapper _mapper;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -40,10 +55,321 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
|
|||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public async Task<EnvelopeDto?> Handle(CreateEnvelopeCommand request, CancellationToken cancel)
|
public async Task<EnvelopeDto?> Handle(CreateEnvelopeCommand request, CancellationToken cancel)
|
||||||
{
|
{
|
||||||
var envelope = request.UseSQLExecutor
|
var now = DateTime.Now;
|
||||||
? await Executor.CreateEnvelopeAsync(request.UserId, request.Title, request.Message, request.TFAEnabled, cancel)
|
var title = request.Title?.Trim() ?? string.Empty;
|
||||||
: await Repository.CreateAsync(request, cancel);
|
var message = request.Message?.Trim() ?? string.Empty;
|
||||||
|
|
||||||
|
Envelope envelope;
|
||||||
|
if (request.EnvelopeId is int envelopeId)
|
||||||
|
{
|
||||||
|
envelope = await Repository.Query
|
||||||
|
.Include(e => e.User)
|
||||||
|
.FirstOrDefaultAsync(e => e.Id == envelopeId, cancel)
|
||||||
|
?? throw new BadRequestException($"Envelope {envelopeId} not found.");
|
||||||
|
|
||||||
|
if (envelope.UserId != request.UserId)
|
||||||
|
throw new ForbiddenException();
|
||||||
|
|
||||||
|
await Repository.UpdateAsync(e =>
|
||||||
|
{
|
||||||
|
e.Title = title;
|
||||||
|
e.Message = message;
|
||||||
|
e.TfaEnabled = request.TFAEnabled;
|
||||||
|
e.Status = EnvelopeStatus.EnvelopeSaved;
|
||||||
|
e.ChangedWhen = now;
|
||||||
|
}, e => e.Id == envelopeId, cancel);
|
||||||
|
|
||||||
|
envelope.Title = title;
|
||||||
|
envelope.Message = message;
|
||||||
|
envelope.TfaEnabled = request.TFAEnabled;
|
||||||
|
envelope.Status = EnvelopeStatus.EnvelopeSaved;
|
||||||
|
envelope.ChangedWhen = now;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
envelope = request.UseSQLExecutor
|
||||||
|
? await Executor.CreateEnvelopeAsync(request.UserId, title, message, request.TFAEnabled, cancel)
|
||||||
|
: await Repository.CreateAsync(new Envelope
|
||||||
|
{
|
||||||
|
UserId = request.UserId,
|
||||||
|
Status = EnvelopeStatus.EnvelopeCreated,
|
||||||
|
Uuid = Guid.NewGuid().ToString(),
|
||||||
|
Title = title,
|
||||||
|
Message = message,
|
||||||
|
AddedWhen = now,
|
||||||
|
ChangedWhen = now,
|
||||||
|
Comment = string.Empty,
|
||||||
|
Language = "de-DE",
|
||||||
|
TfaEnabled = request.TFAEnabled,
|
||||||
|
UseAccessCode = false,
|
||||||
|
SendReminderEmails = false
|
||||||
|
}, cancel);
|
||||||
|
|
||||||
|
await Mediator.Send(new CreateHistoryCommand
|
||||||
|
{
|
||||||
|
EnvelopeId = envelope.Id,
|
||||||
|
UserReference = await ResolveSenderReferenceAsync(envelope, cancel),
|
||||||
|
Status = EnvelopeStatus.EnvelopeCreated
|
||||||
|
}, cancel);
|
||||||
|
}
|
||||||
|
|
||||||
|
await UpsertDocumentAsync(envelope.Id, request.Document, now, cancel);
|
||||||
|
await UpsertReceiversAndFieldsAsync(envelope.Id, request.Receivers, now, cancel);
|
||||||
|
|
||||||
|
if (request.Send)
|
||||||
|
await SendEnvelopeAsync(envelope, cancel);
|
||||||
|
|
||||||
|
envelope = await Repository.Query
|
||||||
|
.Include(e => e.User)
|
||||||
|
.Include(e => e.EnvelopeReceivers)
|
||||||
|
.ThenInclude(er => er.Receiver)
|
||||||
|
.FirstAsync(e => e.Id == envelope.Id, cancel);
|
||||||
|
|
||||||
return _mapper.Map<EnvelopeDto>(envelope);
|
return _mapper.Map<EnvelopeDto>(envelope);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<string> ResolveSenderReferenceAsync(Envelope envelope, CancellationToken cancel)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(envelope.User?.Email))
|
||||||
|
return envelope.User.Email;
|
||||||
|
|
||||||
|
var senderEmail = await Repository.Query
|
||||||
|
.Where(e => e.Id == envelope.Id)
|
||||||
|
.Select(e => e.User.Email)
|
||||||
|
.FirstOrDefaultAsync(cancel);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(senderEmail))
|
||||||
|
throw new BadRequestException("Envelope sender email is missing.");
|
||||||
|
|
||||||
|
return senderEmail;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task UpsertDocumentAsync(int envelopeId, CreateEnvelopeDocumentDto? document, DateTime now, CancellationToken cancel)
|
||||||
|
{
|
||||||
|
if (document is null || string.IsNullOrWhiteSpace(document.DataAsBase64))
|
||||||
|
return;
|
||||||
|
|
||||||
|
byte[] bytes;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
bytes = Convert.FromBase64String(document.DataAsBase64);
|
||||||
|
}
|
||||||
|
catch (FormatException)
|
||||||
|
{
|
||||||
|
throw new BadRequestException("Document payload is not valid base64.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingDocumentId = await DocumentRepository.Query
|
||||||
|
.Where(d => d.EnvelopeId == envelopeId)
|
||||||
|
.Select(d => (int?)d.Id)
|
||||||
|
.FirstOrDefaultAsync(cancel);
|
||||||
|
|
||||||
|
if (existingDocumentId is null)
|
||||||
|
{
|
||||||
|
await DocumentRepository.CreateAsync(new Document
|
||||||
|
{
|
||||||
|
EnvelopeId = envelopeId,
|
||||||
|
AddedWhen = now,
|
||||||
|
ByteData = bytes,
|
||||||
|
Filename = string.Empty,
|
||||||
|
Filepath = string.Empty,
|
||||||
|
FileNameOriginal = string.Empty
|
||||||
|
}, cancel);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await DocumentRepository.UpdateAsync(d =>
|
||||||
|
{
|
||||||
|
d.ByteData = bytes;
|
||||||
|
}, d => d.Id == existingDocumentId.Value, cancel);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task UpsertReceiversAndFieldsAsync(int envelopeId, IReadOnlyCollection<CreateEnvelopeReceiverDto> receivers, DateTime now, CancellationToken cancel)
|
||||||
|
{
|
||||||
|
var filteredReceivers = receivers
|
||||||
|
.Where(r => !string.IsNullOrWhiteSpace(r.EmailAddress))
|
||||||
|
.Select(r => r with
|
||||||
|
{
|
||||||
|
EmailAddress = r.EmailAddress.Trim().ToLowerInvariant(),
|
||||||
|
Name = r.Name?.Trim(),
|
||||||
|
PhoneNumber = string.IsNullOrWhiteSpace(r.PhoneNumber) ? null : r.PhoneNumber.Trim(),
|
||||||
|
Fields = r.Fields ?? new List<CreateEnvelopeFieldDto>()
|
||||||
|
})
|
||||||
|
.GroupBy(r => r.EmailAddress, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(g => g.First())
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (filteredReceivers.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var document = await DocumentRepository.Query
|
||||||
|
.FirstOrDefaultAsync(d => d.EnvelopeId == envelopeId, cancel);
|
||||||
|
|
||||||
|
if (document is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var currentLinks = await EnvelopeReceiverRepository.Query
|
||||||
|
.Include(er => er.Receiver)
|
||||||
|
.Where(er => er.EnvelopeId == envelopeId)
|
||||||
|
.ToListAsync(cancel);
|
||||||
|
|
||||||
|
var linksByEmail = currentLinks
|
||||||
|
.Where(er => !string.IsNullOrWhiteSpace(er.Receiver?.EmailAddress))
|
||||||
|
.GroupBy(er => er.Receiver!.EmailAddress, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToDictionary(g => g.Key.ToLowerInvariant(), g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var sequence = currentLinks.Count > 0 ? currentLinks.Max(er => er.Sequence) : 0;
|
||||||
|
|
||||||
|
foreach (var receiverDraft in filteredReceivers)
|
||||||
|
{
|
||||||
|
var linkResult = await GetOrCreateEnvelopeReceiverAsync(envelopeId, receiverDraft, linksByEmail, now, sequence, cancel);
|
||||||
|
var link = linkResult.Link;
|
||||||
|
sequence = linkResult.NextSequence;
|
||||||
|
|
||||||
|
await DocReceiverElementRepository.DeleteAsync(query => query
|
||||||
|
.Where(e => e.DocumentId == document.Id)
|
||||||
|
.Where(e => e.ReceiverId == link.ReceiverId), cancel);
|
||||||
|
|
||||||
|
foreach (var field in receiverDraft.Fields.Where(f => f.Page > 0))
|
||||||
|
{
|
||||||
|
await DocReceiverElementRepository.CreateAsync(new DocReceiverElement
|
||||||
|
{
|
||||||
|
DocumentId = document.Id,
|
||||||
|
ReceiverId = link.ReceiverId,
|
||||||
|
ElementType = 1,
|
||||||
|
X = field.X,
|
||||||
|
Y = field.Y,
|
||||||
|
Width = 1.77,
|
||||||
|
Height = 1.96,
|
||||||
|
Page = field.Page,
|
||||||
|
Required = true,
|
||||||
|
ReadOnly = false,
|
||||||
|
AnnotationIndex = 0,
|
||||||
|
AddedWhen = now,
|
||||||
|
FullName = link.Name,
|
||||||
|
Position = string.Empty,
|
||||||
|
Place = string.Empty,
|
||||||
|
Tooltip = string.Empty
|
||||||
|
}, cancel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(EnvelopeReceiver Link, int NextSequence)> GetOrCreateEnvelopeReceiverAsync(
|
||||||
|
int envelopeId,
|
||||||
|
CreateEnvelopeReceiverDto receiverDraft,
|
||||||
|
IDictionary<string, EnvelopeReceiver> linksByEmail,
|
||||||
|
DateTime now,
|
||||||
|
int sequence,
|
||||||
|
CancellationToken cancel)
|
||||||
|
{
|
||||||
|
if (linksByEmail.TryGetValue(receiverDraft.EmailAddress, out var existingLink))
|
||||||
|
{
|
||||||
|
var nextName = string.IsNullOrWhiteSpace(receiverDraft.Name) ? existingLink.Name : receiverDraft.Name!;
|
||||||
|
var nextPhoneNumber = receiverDraft.PhoneNumber ?? existingLink.PhoneNumber;
|
||||||
|
|
||||||
|
await EnvelopeReceiverRepository.UpdateAsync(er =>
|
||||||
|
{
|
||||||
|
er.Name = nextName;
|
||||||
|
er.PhoneNumber = nextPhoneNumber;
|
||||||
|
er.ChangedWhen = now;
|
||||||
|
}, er => er.EnvelopeId == existingLink.EnvelopeId && er.ReceiverId == existingLink.ReceiverId, cancel);
|
||||||
|
|
||||||
|
existingLink.Name = nextName;
|
||||||
|
existingLink.PhoneNumber = nextPhoneNumber;
|
||||||
|
return (existingLink, sequence);
|
||||||
|
}
|
||||||
|
|
||||||
|
var receiver = await ReceiverRepository.Query
|
||||||
|
.FirstOrDefaultAsync(r => r.EmailAddress != null && r.EmailAddress.ToLower() == receiverDraft.EmailAddress, cancel);
|
||||||
|
|
||||||
|
if (receiver is null)
|
||||||
|
{
|
||||||
|
receiver = await ReceiverRepository.CreateAsync(new Receiver
|
||||||
|
{
|
||||||
|
EmailAddress = receiverDraft.EmailAddress,
|
||||||
|
Signature = receiverDraft.EmailAddress.ToUpperInvariant().GetChecksum(),
|
||||||
|
AddedWhen = now,
|
||||||
|
TotpSecretkey = string.Empty,
|
||||||
|
TfaRegDeadline = null
|
||||||
|
}, cancel);
|
||||||
|
}
|
||||||
|
|
||||||
|
sequence++;
|
||||||
|
var link = await EnvelopeReceiverRepository.CreateAsync(new EnvelopeReceiver
|
||||||
|
{
|
||||||
|
EnvelopeId = envelopeId,
|
||||||
|
ReceiverId = receiver.Id,
|
||||||
|
Sequence = sequence,
|
||||||
|
Name = receiverDraft.Name ?? receiverDraft.EmailAddress,
|
||||||
|
JobTitle = string.Empty,
|
||||||
|
CompanyName = string.Empty,
|
||||||
|
PrivateMessage = string.Empty,
|
||||||
|
AccessCode = string.Empty,
|
||||||
|
AddedWhen = now,
|
||||||
|
ChangedWhen = now,
|
||||||
|
PhoneNumber = receiverDraft.PhoneNumber ?? string.Empty
|
||||||
|
}, cancel);
|
||||||
|
|
||||||
|
link.Receiver = receiver;
|
||||||
|
linksByEmail[receiverDraft.EmailAddress] = link;
|
||||||
|
return (link, sequence);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SendEnvelopeAsync(Envelope envelope, CancellationToken cancel)
|
||||||
|
{
|
||||||
|
var hasDocument = await DocumentRepository.Query
|
||||||
|
.AnyAsync(d => d.EnvelopeId == envelope.Id && d.ByteData != null && d.ByteData.Length > 0, cancel);
|
||||||
|
|
||||||
|
if (!hasDocument)
|
||||||
|
throw new BadRequestException("Cannot send envelope without a document.");
|
||||||
|
|
||||||
|
var links = await EnvelopeReceiverRepository.Query
|
||||||
|
.Include(er => er.Receiver)
|
||||||
|
.Where(er => er.EnvelopeId == envelope.Id)
|
||||||
|
.ToListAsync(cancel);
|
||||||
|
|
||||||
|
if (links.Count == 0)
|
||||||
|
throw new BadRequestException("Cannot send envelope without receivers.");
|
||||||
|
|
||||||
|
var receiverIds = links.Select(l => l.ReceiverId).Distinct().ToList();
|
||||||
|
var documentIds = await DocumentRepository.Query
|
||||||
|
.Where(d => d.EnvelopeId == envelope.Id)
|
||||||
|
.Select(d => d.Id)
|
||||||
|
.ToListAsync(cancel);
|
||||||
|
|
||||||
|
var receiverFieldCounts = await DocReceiverElementRepository.Query
|
||||||
|
.Where(e => documentIds.Contains(e.DocumentId))
|
||||||
|
.Where(e => receiverIds.Contains(e.ReceiverId))
|
||||||
|
.GroupBy(e => e.ReceiverId)
|
||||||
|
.Select(g => new { ReceiverId = g.Key, Count = g.Count() })
|
||||||
|
.ToDictionaryAsync(x => x.ReceiverId, x => x.Count, cancel);
|
||||||
|
|
||||||
|
var receiversWithoutFields = links
|
||||||
|
.Where(link => !receiverFieldCounts.TryGetValue(link.ReceiverId, out var count) || count <= 0)
|
||||||
|
.Select(link => link.Name)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (receiversWithoutFields.Count > 0)
|
||||||
|
throw new BadRequestException("Cannot send envelope because some receivers have no signature fields.");
|
||||||
|
|
||||||
|
var changedWhen = DateTime.Now;
|
||||||
|
await Repository.UpdateAsync(e =>
|
||||||
|
{
|
||||||
|
e.Status = EnvelopeStatus.EnvelopeQueued;
|
||||||
|
e.ChangedWhen = changedWhen;
|
||||||
|
}, e => e.Id == envelope.Id, cancel);
|
||||||
|
|
||||||
|
var userReference = await ResolveSenderReferenceAsync(envelope, cancel);
|
||||||
|
|
||||||
|
await Mediator.Send(new CreateHistoryCommand
|
||||||
|
{
|
||||||
|
EnvelopeId = envelope.Id,
|
||||||
|
UserReference = userReference,
|
||||||
|
Status = EnvelopeStatus.EnvelopeQueued
|
||||||
|
}, cancel);
|
||||||
|
|
||||||
|
envelope.Status = EnvelopeStatus.EnvelopeQueued;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -46,11 +46,17 @@ public class DeleteEnvelopeCommandHandler : IRequestHandler<DeleteEnvelopeComman
|
|||||||
? EnvelopeStatus.EnvelopeWithdrawn
|
? EnvelopeStatus.EnvelopeWithdrawn
|
||||||
: EnvelopeStatus.EnvelopeDeleted;
|
: EnvelopeStatus.EnvelopeDeleted;
|
||||||
|
|
||||||
|
var changedWhen = DateTime.Now;
|
||||||
envelope.Status = targetStatus;
|
envelope.Status = targetStatus;
|
||||||
envelope.Comment = reason;
|
envelope.Comment = reason;
|
||||||
envelope.ChangedWhen = DateTime.Now;
|
envelope.ChangedWhen = changedWhen;
|
||||||
|
|
||||||
await _envelopeRepository.UpdateAsync(envelope, e => e.Id == envelope.Id, cancel);
|
await _envelopeRepository.UpdateAsync(e =>
|
||||||
|
{
|
||||||
|
e.Status = targetStatus;
|
||||||
|
e.Comment = reason;
|
||||||
|
e.ChangedWhen = changedWhen;
|
||||||
|
}, e => e.Id == envelope.Id, cancel);
|
||||||
|
|
||||||
var userReference = envelope.User?.Email;
|
var userReference = envelope.User?.Email;
|
||||||
if (string.IsNullOrWhiteSpace(userReference))
|
if (string.IsNullOrWhiteSpace(userReference))
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using EnvelopeGenerator.Application.Common.Dto;
|
using EnvelopeGenerator.Application.Common.Dto;
|
||||||
|
using EnvelopeGenerator.Application.Envelopes.Commands;
|
||||||
using Microsoft.AspNetCore.WebUtilities;
|
using Microsoft.AspNetCore.WebUtilities;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
@@ -94,4 +95,52 @@ public class EnvelopeService(IHttpClientFactory clientFactory)
|
|||||||
null,
|
null,
|
||||||
response.StatusCode);
|
response.StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<EnvelopeDto?> UpsertAsync(
|
||||||
|
CreateEnvelopeCommand request,
|
||||||
|
CancellationToken cancel = default)
|
||||||
|
{
|
||||||
|
var httpClient = clientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||||
|
var payload = new
|
||||||
|
{
|
||||||
|
EnvelopeId = request.EnvelopeId,
|
||||||
|
Title = request.Title,
|
||||||
|
Message = request.Message,
|
||||||
|
TFAEnabled = request.TFAEnabled,
|
||||||
|
Send = request.Send,
|
||||||
|
Document = request.Document is null
|
||||||
|
? null
|
||||||
|
: new { DataAsBase64 = request.Document.DataAsBase64 },
|
||||||
|
Receivers = request.Receivers.Select(r => new
|
||||||
|
{
|
||||||
|
EmailAddress = r.EmailAddress,
|
||||||
|
Name = r.Name,
|
||||||
|
PhoneNumber = r.PhoneNumber,
|
||||||
|
Fields = r.Fields.Select(f => new { X = f.X, Y = f.Y, Page = f.Page }).ToList()
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await httpClient.PostAsJsonAsync("/api/Envelope", payload, _jsonOptions, cancel);
|
||||||
|
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadFromJsonAsync<EnvelopeDto>(_jsonOptions, cancel);
|
||||||
|
|
||||||
|
string? message = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var error = await response.Content.ReadFromJsonAsync<ErrorResponse>(_jsonOptions, cancel);
|
||||||
|
message = error?.Message;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
message = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var statusCode = (int)response.StatusCode;
|
||||||
|
var reasonPhrase = response.ReasonPhrase ?? "Unknown error";
|
||||||
|
throw new HttpRequestException(
|
||||||
|
$"Failed to save envelope. Status: {statusCode} ({reasonPhrase}){(string.IsNullOrWhiteSpace(message) ? string.Empty : $" - {message}")}",
|
||||||
|
null,
|
||||||
|
response.StatusCode);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
@using DevExpress.Blazor.PdfViewer
|
@using DevExpress.Blazor.PdfViewer
|
||||||
@using DevExpress.Blazor.Reporting.Models
|
@using DevExpress.Blazor.Reporting.Models
|
||||||
@using DevExpress.Blazor
|
@using DevExpress.Blazor
|
||||||
@using EnvelopeGenerator.Application.EnvelopeReceivers.Commands
|
@using EnvelopeGenerator.Application.Common.Dto
|
||||||
|
@using EnvelopeGenerator.Application.Envelopes.Commands
|
||||||
@using EnvelopeGenerator.Server.Client.Services
|
@using EnvelopeGenerator.Server.Client.Services
|
||||||
@using EnvelopeGenerator.Server.Services
|
@using EnvelopeGenerator.Server.Services
|
||||||
|
@using Microsoft.AspNetCore.WebUtilities
|
||||||
@using Microsoft.AspNetCore.Components.Forms
|
@using Microsoft.AspNetCore.Components.Forms
|
||||||
@using Microsoft.Extensions.Caching.Memory
|
@using Microsoft.Extensions.Caching.Memory
|
||||||
@inject IJSRuntime JSRuntime
|
@inject IJSRuntime JSRuntime
|
||||||
@@ -13,7 +15,8 @@
|
|||||||
@inject AppVersionService AppVersion
|
@inject AppVersionService AppVersion
|
||||||
@inject ILogger<EnvelopeSenderEditorPage> Logger
|
@inject ILogger<EnvelopeSenderEditorPage> Logger
|
||||||
@inject EnvelopeReceiverPageDataService ReceiverPageDataService
|
@inject EnvelopeReceiverPageDataService ReceiverPageDataService
|
||||||
@inject EnvelopeReceiverService EnvelopeReceiverService
|
@inject EnvelopeService EnvelopeService
|
||||||
|
@inject IEnvelopeAuthService EnvelopeAuthService
|
||||||
@inject IMemoryCache MemoryCache
|
@inject IMemoryCache MemoryCache
|
||||||
|
|
||||||
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
|
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
|
||||||
@@ -169,41 +172,55 @@
|
|||||||
style="display: none;" />
|
style="display: none;" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@if (_pdfLoaded)
|
@if (_pdfLoaded && _signatureFields.Count > 0)
|
||||||
{
|
{
|
||||||
@* Clear all fields *@
|
<button class="pdf-toolbar__btn pdf-toolbar__btn--reset"
|
||||||
@if (_signatureFields.Count > 0)
|
@onclick="ClearAllFieldsAsync"
|
||||||
{
|
title="Alle Signaturfelder entfernen">
|
||||||
<button class="pdf-toolbar__btn pdf-toolbar__btn--reset"
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||||
@onclick="ClearAllFieldsAsync"
|
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z" />
|
||||||
title="Alle Signaturfelder entfernen">
|
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1 0-2h3a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1h3a1 1 0 0 1 1 1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3h11V2h-11v1z" />
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
</svg>
|
||||||
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z" />
|
|
||||||
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1 0-2h3a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1h3a1 1 0 0 1 1 1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3h11V2h-11v1z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
|
|
||||||
@* Save *@
|
|
||||||
<button class="pdf-toolbar__btn pdf-toolbar__btn--success"
|
|
||||||
@onclick="SaveAsync"
|
|
||||||
disabled="@_isSaving"
|
|
||||||
title="Speichern"
|
|
||||||
style="background: linear-gradient(135deg,#059669,#047857); color:#fff; border-radius:6px; padding:0.3rem 0.75rem; font-size:0.75rem; font-weight:600; border:none;">
|
|
||||||
@if (_isSaving)
|
|
||||||
{
|
|
||||||
<span class="spinner-border spinner-border-sm me-1" role="status"
|
|
||||||
style="width:0.8rem;height:0.8rem;border-width:2px;"></span>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
||||||
<path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z" />
|
|
||||||
</svg>
|
|
||||||
}
|
|
||||||
Speichern
|
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<button class="pdf-toolbar__btn pdf-toolbar__btn--draft"
|
||||||
|
@onclick="SaveDraftAsync"
|
||||||
|
disabled="@_isSaving"
|
||||||
|
title="Speichern">
|
||||||
|
@if (_isSaving)
|
||||||
|
{
|
||||||
|
<span class="spinner-border spinner-border-sm me-1" role="status"
|
||||||
|
style="width:0.8rem;height:0.8rem;border-width:2px;"></span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||||
|
<path d="M2 2a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V5.414A2 2 0 0 0 15.414 4L12 0.586A2 2 0 0 0 10.586 0H2zm9 1.5v2A1.5 1.5 0 0 1 9.5 7h-3A1.5 1.5 0 0 1 5 5.5v-4h5.586a1 1 0 0 1 .707.293L14.207 4.707A1 1 0 0 1 14.5 5.414V12a.5.5 0 0 1-.5.5H2a.5.5 0 0 1-.5-.5V4A.5.5 0 0 1 2 3.5h9z"/>
|
||||||
|
<path d="M5.5 1.5v4a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-4h-4zM4 9a1 1 0 0 0-1 1v3h10v-3a1 1 0 0 0-1-1H4z"/>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
<span class="pdf-toolbar__btn-text">Speichern</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="pdf-toolbar__btn pdf-toolbar__btn--success"
|
||||||
|
@onclick="SendAsync"
|
||||||
|
disabled="@_isSaving"
|
||||||
|
title="Umschlag senden"
|
||||||
|
style="background: linear-gradient(135deg,#059669,#047857); color:#fff; border-radius:6px; padding:0.3rem 0.75rem; font-size:0.75rem; font-weight:600; border:none;">
|
||||||
|
@if (_isSaving)
|
||||||
|
{
|
||||||
|
<span class="spinner-border spinner-border-sm me-1" role="status"
|
||||||
|
style="width:0.8rem;height:0.8rem;border-width:2px;"></span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||||
|
<path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
Senden
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -358,7 +375,7 @@
|
|||||||
|
|
||||||
@* ── Save result popup (success + error) ── *@
|
@* ── Save result popup (success + error) ── *@
|
||||||
<DxPopup @bind-Visible="_savePopupVisible"
|
<DxPopup @bind-Visible="_savePopupVisible"
|
||||||
HeaderText="@(_saveErrorMessage is null ? "Umschlag erstellt" : "Fehler")"
|
HeaderText="@(_saveErrorMessage is null ? _saveSuccessHeader : "Fehler")"
|
||||||
Width="460px"
|
Width="460px"
|
||||||
MaxWidth="95vw"
|
MaxWidth="95vw"
|
||||||
ShowFooter="true"
|
ShowFooter="true"
|
||||||
@@ -377,10 +394,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p style="margin:0 0 0.4rem;font-weight:600;color:#1f2937;font-size:0.95rem;">
|
<p style="margin:0 0 0.4rem;font-weight:600;color:#1f2937;font-size:0.95rem;">
|
||||||
Umschlag wurde erfolgreich erstellt.
|
@_saveSuccessTitle
|
||||||
</p>
|
</p>
|
||||||
<p style="margin:0;color:#6b7280;font-size:0.85rem;line-height:1.5;">
|
<p style="margin:0;color:#6b7280;font-size:0.85rem;line-height:1.5;">
|
||||||
Der Umschlag wurde gespeichert und die Empfänger wurden benachrichtigt.
|
@_saveSuccessBody
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -410,12 +427,21 @@
|
|||||||
<div style="display:flex;gap:0.5rem;justify-content:flex-end;width:100%;padding:0.5rem 0;">
|
<div style="display:flex;gap:0.5rem;justify-content:flex-end;width:100%;padding:0.5rem 0;">
|
||||||
@if (_saveErrorMessage is null)
|
@if (_saveErrorMessage is null)
|
||||||
{
|
{
|
||||||
<button class="btn btn-primary"
|
<button class="btn btn-outline-secondary"
|
||||||
@onclick="GoToDashboard"
|
@onclick="() => _savePopupVisible = false"
|
||||||
style="background:linear-gradient(135deg,#059669,#047857);border:none;border-radius:6px;
|
style="border-radius:6px;padding:0.5rem 1.25rem;font-weight:500;">
|
||||||
padding:0.5rem 1.5rem;font-weight:600;color:#fff;">
|
Schließen
|
||||||
Zur Übersicht
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
@if (_saveNavigateToDashboard)
|
||||||
|
{
|
||||||
|
<button class="btn btn-primary"
|
||||||
|
@onclick="GoToDashboard"
|
||||||
|
style="background:linear-gradient(135deg,#059669,#047857);border:none;border-radius:6px;
|
||||||
|
padding:0.5rem 1.5rem;font-weight:600;color:#fff;">
|
||||||
|
Zur Übersicht
|
||||||
|
</button>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -434,6 +460,9 @@
|
|||||||
[SupplyParameterFromQuery(Name = "esid")]
|
[SupplyParameterFromQuery(Name = "esid")]
|
||||||
public string? Esid { get; set; }
|
public string? Esid { get; set; }
|
||||||
|
|
||||||
|
[SupplyParameterFromQuery(Name = "envelopeId")]
|
||||||
|
public int? EnvelopeId { get; set; }
|
||||||
|
|
||||||
// ── Constants ──
|
// ── Constants ──
|
||||||
// Signature field size in PDF points (fixed): 1.77" × 1.96"
|
// Signature field size in PDF points (fixed): 1.77" × 1.96"
|
||||||
const double SigWidthPt = 1.77 * 72; // 127.44 pt
|
const double SigWidthPt = 1.77 * 72; // 127.44 pt
|
||||||
@@ -452,6 +481,9 @@
|
|||||||
string? _errorMessage;
|
string? _errorMessage;
|
||||||
byte[]? _pdfBytes; // Current rendered PDF (original + placeholders burned in)
|
byte[]? _pdfBytes; // Current rendered PDF (original + placeholders burned in)
|
||||||
byte[]? _originalPdfBytes; // Pristine upload — never modified, used as base for redraw
|
byte[]? _originalPdfBytes; // Pristine upload — never modified, used as base for redraw
|
||||||
|
int? _loadedEnvelopeId;
|
||||||
|
int? _editingEnvelopeId;
|
||||||
|
bool _isLoadingEnvelopeForEdit;
|
||||||
|
|
||||||
List<SignatureFieldDraft> _signatureFields = [];
|
List<SignatureFieldDraft> _signatureFields = [];
|
||||||
ReceiverDraft? _pendingReceiverForPlacement; // Set when user clicks "Signatur hinzufügen"
|
ReceiverDraft? _pendingReceiverForPlacement; // Set when user clicks "Signatur hinzufügen"
|
||||||
@@ -460,6 +492,10 @@
|
|||||||
bool _isSaving = false;
|
bool _isSaving = false;
|
||||||
bool _savePopupVisible = false;
|
bool _savePopupVisible = false;
|
||||||
string? _saveErrorMessage = null;
|
string? _saveErrorMessage = null;
|
||||||
|
string _saveSuccessHeader = "Erfolg";
|
||||||
|
string _saveSuccessTitle = "Vorgang erfolgreich abgeschlossen.";
|
||||||
|
string _saveSuccessBody = string.Empty;
|
||||||
|
bool _saveNavigateToDashboard = false;
|
||||||
|
|
||||||
// ── Envelope metadata ──
|
// ── Envelope metadata ──
|
||||||
string _envelopeTitle = string.Empty;
|
string _envelopeTitle = string.Empty;
|
||||||
@@ -481,6 +517,7 @@
|
|||||||
|
|
||||||
// ── Cache key helper ──
|
// ── Cache key helper ──
|
||||||
string SessionKey => $"sender-editor:{Esid}";
|
string SessionKey => $"sender-editor:{Esid}";
|
||||||
|
bool IsEditMode => EnvelopeId.HasValue && EnvelopeId.Value > 0;
|
||||||
|
|
||||||
// ── Lifecycle ──
|
// ── Lifecycle ──
|
||||||
|
|
||||||
@@ -500,12 +537,27 @@
|
|||||||
if (string.IsNullOrWhiteSpace(Esid))
|
if (string.IsNullOrWhiteSpace(Esid))
|
||||||
{
|
{
|
||||||
var sid = Guid.NewGuid().ToString("N");
|
var sid = Guid.NewGuid().ToString("N");
|
||||||
NavigationManager.NavigateTo($"/sender/editor?esid={sid}", forceLoad: false);
|
var editQuery = EnvelopeId.HasValue && EnvelopeId.Value > 0
|
||||||
|
? $"&envelopeId={EnvelopeId.Value}"
|
||||||
|
: string.Empty;
|
||||||
|
NavigationManager.NavigateTo($"/sender/editor?esid={sid}{editQuery}", forceLoad: false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnParametersSet()
|
protected override async Task OnParametersSetAsync()
|
||||||
{
|
{
|
||||||
|
if (EnvelopeId.HasValue && EnvelopeId.Value > 0)
|
||||||
|
_editingEnvelopeId = EnvelopeId.Value;
|
||||||
|
|
||||||
|
if (IsEditMode && _loadedEnvelopeId != EnvelopeId)
|
||||||
|
{
|
||||||
|
await LoadEnvelopeForEditAsync(EnvelopeId!.Value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsEditMode)
|
||||||
|
return;
|
||||||
|
|
||||||
// After the esid is set via query param, try to restore from cache.
|
// After the esid is set via query param, try to restore from cache.
|
||||||
if (!string.IsNullOrWhiteSpace(Esid)
|
if (!string.IsNullOrWhiteSpace(Esid)
|
||||||
&& MemoryCache.TryGetValue(SessionKey, out EditorSessionData? cached)
|
&& MemoryCache.TryGetValue(SessionKey, out EditorSessionData? cached)
|
||||||
@@ -525,6 +577,90 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async Task LoadEnvelopeForEditAsync(int envelopeId)
|
||||||
|
{
|
||||||
|
if (_isLoadingEnvelopeForEdit)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_isLoadingEnvelopeForEdit = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var user = EnvelopeAuthService.GetCurrentUser();
|
||||||
|
if (user?.Identity is null || !user.Identity.IsAuthenticated)
|
||||||
|
throw new InvalidOperationException("Sender is not authenticated.");
|
||||||
|
|
||||||
|
var envelopes = await EnvelopeService.GetAsync(id: envelopeId);
|
||||||
|
var envelope = envelopes?.FirstOrDefault()
|
||||||
|
?? throw new InvalidOperationException($"Envelope {envelopeId} not found.");
|
||||||
|
|
||||||
|
var document = await ReceiverPageDataService.GetDocumentDetailsOfSenderAsync(user, envelopeId);
|
||||||
|
|
||||||
|
_envelopeTitle = envelope.Title ?? string.Empty;
|
||||||
|
_envelopeMessage = envelope.Message ?? string.Empty;
|
||||||
|
|
||||||
|
var envelopeReceivers = envelope.EnvelopeReceivers?.ToList() ?? [];
|
||||||
|
_receivers = envelopeReceivers
|
||||||
|
.Select((er, index) => new ReceiverDraft(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
er.Name ?? er.Receiver?.EmailAddress ?? $"Receiver {er.ReceiverId}",
|
||||||
|
er.Receiver?.EmailAddress ?? string.Empty,
|
||||||
|
string.Empty,
|
||||||
|
ReceiverPalette[index % ReceiverPalette.Length]))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var receiverById = envelopeReceivers.ToDictionary(er => er.ReceiverId, er => er);
|
||||||
|
var colorByEmail = _receivers
|
||||||
|
.Where(r => !string.IsNullOrWhiteSpace(r.Email))
|
||||||
|
.GroupBy(r => r.Email, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToDictionary(g => g.Key, g => g.First().Color, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
_signatureFields = (document?.Elements ?? Enumerable.Empty<DocReceiverElementDto>())
|
||||||
|
.Select(element =>
|
||||||
|
{
|
||||||
|
receiverById.TryGetValue(element.ReceiverId, out var envelopeReceiver);
|
||||||
|
|
||||||
|
var receiverName = envelopeReceiver?.Name
|
||||||
|
?? envelopeReceiver?.Receiver?.EmailAddress
|
||||||
|
?? element.FullName
|
||||||
|
?? $"Receiver {element.ReceiverId}";
|
||||||
|
|
||||||
|
var receiverEmail = envelopeReceiver?.Receiver?.EmailAddress;
|
||||||
|
var color = (!string.IsNullOrWhiteSpace(receiverEmail) && colorByEmail.TryGetValue(receiverEmail, out var receiverColor))
|
||||||
|
? receiverColor
|
||||||
|
: ReceiverPalette[Math.Abs(element.ReceiverId) % ReceiverPalette.Length];
|
||||||
|
|
||||||
|
return new SignatureFieldDraft(
|
||||||
|
XPt: element.X * 72.0,
|
||||||
|
YPt: element.Y * 72.0,
|
||||||
|
Page: element.Page,
|
||||||
|
ReceiverName: receiverName,
|
||||||
|
Color: color);
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
_originalPdfBytes = document?.ByteData;
|
||||||
|
_pdfLoaded = _originalPdfBytes is { Length: > 0 };
|
||||||
|
_pdfBytes = _pdfLoaded
|
||||||
|
? DrawPlaceholders(_originalPdfBytes!, _signatureFields)
|
||||||
|
: null;
|
||||||
|
_fileName = $"Envelope-{envelopeId}.pdf";
|
||||||
|
_pendingReceiverForPlacement = null;
|
||||||
|
_loadedEnvelopeId = envelopeId;
|
||||||
|
_editingEnvelopeId = envelopeId;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "Failed to load envelope {EnvelopeId} for edit mode", envelopeId);
|
||||||
|
_errorMessage = $"Fehler beim Laden des Umschlags #{envelopeId}: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isLoadingEnvelopeForEdit = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── PDF upload ──
|
// ── PDF upload ──
|
||||||
async Task OnPdfFileSelectedAsync(InputFileChangeEventArgs e)
|
async Task OnPdfFileSelectedAsync(InputFileChangeEventArgs e)
|
||||||
{
|
{
|
||||||
@@ -670,8 +806,48 @@
|
|||||||
NavigationManager.NavigateTo("/sender");
|
NavigationManager.NavigateTo("/sender");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Save — POST /api/EnvelopeReceiver ──
|
async Task SaveDraftAsync()
|
||||||
async Task SaveAsync()
|
{
|
||||||
|
_isSaving = true;
|
||||||
|
_saveErrorMessage = null;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var previousEnvelopeId = EnvelopeId;
|
||||||
|
var command = BuildCreateEnvelopeCommand(send: false);
|
||||||
|
Logger.LogInformation("[SenderEditor] Saving draft. EnvelopeId={EnvelopeId} IsEditMode={IsEditMode}", command.EnvelopeId, IsEditMode);
|
||||||
|
var result = await EnvelopeService.UpsertAsync(command);
|
||||||
|
|
||||||
|
if (result is null)
|
||||||
|
throw new InvalidOperationException("Draft save did not return a result.");
|
||||||
|
|
||||||
|
EnvelopeId = result.Id;
|
||||||
|
_loadedEnvelopeId = result.Id;
|
||||||
|
_editingEnvelopeId = result.Id;
|
||||||
|
|
||||||
|
if (previousEnvelopeId != result.Id)
|
||||||
|
{
|
||||||
|
var sid = string.IsNullOrWhiteSpace(Esid) ? Guid.NewGuid().ToString("N") : Esid;
|
||||||
|
NavigationManager.NavigateTo($"/sender/editor?esid={sid}&envelopeId={result.Id}", forceLoad: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
NavigationManager.NavigateTo("/sender", forceLoad: false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "[SenderEditor] Failed to save draft");
|
||||||
|
_saveErrorMessage = ex.Message;
|
||||||
|
_savePopupVisible = true;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Send envelope ──
|
||||||
|
async Task SendAsync()
|
||||||
{
|
{
|
||||||
// ── Validation ──
|
// ── Validation ──
|
||||||
_titleTouched = true;
|
_titleTouched = true;
|
||||||
@@ -717,57 +893,38 @@
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// ── Build request ──
|
var envelopeId = _editingEnvelopeId
|
||||||
// Document: use the ORIGINAL pdf (without placeholder burn-in) as base64
|
?? EnvelopeId
|
||||||
var docBase64 = Convert.ToBase64String(_originalPdfBytes);
|
?? _loadedEnvelopeId
|
||||||
|
?? GetEnvelopeIdFromCurrentUri();
|
||||||
|
|
||||||
// Build receivers list — each receiver gets their own signature fields
|
if (!envelopeId.HasValue || envelopeId.Value <= 0)
|
||||||
// Coordinate conversion: PDF points → inches (DB stores inches)
|
|
||||||
var receiversCmd = _receivers.Select(receiver =>
|
|
||||||
{
|
{
|
||||||
var fields = _signatureFields
|
var saveResult = await EnvelopeService.UpsertAsync(BuildCreateEnvelopeCommand(send: false))
|
||||||
.Where(f => f.ReceiverName == receiver.FullName)
|
?? throw new InvalidOperationException("Draft save did not return a result.");
|
||||||
.Select(f => new DocReceiverElementCreateDto(
|
|
||||||
X: f.XPt / 72.0,
|
|
||||||
Y: f.YPt / 72.0,
|
|
||||||
Page: f.Page))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return new ReceiverGetOrCreateCommand
|
envelopeId = saveResult.Id;
|
||||||
{
|
EnvelopeId = saveResult.Id;
|
||||||
EmailAddress = receiver.Email,
|
_loadedEnvelopeId = saveResult.Id;
|
||||||
Salution = receiver.FullName,
|
_editingEnvelopeId = saveResult.Id;
|
||||||
PhoneNumber = string.IsNullOrWhiteSpace(receiver.PhoneNumber)
|
}
|
||||||
? null
|
|
||||||
: receiver.PhoneNumber,
|
|
||||||
DocReceiverElements = fields,
|
|
||||||
};
|
|
||||||
}).ToList();
|
|
||||||
|
|
||||||
var command = new CreateEnvelopeReceiverCommand
|
var sendResult = await EnvelopeService.UpsertAsync(BuildCreateEnvelopeCommand(send: true) with { EnvelopeId = envelopeId })
|
||||||
{
|
?? throw new InvalidOperationException("Send did not return a result.");
|
||||||
Title = _envelopeTitle.Trim(),
|
|
||||||
Message = string.IsNullOrWhiteSpace(_envelopeMessage)
|
|
||||||
? "Bitte unterzeichnen Sie das beigefügte Dokument."
|
|
||||||
: _envelopeMessage.Trim(),
|
|
||||||
TFAEnabled = false,
|
|
||||||
Document = new DocumentCreateCommand { DataAsBase64 = docBase64 },
|
|
||||||
Receivers = receiversCmd,
|
|
||||||
};
|
|
||||||
|
|
||||||
var result = await EnvelopeReceiverService.CreateAsync(command);
|
Logger.LogInformation("[SenderEditor] Envelope sent from draft. Id={Id} Status={Status}", sendResult.Id, sendResult.Status);
|
||||||
|
|
||||||
Logger.LogInformation(
|
|
||||||
"[SenderEditor] Envelope created. Id={Id} SentReceivers={Count}",
|
|
||||||
result?.Id, result?.SentReceiver.Count());
|
|
||||||
|
|
||||||
// Success — show popup; GoToDashboard clears cache and navigates
|
// Success — show popup; GoToDashboard clears cache and navigates
|
||||||
_saveErrorMessage = null;
|
_saveErrorMessage = null;
|
||||||
|
_saveSuccessHeader = "Umschlag gesendet";
|
||||||
|
_saveSuccessTitle = "Umschlag wurde erfolgreich gesendet.";
|
||||||
|
_saveSuccessBody = "Der bestehende Entwurf wurde versendet.";
|
||||||
|
_saveNavigateToDashboard = true;
|
||||||
_savePopupVisible = true;
|
_savePopupVisible = true;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Logger.LogError(ex, "[SenderEditor] Failed to create envelope");
|
Logger.LogError(ex, "[SenderEditor] Failed to send envelope draft");
|
||||||
_saveErrorMessage = ex.Message;
|
_saveErrorMessage = ex.Message;
|
||||||
_savePopupVisible = true;
|
_savePopupVisible = true;
|
||||||
}
|
}
|
||||||
@@ -777,6 +934,71 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CreateEnvelopeCommand BuildCreateEnvelopeCommand(bool send)
|
||||||
|
{
|
||||||
|
var envelopeId = _editingEnvelopeId
|
||||||
|
?? EnvelopeId
|
||||||
|
?? _loadedEnvelopeId
|
||||||
|
?? GetEnvelopeIdFromCurrentUri();
|
||||||
|
|
||||||
|
if (IsEditMode && envelopeId is null)
|
||||||
|
throw new InvalidOperationException("Edit mode envelope id is missing.");
|
||||||
|
|
||||||
|
var docBase64 = _originalPdfBytes is { Length: > 0 }
|
||||||
|
? Convert.ToBase64String(_originalPdfBytes)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
var receiverCommands = _receivers.Select(receiver =>
|
||||||
|
{
|
||||||
|
var fields = _signatureFields
|
||||||
|
.Where(f => f.ReceiverName == receiver.FullName)
|
||||||
|
.Select(f => new CreateEnvelopeFieldDto(
|
||||||
|
X: f.XPt / 72.0,
|
||||||
|
Y: f.YPt / 72.0,
|
||||||
|
Page: f.Page))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new CreateEnvelopeReceiverDto(
|
||||||
|
EmailAddress: receiver.Email,
|
||||||
|
Name: receiver.FullName,
|
||||||
|
PhoneNumber: string.IsNullOrWhiteSpace(receiver.PhoneNumber) ? null : receiver.PhoneNumber,
|
||||||
|
Fields: fields);
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
return new CreateEnvelopeCommand
|
||||||
|
{
|
||||||
|
EnvelopeId = envelopeId,
|
||||||
|
Title = _envelopeTitle,
|
||||||
|
Message = _envelopeMessage,
|
||||||
|
TFAEnabled = false,
|
||||||
|
Send = send,
|
||||||
|
Document = string.IsNullOrWhiteSpace(docBase64) ? null : new CreateEnvelopeDocumentDto(docBase64),
|
||||||
|
Receivers = receiverCommands
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
int? GetEnvelopeIdFromCurrentUri()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri);
|
||||||
|
if (string.IsNullOrWhiteSpace(uri.Query))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var query = QueryHelpers.ParseQuery(uri.Query);
|
||||||
|
if (!query.TryGetValue("envelopeId", out var raw))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return int.TryParse(raw.ToString(), out var id) && id > 0
|
||||||
|
? id
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Cache persistence ──
|
// ── Cache persistence ──
|
||||||
void PersistSession()
|
void PersistSession()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
Neuer Umschlag
|
Neuer Umschlag
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button class="sender-btn" @onclick="EditEnvelope" disabled="@(_selectedEnvelope == null || IsEnvelopeSent(_selectedEnvelope))" title="Ausgewählten Umschlag bearbeiten">
|
<button class="sender-btn" @onclick="EditEnvelope" disabled="@(_selectedEnvelope == null || !IsEnvelopeEditable(_selectedEnvelope))" title="Ausgewählten Umschlag bearbeiten">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||||
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z" />
|
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -728,7 +728,9 @@
|
|||||||
void EditEnvelope()
|
void EditEnvelope()
|
||||||
{
|
{
|
||||||
if (_selectedEnvelope == null) return;
|
if (_selectedEnvelope == null) return;
|
||||||
// TODO: Navigate to envelope editor
|
if (!IsEnvelopeEditable(_selectedEnvelope)) return;
|
||||||
|
|
||||||
|
Navigation.NavigateTo($"/sender/editor?envelopeId={_selectedEnvelope.Id}");
|
||||||
}
|
}
|
||||||
|
|
||||||
void DeleteEnvelope()
|
void DeleteEnvelope()
|
||||||
@@ -796,6 +798,12 @@
|
|||||||
return status >= EnvelopeStatus.EnvelopeQueued;
|
return status >= EnvelopeStatus.EnvelopeQueued;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool IsEnvelopeEditable(EnvelopeDto envelope)
|
||||||
|
{
|
||||||
|
var status = (EnvelopeStatus)envelope.Status;
|
||||||
|
return status is EnvelopeStatus.EnvelopeCreated or EnvelopeStatus.EnvelopeSaved;
|
||||||
|
}
|
||||||
|
|
||||||
(string Label, string CssClass, string DotColor) GetStatusInfo(EnvelopeStatus status)
|
(string Label, string CssClass, string DotColor) GetStatusInfo(EnvelopeStatus status)
|
||||||
{
|
{
|
||||||
return status switch
|
return status switch
|
||||||
|
|||||||
@@ -95,7 +95,6 @@ public class EnvelopeController : ControllerBase
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="command"></param>
|
/// <param name="command"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
[NonAction]
|
|
||||||
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
|
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> CreateAsync([FromBody] CreateEnvelopeCommand command)
|
public async Task<IActionResult> CreateAsync([FromBody] CreateEnvelopeCommand command)
|
||||||
@@ -123,4 +122,5 @@ public class EnvelopeController : ControllerBase
|
|||||||
|
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,14 @@ public class EnvelopeReceiverPageDataService(
|
|||||||
: throw new NotFoundException($"Document for envelope {envelopeId} not found.");
|
: throw new NotFoundException($"Document for envelope {envelopeId} not found.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads the full document DTO (including receiver elements) for the authenticated sender.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<DocumentDto?> GetDocumentDetailsOfSenderAsync(ClaimsPrincipal user, int envelopeId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await mediator.Send(new ReadDocumentQuery(EnvelopeId: envelopeId, UserId: user.SenderId()), cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Loads the current receiver's signature placeholders.
|
/// Loads the current receiver's signature placeholders.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -187,6 +187,7 @@
|
|||||||
"DbTriggerParams": {
|
"DbTriggerParams": {
|
||||||
"Envelope": [ "TBSIG_ENVELOPE_HISTORY_AFT_INS" ],
|
"Envelope": [ "TBSIG_ENVELOPE_HISTORY_AFT_INS" ],
|
||||||
"History": [ "TBSIG_ENVELOPE_HISTORY_AFT_INS" ],
|
"History": [ "TBSIG_ENVELOPE_HISTORY_AFT_INS" ],
|
||||||
|
"DocReceiverElement": [ "TBSIG_DOCUMENT_RECEIVER_ELEMENT_AFT_INS", "TBSIG_DOCUMENT_RECEIVER_ELEMENT_AFT_UPD" ],
|
||||||
"EmailOut": [ "TBEMLP_EMAIL_OUT_AFT_INS", "TBEMLP_EMAIL_OUT_AFT_UPD" ],
|
"EmailOut": [ "TBEMLP_EMAIL_OUT_AFT_INS", "TBEMLP_EMAIL_OUT_AFT_UPD" ],
|
||||||
"EnvelopeReceiverReadOnly": [ "TBSIG_ENVELOPE_RECEIVER_READ_ONLY_UPD" ],
|
"EnvelopeReceiverReadOnly": [ "TBSIG_ENVELOPE_RECEIVER_READ_ONLY_UPD" ],
|
||||||
"Receiver": [],
|
"Receiver": [],
|
||||||
|
|||||||
@@ -542,6 +542,40 @@ body.resizing {
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Draft save button for sender editor */
|
||||||
|
.pdf-toolbar__btn--draft {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
min-width: auto;
|
||||||
|
padding: 0.3rem 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0f4c81;
|
||||||
|
border: 1px solid rgba(14, 165, 233, 0.34);
|
||||||
|
background: linear-gradient(135deg, rgba(14, 165, 233, 0.12) 0%, rgba(2, 132, 199, 0.1) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-toolbar__btn--draft:hover:not(:disabled) {
|
||||||
|
color: #ffffff;
|
||||||
|
border-color: transparent;
|
||||||
|
background: linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%);
|
||||||
|
box-shadow: 0 4px 12px rgba(2, 132, 199, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-toolbar__btn--draft:disabled {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-toolbar__btn--draft svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-toolbar__btn--draft .pdf-toolbar__btn-text {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
/* Signature Change Button */
|
/* Signature Change Button */
|
||||||
.pdf-toolbar__btn--signature-change {
|
.pdf-toolbar__btn--signature-change {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"DbTriggerParams": {
|
"DbTriggerParams": {
|
||||||
"Envelope": [ "TBSIG_ENVELOPE_AFT_INS" ],
|
"Envelope": [ "TBSIG_ENVELOPE_AFT_INS" ],
|
||||||
"History": [ "TBSIG_ENVELOPE_HISTORY_AFT_INS" ],
|
"History": [ "TBSIG_ENVELOPE_HISTORY_AFT_INS" ],
|
||||||
|
"DocReceiverElement": [ "TBSIG_DOCUMENT_RECEIVER_ELEMENT_AFT_INS", "TBSIG_DOCUMENT_RECEIVER_ELEMENT_AFT_UPD" ],
|
||||||
"EmailOut": [ "TBEMLP_EMAIL_OUT_AFT_INS", "TBEMLP_EMAIL_OUT_AFT_UPD" ],
|
"EmailOut": [ "TBEMLP_EMAIL_OUT_AFT_INS", "TBEMLP_EMAIL_OUT_AFT_UPD" ],
|
||||||
"EnvelopeReceiverReadOnly": [ "TBSIG_ENVELOPE_RECEIVER_READ_ONLY_UPD" ],
|
"EnvelopeReceiverReadOnly": [ "TBSIG_ENVELOPE_RECEIVER_READ_ONLY_UPD" ],
|
||||||
"Receiver": [],
|
"Receiver": [],
|
||||||
|
|||||||
@@ -36,8 +36,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.PdfEditor
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.Tests", "EnvelopeGenerator.Tests\EnvelopeGenerator.Tests.csproj", "{224C4845-1CDE-22B7-F3A9-1FF9297F70E8}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.Tests", "EnvelopeGenerator.Tests\EnvelopeGenerator.Tests.csproj", "{224C4845-1CDE-22B7-F3A9-1FF9297F70E8}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.API", "EnvelopeGenerator.API\EnvelopeGenerator.API.csproj", "{EC768913-6270-14F4-1DD3-69C87A659462}"
|
|
||||||
EndProject
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.DependencyInjection", "EnvelopeGenerator.DependencyInjection\EnvelopeGenerator.DependencyInjection.csproj", "{5DCCF9A1-C03F-90E6-87D3-E96DB25250C2}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.DependencyInjection", "EnvelopeGenerator.DependencyInjection\EnvelopeGenerator.DependencyInjection.csproj", "{5DCCF9A1-C03F-90E6-87D3-E96DB25250C2}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "EnvelopeGenerator.Server", "EnvelopeGenerator.Server", "{BF1700D5-592E-4FFA-84E8-5480E289A1F0}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "EnvelopeGenerator.Server", "EnvelopeGenerator.Server", "{BF1700D5-592E-4FFA-84E8-5480E289A1F0}"
|
||||||
@@ -95,10 +93,6 @@ Global
|
|||||||
{224C4845-1CDE-22B7-F3A9-1FF9297F70E8}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{224C4845-1CDE-22B7-F3A9-1FF9297F70E8}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{224C4845-1CDE-22B7-F3A9-1FF9297F70E8}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{224C4845-1CDE-22B7-F3A9-1FF9297F70E8}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{224C4845-1CDE-22B7-F3A9-1FF9297F70E8}.Release|Any CPU.Build.0 = Release|Any CPU
|
{224C4845-1CDE-22B7-F3A9-1FF9297F70E8}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{EC768913-6270-14F4-1DD3-69C87A659462}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{EC768913-6270-14F4-1DD3-69C87A659462}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{EC768913-6270-14F4-1DD3-69C87A659462}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{EC768913-6270-14F4-1DD3-69C87A659462}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{5DCCF9A1-C03F-90E6-87D3-E96DB25250C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{5DCCF9A1-C03F-90E6-87D3-E96DB25250C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{5DCCF9A1-C03F-90E6-87D3-E96DB25250C2}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{5DCCF9A1-C03F-90E6-87D3-E96DB25250C2}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{5DCCF9A1-C03F-90E6-87D3-E96DB25250C2}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{5DCCF9A1-C03F-90E6-87D3-E96DB25250C2}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
@@ -136,7 +130,6 @@ Global
|
|||||||
{6D56C01F-D6CB-4D8A-BD3D-4FD34326998C} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB}
|
{6D56C01F-D6CB-4D8A-BD3D-4FD34326998C} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB}
|
||||||
{211619F5-AE25-4BA5-A552-BACAFE0632D3} = {9943209E-1744-4944-B1BA-4F87FC1A0EEB}
|
{211619F5-AE25-4BA5-A552-BACAFE0632D3} = {9943209E-1744-4944-B1BA-4F87FC1A0EEB}
|
||||||
{224C4845-1CDE-22B7-F3A9-1FF9297F70E8} = {0CBC2432-A561-4440-89BC-671B66A24146}
|
{224C4845-1CDE-22B7-F3A9-1FF9297F70E8} = {0CBC2432-A561-4440-89BC-671B66A24146}
|
||||||
{EC768913-6270-14F4-1DD3-69C87A659462} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB}
|
|
||||||
{5DCCF9A1-C03F-90E6-87D3-E96DB25250C2} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB}
|
{5DCCF9A1-C03F-90E6-87D3-E96DB25250C2} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB}
|
||||||
{BF1700D5-592E-4FFA-84E8-5480E289A1F0} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB}
|
{BF1700D5-592E-4FFA-84E8-5480E289A1F0} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB}
|
||||||
{4E6C54DA-576D-0955-2564-9EC890BB8279} = {BF1700D5-592E-4FFA-84E8-5480E289A1F0}
|
{4E6C54DA-576D-0955-2564-9EC890BB8279} = {BF1700D5-592E-4FFA-84E8-5480E289A1F0}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# EnvelopeGenerator.Form — VB.NET Desktop Application Context
|
# EnvelopeGenerator.Form — VB.NET Desktop Application Context
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
**Legacy Windows Forms application** for envelope creation, management, and signature field placement. Built with **DevExpress components** and **GdPicture14** for PDF manipulation. This application is being **migrated to EnvelopeGenerator.Server + API** architecture.
|
**Legacy Windows Forms application** for envelope creation, management, and signature field placement. Built with **DevExpress components** and **GdPicture14** for PDF manipulation. This application is being **migrated to `EnvelopeGenerator.Server` architecture** (merged Blazor + Web API host).
|
||||||
|
|
||||||
**Primary Libraries:** DevExpress XtraGrid/XtraEditors, GdPicture14, VB.NET (.NET Framework 4.6.2)
|
**Primary Libraries:** DevExpress XtraGrid/XtraEditors, GdPicture14, VB.NET (.NET Framework 4.6.2)
|
||||||
|
|
||||||
@@ -368,7 +368,7 @@ End Sub
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Migration to EnvelopeGenerator.Server + API
|
## Migration to EnvelopeGenerator.Server
|
||||||
|
|
||||||
### Mapping Table
|
### Mapping Table
|
||||||
|
|
||||||
|
|||||||
281
SENDER_SAVE_EDIT_MIGRATION_PLAN.md
Normal file
281
SENDER_SAVE_EDIT_MIGRATION_PLAN.md
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
# Sender Save/Edit Migration Plan (Legacy Form -> Blazor Server)
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Migrate the legacy `frmEnvelopeEditor` save/edit workflow into current architecture so that:
|
||||||
|
|
||||||
|
1. Sender can **save incomplete envelope as draft** (`EnvelopeSaved = 1002`) from `EnvelopeSenderEditorPage.razor`.
|
||||||
|
2. Sender can **resume editing** saved/created envelopes from `EnvelopeSenderPage.razor` via **Bearbeiten** and continue where they left off.
|
||||||
|
|
||||||
|
This plan introduces a distinct draft flow and a same-ID send-from-draft flow, aligned with legacy Form app semantics.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### In scope
|
||||||
|
- Add separate **Save** and **Send** actions in sender editor.
|
||||||
|
- Persist draft data server-side with partial/incomplete payload support.
|
||||||
|
- Re-open existing draft from dashboard and restore editor state.
|
||||||
|
- Preserve existing delete/withdraw logic and status model.
|
||||||
|
- Add endpoints and application commands required for progressive sender workflow.
|
||||||
|
|
||||||
|
### Out of scope (this ticket)
|
||||||
|
- Full redesign of editor UX.
|
||||||
|
- Multi-document support parity with `frmOrderFiles`.
|
||||||
|
- New DB schema changes (unless separately approved).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Gap Analysis
|
||||||
|
|
||||||
|
## Current behavior (problem)
|
||||||
|
- `EnvelopeSenderEditorPage.razor` has only one save path (`SaveAsync`) that behaves like "send":
|
||||||
|
- requires title + PDF + receivers + fields.
|
||||||
|
- calls `EnvelopeReceiverService.CreateAsync(...)` -> `/api/EnvelopeReceiver` monolithic create/send pipeline.
|
||||||
|
- `EnvelopeSenderPage.razor` `EditEnvelope()` is TODO and does not navigate to editable persisted draft data.
|
||||||
|
|
||||||
|
## Legacy behavior (target)
|
||||||
|
- `frmEnvelopeEditor` has both:
|
||||||
|
- **Save**: persists envelope without full validation/sending.
|
||||||
|
- **Send**: validates required business data then sends invitations.
|
||||||
|
- Dashboard "Bearbeiten" opens selected unsent envelope and restores work state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architectural Decisions
|
||||||
|
|
||||||
|
1. **Split command model by intent**
|
||||||
|
- Draft persistence must not reuse monolithic `/api/EnvelopeReceiver` create+send endpoint.
|
||||||
|
- Introduce dedicated sender workflow commands/endpoints for draft lifecycle.
|
||||||
|
|
||||||
|
2. **Progressive persistence**
|
||||||
|
- Persist envelope metadata, document, receivers, and fields incrementally.
|
||||||
|
- Draft save accepts partial data and updates only provided segments.
|
||||||
|
|
||||||
|
3. **Status ownership**
|
||||||
|
- Draft operations set status to `EnvelopeSaved` (1002).
|
||||||
|
- Send operation transitions to queued/sent path (existing 1003+ behavior).
|
||||||
|
|
||||||
|
4. **Editor route strategy**
|
||||||
|
- Keep `/sender/editor` for new envelope.
|
||||||
|
- Support edit mode via query: `/sender/editor?envelopeId={id}` (or route variant in follow-up if preferred).
|
||||||
|
|
||||||
|
5. **No DB schema migration by default**
|
||||||
|
- Use existing tables (`TBSIG_ENVELOPE`, `TBSIG_DOCUMENT`, `TBSIG_ENVELOPE_RECEIVER`, `TBSIG_DOC_RECEIVER_ELEMENT`).
|
||||||
|
- If a field is missing for a requirement, handle in application logic first and escalate only if unavoidable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Target Workflow
|
||||||
|
|
||||||
|
## A) New envelope (draft first)
|
||||||
|
1. User opens `/sender/editor`.
|
||||||
|
2. User enters any subset of data.
|
||||||
|
3. User clicks **Save**.
|
||||||
|
4. System creates or updates draft envelope and sets status `EnvelopeSaved`.
|
||||||
|
5. User gets confirmation and remains in editor (or optional return to dashboard).
|
||||||
|
|
||||||
|
## B) New envelope (direct send)
|
||||||
|
1. User fills required data.
|
||||||
|
2. User clicks **Send**.
|
||||||
|
3. System validates complete data and sends invitations.
|
||||||
|
4. Status transitions to queued/sent flow.
|
||||||
|
|
||||||
|
## C) Edit existing draft/created
|
||||||
|
1. User selects row in `/sender` with `EnvelopeCreated` or `EnvelopeSaved`.
|
||||||
|
2. User clicks **Bearbeiten**.
|
||||||
|
3. App navigates to `/sender/editor?envelopeId={id}`.
|
||||||
|
4. Editor loads persisted data (metadata, PDF, receivers, fields).
|
||||||
|
5. User can save again (draft) or send.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Detailed Implementation Plan
|
||||||
|
|
||||||
|
## Phase 1 - Contracts and Application Use Cases
|
||||||
|
|
||||||
|
### 1.1 Add sender draft commands (Application)
|
||||||
|
- `CreateEnvelopeCommand` (create/update draft with partial data)
|
||||||
|
- `ReadEnvelopeDraftQuery` (load full editable projection by envelope id)
|
||||||
|
- `CreateEnvelopeCommand` (same request, `Send=true` for final send)
|
||||||
|
|
||||||
|
Each command should enforce sender ownership (`UserId`) and return structured result DTOs.
|
||||||
|
|
||||||
|
### 1.2 Validation policy split
|
||||||
|
- **Draft Save validation (minimal):**
|
||||||
|
- title optional at first save? choose one policy and keep consistent.
|
||||||
|
- no requirement for receivers/fields/document.
|
||||||
|
- **Send validation (strict):**
|
||||||
|
- title required
|
||||||
|
- document required
|
||||||
|
- at least one receiver
|
||||||
|
- required signature field mapping per receiver (business rule)
|
||||||
|
|
||||||
|
### 1.3 History policy
|
||||||
|
- On draft save/update: optional lightweight history entry (`EnvelopeSaved`) if legacy parity requires it.
|
||||||
|
- On send: keep current history/send pipeline behavior.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 - Server Endpoints
|
||||||
|
|
||||||
|
Implement in `EnvelopeGenerator.Server` only.
|
||||||
|
|
||||||
|
### 2.1 Draft endpoints
|
||||||
|
- `POST /api/Envelope`
|
||||||
|
- Create/update draft envelope (partial payload allowed), and send when `Send=true`.
|
||||||
|
- `GET /api/Envelope/{id}/draft`
|
||||||
|
- Read full editable aggregate for editor hydration.
|
||||||
|
|
||||||
|
### 2.2 Authorization + errors
|
||||||
|
- 400 validation issues
|
||||||
|
- 403 sender mismatch
|
||||||
|
- 404 envelope not found
|
||||||
|
- 409 invalid state transition (optional, if needed)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 - Sender Editor UI (`EnvelopeSenderEditorPage.razor`)
|
||||||
|
|
||||||
|
### 3.1 Action buttons
|
||||||
|
- Keep current action as **Send** (rename from "Speichern" to "Senden").
|
||||||
|
- Add separate **Save** button next to Send.
|
||||||
|
|
||||||
|
### 3.2 Editor mode state
|
||||||
|
- Add `_editingEnvelopeId` from query param `envelopeId`.
|
||||||
|
- `null` => create mode, value => edit mode.
|
||||||
|
|
||||||
|
### 3.3 Load existing draft
|
||||||
|
- On parameter set / first render in edit mode:
|
||||||
|
- call draft read endpoint.
|
||||||
|
- hydrate title, message, PDF bytes, receiver list, field list.
|
||||||
|
- map DB inches <-> PDF points correctly:
|
||||||
|
- DB inches -> UI points: `pt = inches * 72`
|
||||||
|
- UI points -> DB inches: `inches = pt / 72`
|
||||||
|
|
||||||
|
### 3.4 Save behavior (new)
|
||||||
|
- Save button calls `SaveDraftAsync()`:
|
||||||
|
- no strict send validations.
|
||||||
|
- create draft if no id yet, otherwise update.
|
||||||
|
- force status `EnvelopeSaved`.
|
||||||
|
- keep user in editor and show non-blocking success feedback.
|
||||||
|
|
||||||
|
### 3.5 Send behavior (existing path replacement)
|
||||||
|
- Send button calls `SendAsync()`:
|
||||||
|
- strict validation.
|
||||||
|
- if no draft id: create draft first then send, or direct send command.
|
||||||
|
- if edit mode: send current draft id.
|
||||||
|
|
||||||
|
### 3.6 Session cache coexistence
|
||||||
|
- Keep `esid` memory cache for transient UX resilience.
|
||||||
|
- In edit mode, server data is source of truth; cache is fallback only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 - Dashboard Edit Resume (`EnvelopeSenderPage.razor`)
|
||||||
|
|
||||||
|
### 4.1 Edit button enablement
|
||||||
|
- Enable **Bearbeiten** for statuses:
|
||||||
|
- `EnvelopeCreated`
|
||||||
|
- `EnvelopeSaved`
|
||||||
|
- Keep disabled for sent/queued/completed/deleted/withdrawn/rejected.
|
||||||
|
|
||||||
|
### 4.2 Navigation
|
||||||
|
- Implement `EditEnvelope()`:
|
||||||
|
- `Navigation.NavigateTo($"/sender/editor?envelopeId={_selectedEnvelope.Id}")`.
|
||||||
|
|
||||||
|
### 4.3 Optional UX
|
||||||
|
- Double-click on editable rows can open editor instead of preview (or keep current preview behavior per product decision).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5 - Data Mapping Rules
|
||||||
|
|
||||||
|
1. **Document**
|
||||||
|
- Persist original PDF bytes (no visual placeholder burn-in).
|
||||||
|
2. **Receivers**
|
||||||
|
- Draft can contain receivers with/without fields.
|
||||||
|
3. **Fields**
|
||||||
|
- Store in DB inches with fixed signature size semantics.
|
||||||
|
4. **Ownership**
|
||||||
|
- Sender can read/update/send only own envelopes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suggested DTO Shape (Draft)
|
||||||
|
|
||||||
|
Use one aggregate DTO for editor hydration/save/update:
|
||||||
|
|
||||||
|
- `EnvelopeDraftDto`
|
||||||
|
- `EnvelopeId`
|
||||||
|
- `Status`
|
||||||
|
- `Title`
|
||||||
|
- `Message`
|
||||||
|
- `DocumentBase64` (or document id + fetch endpoint)
|
||||||
|
- `Receivers[]`
|
||||||
|
- `ReceiverId?`
|
||||||
|
- `Name`
|
||||||
|
- `Email`
|
||||||
|
- `Phone`
|
||||||
|
- `Fields[] { XInches, YInches, Page }`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration Strategy
|
||||||
|
|
||||||
|
## Step A (safe vertical slice)
|
||||||
|
- Add read/edit route query handling and dashboard navigation first.
|
||||||
|
- Hydrate editor from existing envelope data where possible.
|
||||||
|
|
||||||
|
## Step B (draft save)
|
||||||
|
- Implement draft create/update endpoints + app commands.
|
||||||
|
- Wire Save button.
|
||||||
|
|
||||||
|
## Step C (send from draft)
|
||||||
|
- Move Send to explicit command/endpoint.
|
||||||
|
- Ensure existing email/history behavior remains intact.
|
||||||
|
|
||||||
|
## Step D (hardening)
|
||||||
|
- Add tests + manual QA checklist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Plan
|
||||||
|
|
||||||
|
## Automated (Application)
|
||||||
|
- Save draft with partial data -> status `EnvelopeSaved`.
|
||||||
|
- Edit draft updates existing records, no duplicate orphan data.
|
||||||
|
- Send draft with missing required data -> validation error.
|
||||||
|
- Send draft complete -> queued/sent flow success.
|
||||||
|
- Ownership checks -> 403 behavior.
|
||||||
|
|
||||||
|
## Manual (UI)
|
||||||
|
1. New editor -> save with only title.
|
||||||
|
2. Reopen from dashboard -> state restored.
|
||||||
|
3. Add PDF only -> save -> reopen -> PDF still present.
|
||||||
|
4. Add receivers/fields incrementally -> save repeatedly.
|
||||||
|
5. Final send -> status transitions and receiver notifications.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risks and Mitigations
|
||||||
|
|
||||||
|
1. **Monolithic endpoint coupling risk**
|
||||||
|
- Mitigation: draft/send dedicated endpoints.
|
||||||
|
2. **Data duplication on repeated save**
|
||||||
|
- Mitigation: idempotent upsert logic for receivers/fields.
|
||||||
|
3. **Coordinate regression**
|
||||||
|
- Mitigation: centralize inches/points conversion helpers.
|
||||||
|
4. **Trigger side effects/history duplication**
|
||||||
|
- Mitigation: verify DB-trigger interactions per status events.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
- Editor has two distinct actions: **Save** and **Send**.
|
||||||
|
- Save allows incomplete payload and persists with `EnvelopeSaved` status.
|
||||||
|
- Dashboard **Bearbeiten** opens editor for `EnvelopeCreated`/`EnvelopeSaved` and restores state.
|
||||||
|
- Sender can continue from last saved point and eventually send.
|
||||||
|
- Server endpoints support save/edit/send draft workflow with same envelope id.
|
||||||
|
- No DB schema changes introduced without explicit approval.
|
||||||
Reference in New Issue
Block a user