Compare commits

..

2 Commits

Author SHA1 Message Date
380a5f4c0c Add signature submission feature and pipeline updates
Introduced a new feature for submitting signatures, including
backend and frontend changes. Added `SignatureSubmitDto` and
`SubmitAsync` in `SignatureService` to handle signature data
submission. Implemented a new `Submit` endpoint in
`SignatureController` to validate and process signature payloads.

Updated pipeline behaviors to include `SaveSignatureBehavior`
and adjusted the execution order. Enhanced query logic in
`EnvelopeReceiverResolutionBehavior` and `SaveSignatureBehavior`
to align with the updated data model.

Refactored `EGDbContext` to improve trigger handling and logging.
Updated `ReceiverSignedPage.razor` to integrate `SignatureService`
and handle submission errors gracefully. Adjusted application
URLs in `launchSettings.json` and added new database triggers
in `appsettings.json`.
2026-09-28 12:21:44 +02:00
0f53d0509e remove depr API 2026-09-28 09:51:51 +02:00
33 changed files with 297 additions and 1532 deletions

View File

@@ -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 + API Host:** `EnvelopeGenerator.Server` (ASP.NET Core 8.0, merged Blazor + Web API host) **Backend:** EnvelopeGenerator.API (ASP.NET Core 8.0)
**Proxy:** YARP in `EnvelopeGenerator.Server` is used for external integrations (for example AuthHub forwarding), not for routing app APIs to `EnvelopeGenerator.API`. **Proxy:** YARP in EnvelopeGenerator.Server routes `/api/*` → `localhost:8088` (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,14 +80,18 @@ This section explains every markdown file in the repo: what it contains, why it
## Development Commands ## Development Commands
### Run Active Host ### Run Both Projects (Required)
```powershell ```powershell
# Terminal - Blazor + Web API host # Terminal 1 - API Backend
cd EnvelopeGenerator.API
dotnet run
# Terminal 2 - Blazor Frontend
cd EnvelopeGenerator.Server\EnvelopeGenerator.Server cd EnvelopeGenerator.Server\EnvelopeGenerator.Server
dotnet run dotnet run
``` ```
**Critical:** `EnvelopeGenerator.Server` is the active runtime host for both UI and API controllers. **Critical:** Both must run simultaneously. EnvelopeGenerator.Server proxy forwards `/api/*` to API.
### Build ### Build
```powershell ```powershell
@@ -100,10 +104,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 + API controllers EnvelopeGenerator.Server/ # Server-side Blazor components
├─ 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
@@ -152,13 +156,15 @@ 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)
### Sender Workflow Endpoint ### Missing Granular Endpoints (Need to Create)
``` ```
POST /api/Envelope # Create/update/save/send envelope workflow payload POST /api/Envelope/draft # Create draft envelope
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.
@@ -193,7 +199,7 @@ Routes `/api/*`, `/swagger/*`, `/openapi/*`, `/scalar/*` → `https://localhost:
} }
``` ```
### Server Host Config (`EnvelopeGenerator.Server/EnvelopeGenerator.Server/appsettings.json`) ### API Config (`API/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
@@ -231,15 +237,16 @@ 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 EnvelopeGenerator.Server (`dotnet run` in `EnvelopeGenerator.Server\EnvelopeGenerator.Server`) 1. Start API (`dotnet run` in `EnvelopeGenerator.API`)
2. Navigate to `https://localhost:5131` (or check console output for port) 2. Start EnvelopeGenerator.Server (`dotnet run` in `EnvelopeGenerator.Server\EnvelopeGenerator.Server`)
3. Test sender login at `/sender/login` 3. Navigate to `https://localhost:5131` (or check console output for port)
4. Test receiver flow at `/envelope/login/{envelopeKey}` 4. Test sender login at `/sender/login`
5. Test receiver flow at `/envelope/login/{envelopeKey}`
## Database ## Database
**SQL Server** (DD_ECM) **SQL Server** (DD_ECM)
- Connection string in `EnvelopeGenerator.Server/EnvelopeGenerator.Server/appsettings.json` - Connection string in `API/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
@@ -313,4 +320,5 @@ 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:**
- App host (UI + API): `https://localhost:5131` or `http://localhost:7192` - API: `https://localhost:8088`
- WebUI: `https://localhost:5131` or `http://localhost:7192`

View File

@@ -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` — deprecated project (no longer part of the active solution/runtime path) - `EnvelopeGenerator.API` — still exists in the solution, but the current merged app host is `EnvelopeGenerator.Server`
### 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 in `EnvelopeGenerator.API`; the active merged host contains controller endpoints directly. Do not assume API behavior lives only in `EnvelopeGenerator.API`; the active merged host contains controller endpoints directly.
--- ---

View File

@@ -49,7 +49,7 @@ public sealed record Signature
public string? Position public string? Position
{ {
get => _position; get => _position;
init => _position = string.IsNullOrWhiteSpace(value) ? value : null; init => _position = string.IsNullOrWhiteSpace(value) ? null : value;
} }
/// <summary> /// <summary>
@@ -62,4 +62,4 @@ public sealed record Signature
/// <b>Example:</b> "Berlin" ? rendered as "Berlin, 26.01.2025" /// <b>Example:</b> "Berlin" ? rendered as "Berlin, 26.01.2025"
/// </summary> /// </summary>
public required string Place { get; init; } public required string Place { get; init; }
} }

View File

@@ -1,21 +0,0 @@
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Domain.Entities;
using MediatR;
namespace EnvelopeGenerator.Application.Common.Notifications.EnvelopeDeleted;
public record EnvelopeDeletedNotification : INotification, ISendMailNotification
{
public required Envelope Envelope { get; init; }
public required EnvelopeReceiver EnvelopeReceiver { get; init; }
public required string Reason { get; init; }
public required string SenderReference { get; init; }
public EmailTemplateType TemplateType => EmailTemplateType.DocumentDeleted;
public string EmailAddress => EnvelopeReceiver.Receiver?.EmailAddress
?? throw new InvalidOperationException("Receiver email is missing for EnvelopeDeletedNotification.");
}

View File

@@ -1,42 +0,0 @@
using DigitalData.Core.Abstraction.Application.Repository;
using DigitalData.EmailProfilerDispatcher.Abstraction.Entities;
using EnvelopeGenerator.Application.Common.Configurations;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Domain.Entities;
using Microsoft.Extensions.Options;
namespace EnvelopeGenerator.Application.Common.Notifications.EnvelopeDeleted.Handlers;
public class SendDeletedMailHandler : SendMailHandler<EnvelopeDeletedNotification>
{
public SendDeletedMailHandler(
IRepository<EmailTemplate> tempRepo,
IRepository<EmailOut> emailOutRepo,
IOptions<MailParams> mailParamsOptions,
IOptions<DispatcherParams> dispatcherParamsOptions)
: base(tempRepo, emailOutRepo, mailParamsOptions, dispatcherParamsOptions)
{
}
protected override void ConfigureEmailOut(EnvelopeDeletedNotification notification, EmailOut emailOut)
{
emailOut.ReferenceString = notification.EmailAddress;
emailOut.ReferenceId = notification.EnvelopeReceiver.ReceiverId;
emailOut.WfId = (int)EnvelopeStatus.MessageDeletionSent;
}
protected override Dictionary<string, string> CreatePlaceHolders(EnvelopeDeletedNotification notification)
{
var senderName = notification.Envelope.User?.Email;
if (string.IsNullOrWhiteSpace(senderName))
senderName = notification.SenderReference;
return new Dictionary<string, string>
{
["[NAME_RECEIVER]"] = notification.EnvelopeReceiver.Name ?? string.Empty,
["[NAME_SENDER]"] = senderName ?? string.Empty,
["[DOCUMENT_TITLE]"] = notification.Envelope.Title ?? string.Empty,
["[REASON]"] = notification.Reason
};
}
}

View File

@@ -65,18 +65,21 @@ public static class DependencyInjection
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, EnvelopeReceiverResolutionBehavior>(); cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, EnvelopeReceiverResolutionBehavior>();
// 1. AnnotationBehavior - Saves annotations (executes second) // 1. AnnotationBehavior - Saves annotations (executes second)
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, SaveSignatureBehavior>();
// 2. AnnotationBehavior - Saves annotations for legacy web flow
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, AnnotationBehavior>(); cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, AnnotationBehavior>();
// 2. DocStatusBehavior - Creates document status (executes third) // 3. DocStatusBehavior - Creates document status
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, DocStatusBehavior>(); cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, DocStatusBehavior>();
// 3. HistoryBehavior - Records history (executes fourth) // 4. HistoryBehavior - Records history
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, HistoryBehavior>(); cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, HistoryBehavior>();
// 4. SendSignedMailBehavior - Sends notification email (executes LAST, only if all previous succeed) // 5. SendSignedMailBehavior - Sends notification email (executes LAST)
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, SendSignedMailBehavior>(); cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, SendSignedMailBehavior>();
}); });
return services; return services;
} }
} }

View File

@@ -43,7 +43,11 @@ public class EnvelopeReceiverResolutionBehavior : IPipelineBehavior<SigningComma
// If EnvelopeReceiver is not provided, query it from database // If EnvelopeReceiver is not provided, query it from database
if (request.EnvelopeReceiver is null) if (request.EnvelopeReceiver is null)
{ {
var er = await _erRepo.Query.Where(request, notnull: true).SingleOrDefaultAsync(cancellationToken) var er = await _erRepo.Query
.Where(request, notnull: true)
.Include(x => x.Envelope)
.Include(x => x.Receiver)
.SingleOrDefaultAsync(cancellationToken)
?? throw new NotFoundException("EnvelopeReceiver not found"); ?? throw new NotFoundException("EnvelopeReceiver not found");
request.SetEnvelopeReceiver(_mapper.Map<EnvelopeReceiverDto>(er)); request.SetEnvelopeReceiver(_mapper.Map<EnvelopeReceiverDto>(er));

View File

@@ -50,8 +50,8 @@ public class SaveSignatureBehavior : IPipelineBehavior<SigningCommand, Unit>
throw new BadRequestException($"Signatures are required for saving signature behavior."); throw new BadRequestException($"Signatures are required for saving signature behavior.");
var elements = await _elementRepo var elements = await _elementRepo
.Where(e => e.Document.EnvelopeId == request.Envelope.Id) .Where(e => e.Document.EnvelopeId == request.EnvelopeReceiver.EnvelopeId)
.Where(e => e.ReceiverId == request.Receiver.Id) .Where(e => e.ReceiverId == request.EnvelopeReceiver.ReceiverId)
.ToListAsync(cancel); .ToListAsync(cancel);
foreach (var element in elements) foreach (var element in elements)

View File

@@ -1,4 +1,5 @@
using EnvelopeGenerator.Application.Common.Dto; using DigitalData.Core.Abstraction.Application.Repository;
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;
@@ -12,29 +13,23 @@ 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>
public string? Title { get; set; } [Required]
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>
public string? Message { get; set; } [Required]
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>
@@ -60,14 +55,4 @@ public record CreateEnvelopeCommand : IRequest<EnvelopeDto?>
[JsonIgnore] [JsonIgnore]
[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);

View File

@@ -2,14 +2,9 @@
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;
@@ -24,16 +19,6 @@ 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>
@@ -55,321 +40,10 @@ 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 now = DateTime.Now; var envelope = request.UseSQLExecutor
var title = request.Title?.Trim() ?? string.Empty; ? await Executor.CreateEnvelopeAsync(request.UserId, request.Title, request.Message, request.TFAEnabled, cancel)
var message = request.Message?.Trim() ?? string.Empty; : await Repository.CreateAsync(request, cancel);
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;
}
}

View File

@@ -1,25 +0,0 @@
using System.ComponentModel.DataAnnotations;
using EnvelopeGenerator.Domain.Constants;
using MediatR;
namespace EnvelopeGenerator.Application.Envelopes.Commands;
public record DeleteEnvelopeResultDto(int EnvelopeId, EnvelopeStatus AppliedStatus, bool IsNoOp);
public record DeleteEnvelopeCommand : IRequest<DeleteEnvelopeResultDto>
{
[Required]
public int EnvelopeId { get; init; }
[Required]
[MinLength(1)]
public string Reason { get; init; } = string.Empty;
internal int UserId { get; private set; }
public DeleteEnvelopeCommand WithAuth(int userId)
{
UserId = userId;
return this;
}
}

View File

@@ -1,89 +0,0 @@
using EnvelopeGenerator.Application.Common.Notifications.EnvelopeDeleted;
using DigitalData.Core.Abstraction.Application.Repository;
using DigitalData.Core.Exceptions;
using EnvelopeGenerator.Application.Histories.Commands;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Domain.Entities;
using MediatR;
using Microsoft.EntityFrameworkCore;
using System.Linq;
namespace EnvelopeGenerator.Application.Envelopes.Commands;
public class DeleteEnvelopeCommandHandler : IRequestHandler<DeleteEnvelopeCommand, DeleteEnvelopeResultDto>
{
private readonly IRepository<Envelope> _envelopeRepository;
private readonly IMediator _mediator;
public DeleteEnvelopeCommandHandler(
IRepository<Envelope> envelopeRepository,
IMediator mediator)
{
_envelopeRepository = envelopeRepository;
_mediator = mediator;
}
public async Task<DeleteEnvelopeResultDto> Handle(DeleteEnvelopeCommand request, CancellationToken cancel)
{
var reason = request.Reason?.Trim();
if (string.IsNullOrWhiteSpace(reason))
throw new BadRequestException("Please provide a reason.");
var envelope = await _envelopeRepository.Query
.Include(e => e.User)
.Include(e => e.EnvelopeReceivers)
.ThenInclude(er => er.Receiver)
.FirstOrDefaultAsync(e => e.Id == request.EnvelopeId, cancel)
?? throw new NotFoundException($"Envelope {request.EnvelopeId} not found.");
if (envelope.UserId != request.UserId)
throw new ForbiddenException();
if (envelope.Status is EnvelopeStatus.EnvelopeDeleted or EnvelopeStatus.EnvelopeWithdrawn)
return new DeleteEnvelopeResultDto(envelope.Id, envelope.Status, IsNoOp: true);
var targetStatus = envelope.Status > EnvelopeStatus.EnvelopeSaved
? EnvelopeStatus.EnvelopeWithdrawn
: EnvelopeStatus.EnvelopeDeleted;
var changedWhen = DateTime.Now;
envelope.Status = targetStatus;
envelope.Comment = reason;
envelope.ChangedWhen = changedWhen;
await _envelopeRepository.UpdateAsync(e =>
{
e.Status = targetStatus;
e.Comment = reason;
e.ChangedWhen = changedWhen;
}, e => e.Id == envelope.Id, cancel);
var userReference = envelope.User?.Email;
if (string.IsNullOrWhiteSpace(userReference))
throw new BadRequestException("Envelope sender email is missing.");
await _mediator.Send(new CreateHistoryCommand
{
EnvelopeId = envelope.Id,
UserReference = userReference,
Status = targetStatus,
Comment = reason
}, cancel);
foreach (var envelopeReceiver in envelope.EnvelopeReceivers ?? Enumerable.Empty<EnvelopeReceiver>())
{
if (envelopeReceiver.Receiver is null)
continue;
await _mediator.Publish(new EnvelopeDeletedNotification
{
Envelope = envelope,
EnvelopeReceiver = envelopeReceiver,
Reason = reason,
SenderReference = userReference
}, cancel);
}
return new DeleteEnvelopeResultDto(envelope.Id, targetStatus, IsNoOp: false);
}
}

View File

@@ -214,4 +214,4 @@ namespace EnvelopeGenerator.Infrastructure
#endif #endif
} }
} }
} }

View File

@@ -204,16 +204,24 @@ public abstract class EGDbContextBase : DbContext
#region Trigger #region Trigger
// Configure entities to handle database triggers // Configure entities to handle database triggers
void AddTrigger<T>() where T : class => _triggers void AddTrigger<T>() where T : class
.Where(t => t.Key == typeof(T).Name) {
.SelectMany(t => t.Value) var entityName = typeof(T).Name;
.ForEach(tName =>
var configuredTriggerNames = _triggers
.Where(t => t.Key == entityName)
.SelectMany(t => t.Value)
.Distinct()
.ToList();
foreach (var triggerName in configuredTriggerNames)
{ {
#if NET #if NET
modelBuilder.Entity<T>().ToTable(tb => tb.HasTrigger(tName)); modelBuilder.Entity<T>().ToTable(tb => tb.HasTrigger(triggerName));
#endif #endif
_logger?.LogInformation("Trigger '{triggerName}' has been added to the '{entityName}' entity.", tName, typeof(T).Name); _logger?.LogInformation("Trigger '{triggerName}' has been added to the '{entityName}' entity.", triggerName, entityName);
}); }
}
// TODO: call add trigger methods with attributes and reflection // TODO: call add trigger methods with attributes and reflection
AddTrigger<Config>(); AddTrigger<Config>();
@@ -243,4 +251,4 @@ public abstract class EGDbContextBase : DbContext
#if NETFRAMEWORK #if NETFRAMEWORK
} }
#endif #endif

View File

@@ -0,0 +1,10 @@
namespace EnvelopeGenerator.Server.Client.Models;
public sealed record SignatureSubmitDto
{
public required int Id { get; init; }
public required string DataUrl { get; init; }
public required string FullName { get; init; }
public string? Position { get; init; }
public required string Place { get; init; }
}

View File

@@ -1,5 +1,4 @@
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;
@@ -13,8 +12,6 @@ namespace EnvelopeGenerator.Server.Client.Services;
public class EnvelopeService(IHttpClientFactory clientFactory) public class EnvelopeService(IHttpClientFactory clientFactory)
{ {
private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web); private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
private sealed record DeleteEnvelopeRequest(string Reason);
private sealed record ErrorResponse(string? Message);
/// <summary> /// <summary>
/// Fetches envelopes from the API with optional filters. /// Fetches envelopes from the API with optional filters.
@@ -64,83 +61,4 @@ public class EnvelopeService(IHttpClientFactory clientFactory)
return await response.Content.ReadFromJsonAsync<IEnumerable<EnvelopeDto>>(_jsonOptions, cancel); return await response.Content.ReadFromJsonAsync<IEnumerable<EnvelopeDto>>(_jsonOptions, cancel);
} }
public async Task DeleteAsync(int envelopeId, string reason, CancellationToken cancel = default)
{
var httpClient = clientFactory.CreateClient("EnvelopeGenerator.Server");
using var request = new HttpRequestMessage(HttpMethod.Delete, $"/api/Envelope/{envelopeId}")
{
Content = JsonContent.Create(new DeleteEnvelopeRequest(reason), options: _jsonOptions)
};
var response = await httpClient.SendAsync(request, cancel);
if (response.IsSuccessStatusCode)
return;
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 delete envelope. Status: {statusCode} ({reasonPhrase}){(string.IsNullOrWhiteSpace(message) ? string.Empty : $" - {message}")}",
null,
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);
}
} }

View File

@@ -1,6 +1,7 @@
using System.Net.Http; using System.Net.Http;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Server.Client.Models; using EnvelopeGenerator.Server.Client.Models;
namespace EnvelopeGenerator.Server.Client.Services; namespace EnvelopeGenerator.Server.Client.Services;
@@ -21,4 +22,32 @@ public class SignatureService(IHttpClientFactory httpClientFactory)
var result = await response.Content.ReadFromJsonAsync<List<SignatureDto>>(_jsonOptions, cancel); var result = await response.Content.ReadFromJsonAsync<List<SignatureDto>>(_jsonOptions, cancel);
return result ?? []; return result ?? [];
} }
public async Task SubmitAsync(string envelopeKey, IEnumerable<SignatureSubmitDto> signatures, CancellationToken cancel = default)
{
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
var payload = signatures
.Select(signature => new Signature
{
Id = signature.Id,
DataUrl = signature.DataUrl,
FullName = signature.FullName,
Position = signature.Position,
Place = signature.Place
})
.ToList();
var response = await http.PostAsJsonAsync(
$"/api/Signature/submit/{Uri.EscapeDataString(envelopeKey)}",
payload,
cancel);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(cancel);
throw new HttpRequestException(
$"Failed to submit signature for envelope {envelopeKey}: {(int)response.StatusCode} {response.StatusCode} - {body}");
}
}
} }

View File

@@ -3,11 +3,9 @@
@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.Common.Dto @using EnvelopeGenerator.Application.EnvelopeReceivers.Commands
@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
@@ -15,8 +13,7 @@
@inject AppVersionService AppVersion @inject AppVersionService AppVersion
@inject ILogger<EnvelopeSenderEditorPage> Logger @inject ILogger<EnvelopeSenderEditorPage> Logger
@inject EnvelopeReceiverPageDataService ReceiverPageDataService @inject EnvelopeReceiverPageDataService ReceiverPageDataService
@inject EnvelopeService EnvelopeService @inject EnvelopeReceiverService EnvelopeReceiverService
@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" />
@@ -172,55 +169,41 @@
style="display: none;" /> style="display: none;" />
</label> </label>
@if (_pdfLoaded && _signatureFields.Count > 0) @if (_pdfLoaded)
{ {
<button class="pdf-toolbar__btn pdf-toolbar__btn--reset" @* Clear all fields *@
@onclick="ClearAllFieldsAsync" @if (_signatureFields.Count > 0)
title="Alle Signaturfelder entfernen"> {
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16"> <button class="pdf-toolbar__btn pdf-toolbar__btn--reset"
<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" /> @onclick="ClearAllFieldsAsync"
<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" /> title="Alle Signaturfelder entfernen">
</svg> <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
<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>
@@ -375,7 +358,7 @@
@* ── Save result popup (success + error) ── *@ @* ── Save result popup (success + error) ── *@
<DxPopup @bind-Visible="_savePopupVisible" <DxPopup @bind-Visible="_savePopupVisible"
HeaderText="@(_saveErrorMessage is null ? _saveSuccessHeader : "Fehler")" HeaderText="@(_saveErrorMessage is null ? "Umschlag erstellt" : "Fehler")"
Width="460px" Width="460px"
MaxWidth="95vw" MaxWidth="95vw"
ShowFooter="true" ShowFooter="true"
@@ -394,10 +377,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;">
@_saveSuccessTitle Umschlag wurde erfolgreich erstellt.
</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;">
@_saveSuccessBody Der Umschlag wurde gespeichert und die Empfänger wurden benachrichtigt.
</p> </p>
</div> </div>
</div> </div>
@@ -427,21 +410,12 @@
<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-outline-secondary" <button class="btn btn-primary"
@onclick="() => _savePopupVisible = false" @onclick="GoToDashboard"
style="border-radius:6px;padding:0.5rem 1.25rem;font-weight:500;"> style="background:linear-gradient(135deg,#059669,#047857);border:none;border-radius:6px;
Schließen padding:0.5rem 1.5rem;font-weight:600;color:#fff;">
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
{ {
@@ -460,9 +434,6 @@
[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
@@ -481,9 +452,6 @@
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"
@@ -492,10 +460,6 @@
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;
@@ -517,7 +481,6 @@
// ── 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 ──
@@ -537,27 +500,12 @@
if (string.IsNullOrWhiteSpace(Esid)) if (string.IsNullOrWhiteSpace(Esid))
{ {
var sid = Guid.NewGuid().ToString("N"); var sid = Guid.NewGuid().ToString("N");
var editQuery = EnvelopeId.HasValue && EnvelopeId.Value > 0 NavigationManager.NavigateTo($"/sender/editor?esid={sid}", forceLoad: false);
? $"&envelopeId={EnvelopeId.Value}"
: string.Empty;
NavigationManager.NavigateTo($"/sender/editor?esid={sid}{editQuery}", forceLoad: false);
} }
} }
protected override async Task OnParametersSetAsync() protected override void OnParametersSet()
{ {
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)
@@ -577,90 +525,6 @@
} }
} }
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)
{ {
@@ -806,48 +670,8 @@
NavigationManager.NavigateTo("/sender"); NavigationManager.NavigateTo("/sender");
} }
async Task SaveDraftAsync() // ── Save — POST /api/EnvelopeReceiver ──
{ 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;
@@ -893,38 +717,57 @@
try try
{ {
var envelopeId = _editingEnvelopeId // ── Build request ──
?? EnvelopeId // Document: use the ORIGINAL pdf (without placeholder burn-in) as base64
?? _loadedEnvelopeId var docBase64 = Convert.ToBase64String(_originalPdfBytes);
?? GetEnvelopeIdFromCurrentUri();
if (!envelopeId.HasValue || envelopeId.Value <= 0) // Build receivers list — each receiver gets their own signature fields
// Coordinate conversion: PDF points → inches (DB stores inches)
var receiversCmd = _receivers.Select(receiver =>
{ {
var saveResult = await EnvelopeService.UpsertAsync(BuildCreateEnvelopeCommand(send: false)) var fields = _signatureFields
?? throw new InvalidOperationException("Draft save did not return a result."); .Where(f => f.ReceiverName == receiver.FullName)
.Select(f => new DocReceiverElementCreateDto(
X: f.XPt / 72.0,
Y: f.YPt / 72.0,
Page: f.Page))
.ToList();
envelopeId = saveResult.Id; return new ReceiverGetOrCreateCommand
EnvelopeId = saveResult.Id; {
_loadedEnvelopeId = saveResult.Id; EmailAddress = receiver.Email,
_editingEnvelopeId = saveResult.Id; Salution = receiver.FullName,
} PhoneNumber = string.IsNullOrWhiteSpace(receiver.PhoneNumber)
? null
: receiver.PhoneNumber,
DocReceiverElements = fields,
};
}).ToList();
var sendResult = await EnvelopeService.UpsertAsync(BuildCreateEnvelopeCommand(send: true) with { EnvelopeId = envelopeId }) var command = new CreateEnvelopeReceiverCommand
?? 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,
};
Logger.LogInformation("[SenderEditor] Envelope sent from draft. Id={Id} Status={Status}", sendResult.Id, sendResult.Status); var result = await EnvelopeReceiverService.CreateAsync(command);
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 send envelope draft"); Logger.LogError(ex, "[SenderEditor] Failed to create envelope");
_saveErrorMessage = ex.Message; _saveErrorMessage = ex.Message;
_savePopupVisible = true; _savePopupVisible = true;
} }
@@ -934,71 +777,6 @@
} }
} }
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()
{ {

View File

@@ -73,7 +73,7 @@
Neuer Umschlag Neuer Umschlag
</button> </button>
<button class="sender-btn" @onclick="EditEnvelope" disabled="@(_selectedEnvelope == null || !IsEnvelopeEditable(_selectedEnvelope))" title="Ausgewählten Umschlag bearbeiten"> <button class="sender-btn" @onclick="EditEnvelope" disabled="@(_selectedEnvelope == null || IsEnvelopeSent(_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>
@@ -521,54 +521,6 @@
</BodyContentTemplate> </BodyContentTemplate>
</DxPopup> </DxPopup>
<DxPopup @bind-Visible="@_deletePopupVisible"
HeaderText="Umschlag löschen"
Width="560px"
MaxWidth="95vw"
ShowFooter="true"
CloseOnOutsideClick="false"
ShowCloseButton="true"
CloseOnEscape="true"
ShowHeader="true"
CssClass="sender-delete-popup">
<BodyContentTemplate>
<div style="display:flex;flex-direction:column;gap:0.75rem;">
<p style="margin:0;color:#374151;font-size:0.9rem;line-height:1.45;">
Bitte geben Sie einen Grund an, warum der Umschlag gelöscht/zurückgezogen werden soll.
</p>
@if (_selectedEnvelope is not null)
{
<p style="margin:0;color:#6b7280;font-size:0.825rem;">
Umschlag: <strong>#@_selectedEnvelope.Id - @_selectedEnvelope.Title</strong>
</p>
}
<DxMemo @bind-Text="_deleteReason"
NullText="Begründung eingeben..."
Rows="5"
MaxLength="2000"
CssClass="w-100" />
@if (!string.IsNullOrWhiteSpace(_deleteErrorMessage))
{
<div class="alert alert-danger" style="margin:0;padding:0.5rem 0.75rem;font-size:0.85rem;">
@_deleteErrorMessage
</div>
}
</div>
</BodyContentTemplate>
<FooterContentTemplate>
<div style="display:flex;justify-content:flex-end;gap:0.5rem;width:100%;">
<DxButton Text="Abbrechen"
RenderStyle="ButtonRenderStyle.Secondary"
Click="CloseDeletePopup"
Enabled="@(!_isDeletingEnvelope)" />
<DxButton Text="Löschen"
RenderStyle="ButtonRenderStyle.Danger"
Click="ConfirmDeleteEnvelopeAsync"
Enabled="@(!_isDeletingEnvelope)" />
</div>
</FooterContentTemplate>
</DxPopup>
@code { @code {
private IEnumerable<EnvelopeDto>? _allEnvelopes; private IEnumerable<EnvelopeDto>? _allEnvelopes;
private IEnumerable<EnvelopeDto>? _activeEnvelopes; private IEnumerable<EnvelopeDto>? _activeEnvelopes;
@@ -597,11 +549,6 @@
private bool _exportSelectedRowsOnly = false; private bool _exportSelectedRowsOnly = false;
private bool _isExporting = false; private bool _isExporting = false;
private bool _deletePopupVisible = false;
private bool _isDeletingEnvelope = false;
private string _deleteReason = string.Empty;
private string? _deleteErrorMessage;
// Stable keys used to store/restore each grid's layout in UserPreferences.GridLayouts // Stable keys used to store/restore each grid's layout in UserPreferences.GridLayouts
private const string GridKeyActive = "sender.active-envelopes"; private const string GridKeyActive = "sender.active-envelopes";
private const string GridKeyCompleted = "sender.completed-envelopes"; private const string GridKeyCompleted = "sender.completed-envelopes";
@@ -728,59 +675,13 @@
void EditEnvelope() void EditEnvelope()
{ {
if (_selectedEnvelope == null) return; if (_selectedEnvelope == null) return;
if (!IsEnvelopeEditable(_selectedEnvelope)) return; // TODO: Navigate to envelope editor
Navigation.NavigateTo($"/sender/editor?envelopeId={_selectedEnvelope.Id}");
} }
void DeleteEnvelope() void DeleteEnvelope()
{ {
if (_selectedEnvelope == null) return; if (_selectedEnvelope == null) return;
_deleteReason = string.Empty; // TODO: Show delete confirmation dialog
_deleteErrorMessage = null;
_deletePopupVisible = true;
}
void CloseDeletePopup()
{
if (_isDeletingEnvelope)
return;
_deletePopupVisible = false;
_deleteErrorMessage = null;
}
async Task ConfirmDeleteEnvelopeAsync()
{
if (_selectedEnvelope is null)
return;
if (string.IsNullOrWhiteSpace(_deleteReason))
{
_deleteErrorMessage = "Bitte geben Sie einen Löschgrund an.";
return;
}
_isDeletingEnvelope = true;
_deleteErrorMessage = null;
await InvokeAsync(StateHasChanged);
try
{
await EnvelopeService.DeleteAsync(_selectedEnvelope.Id, _deleteReason.Trim());
_deletePopupVisible = false;
_selectedEnvelope = null;
await LoadEnvelopesAsync();
}
catch (Exception ex)
{
_deleteErrorMessage = ex.Message;
}
finally
{
_isDeletingEnvelope = false;
await InvokeAsync(StateHasChanged);
}
} }
async Task LogoutAsync() async Task LogoutAsync()
@@ -798,12 +699,6 @@
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

View File

@@ -12,6 +12,7 @@
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService @inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverAuthorizationService ReceiverAuthorizationService @inject EnvelopeGenerator.Server.Services.EnvelopeReceiverAuthorizationService ReceiverAuthorizationService
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverPageDataService PageDataService @inject EnvelopeGenerator.Server.Services.EnvelopeReceiverPageDataService PageDataService
@inject SignatureService SignatureService
@inject AppVersionService AppVersion @inject AppVersionService AppVersion
@inject IMemoryCache MemoryCache @inject IMemoryCache MemoryCache
@inject ILogger<ReceiverSignedPage> Logger @inject ILogger<ReceiverSignedPage> Logger
@@ -122,6 +123,12 @@
</p> </p>
</div> </div>
</div> </div>
@if (!string.IsNullOrWhiteSpace(_submitErrorMessage))
{
<div class="alert alert-danger mb-0" role="alert" style="font-size: 0.85rem;">
@_submitErrorMessage
</div>
}
</BodyContentTemplate> </BodyContentTemplate>
<FooterContentTemplate> <FooterContentTemplate>
<div class="d-flex gap-2 justify-content-end w-100" style="padding: 0.5rem 0;"> <div class="d-flex gap-2 justify-content-end w-100" style="padding: 0.5rem 0;">
@@ -161,17 +168,48 @@
// ----- Submit / logout state ----- // ----- Submit / logout state -----
bool _isLoggingOut = false; bool _isLoggingOut = false;
bool _submitConfirmVisible = false; bool _submitConfirmVisible = false;
string? _submitErrorMessage;
void OpenSubmitConfirmPopup() => _submitConfirmVisible = true; void OpenSubmitConfirmPopup() => _submitConfirmVisible = true;
async Task SubmitAndLogoutAsync() async Task SubmitAndLogoutAsync()
{ {
if (_isLoggingOut) return; if (_isLoggingOut) return;
if (string.IsNullOrWhiteSpace(EnvelopeKey) || _sig is null)
return;
_isLoggingOut = true; _isLoggingOut = true;
_submitConfirmVisible = false; _submitConfirmVisible = false;
_submitErrorMessage = null;
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
await AuthService.LogoutEnvelopeReceiverAsync(EnvelopeKey!);
Navigation.NavigateTo("/", forceLoad: true); try
{
if (_signatures.Count == 0)
throw new InvalidOperationException("No signature fields found for receiver.");
var payload = _signatures.Select(field => new SignatureSubmitDto
{
Id = field.Id,
DataUrl = _sig.DataUrl,
FullName = _sig.FullName,
Position = string.IsNullOrWhiteSpace(_sig.Position) ? null : _sig.Position,
Place = _sig.Place
}).ToList();
await SignatureService.SubmitAsync(EnvelopeKey, payload);
await AuthService.LogoutEnvelopeReceiverAsync(EnvelopeKey);
Navigation.NavigateTo("/", forceLoad: true);
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to finalize signature for {EnvelopeKey}", EnvelopeKey);
_submitErrorMessage = "Die Unterschrift konnte nicht abgeschlossen werden. Bitte versuchen Sie es erneut.";
_isLoggingOut = false;
await InvokeAsync(StateHasChanged);
}
} }
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()

View File

@@ -27,8 +27,6 @@ namespace EnvelopeGenerator.Server.Controllers;
[Authorize] [Authorize]
public class EnvelopeController : ControllerBase public class EnvelopeController : ControllerBase
{ {
public record DeleteEnvelopeRequest(string Reason);
private readonly ILogger<EnvelopeController> _logger; private readonly ILogger<EnvelopeController> _logger;
private readonly IMediator _mediator; private readonly IMediator _mediator;
@@ -95,6 +93,7 @@ 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)
@@ -109,18 +108,4 @@ public class EnvelopeController : ControllerBase
else else
return Ok(res); return Ok(res);
} }
}
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
[HttpDelete("{id:int}")]
public async Task<IActionResult> DeleteAsync([FromRoute] int id, [FromBody] DeleteEnvelopeRequest request)
{
var result = await _mediator.Send(new DeleteEnvelopeCommand
{
EnvelopeId = id,
Reason = request?.Reason ?? string.Empty
}.WithAuth(User.GetId()));
return Ok(result);
}
}

View File

@@ -1,7 +1,10 @@
using EnvelopeGenerator.Server.Extensions; using EnvelopeGenerator.Server.Extensions;
using EnvelopeGenerator.Application.Common.Dto; using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Extensions; using EnvelopeGenerator.Application.Common.Extensions;
using EnvelopeGenerator.Application.DocReceiverElements.Commands;
using EnvelopeGenerator.Application.Documents.Queries; using EnvelopeGenerator.Application.Documents.Queries;
using EnvelopeGenerator.Application.EnvelopeReceivers.Queries;
using EnvelopeGenerator.Application.Histories.Queries;
using EnvelopeGenerator.Domain.Constants; using EnvelopeGenerator.Domain.Constants;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@@ -18,13 +21,15 @@ namespace EnvelopeGenerator.Server.Controllers;
public class SignatureController : ControllerBase public class SignatureController : ControllerBase
{ {
private readonly IMediator _mediator; private readonly IMediator _mediator;
private readonly ILogger<SignatureController> _logger;
/// <summary> /// <summary>
/// Initializes a new instance of <see cref="SignatureController"/>. /// Initializes a new instance of <see cref="SignatureController"/>.
/// </summary> /// </summary>
public SignatureController(IMediator mediator) public SignatureController(IMediator mediator, ILogger<SignatureController> logger)
{ {
_mediator = mediator; _mediator = mediator;
_logger = logger;
} }
//TODO: update to use signature query //TODO: update to use signature query
@@ -54,4 +59,46 @@ public class SignatureController : ControllerBase
else else
return Ok(rcvSignatures); return Ok(rcvSignatures);
} }
/// <summary>
/// Finalizes the receiver signing flow and persists signature data to TBSIG_DOCUMENT_RECEIVER_ELEMENT.
/// Also triggers status/history/mail side effects through SigningCommand pipeline.
/// </summary>
[Authorize(Policy = AuthPolicy.Receiver)]
[HttpPost("submit/{envelopeKey}")]
public async Task<IActionResult> Submit(
[FromRoute] string envelopeKey,
[FromBody] IEnumerable<Signature>? signatures,
CancellationToken cancel)
{
if (signatures is null)
return BadRequest("Signature payload is required.");
var uuid = User.EnvelopeUuid();
var receiverSignature = User.ReceiverSignature();
if (await _mediator.IsSignedAsync(uuid, receiverSignature, cancel))
return Problem(statusCode: StatusCodes.Status409Conflict);
if (await _mediator.AnyHistoryAsync(uuid, new[] { EnvelopeStatus.EnvelopeRejected, EnvelopeStatus.DocumentRejected }, cancel))
return Problem(statusCode: StatusCodes.Status423Locked);
try
{
await _mediator.Send(new SigningCommand
{
Envelope = new() { Uuid = uuid },
Receiver = new() { Signature = receiverSignature },
ReceiverAppType = ReceiverAppType.ReceiverUI,
Signatures = signatures.ToList()
}, cancel);
return Ok();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to submit signatures for envelope {EnvelopeKey}", envelopeKey);
throw;
}
}
} }

View File

@@ -69,11 +69,6 @@ public class ExceptionHandlingMiddleware
message = notFoundEx.Message; message = notFoundEx.Message;
break; break;
case ForbiddenException forbiddenEx:
context.Response.StatusCode = (int)HttpStatusCode.Forbidden;
message = forbiddenEx.Message;
break;
default: default:
logger.LogError(exception, "Unhandled exception occurred."); logger.LogError(exception, "Unhandled exception occurred.");
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;

View File

@@ -13,7 +13,7 @@
"commandName": "Project", "commandName": "Project",
"dotnetRunMessages": true, "dotnetRunMessages": true,
"launchBrowser": true, "launchBrowser": true,
"applicationUrl": "https://localhost:8088;http://localhost:5131", "applicationUrl": "https://localhost:8089;http://localhost:5132",
"environmentVariables": { "environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development" "ASPNETCORE_ENVIRONMENT": "Development"
}, },

View File

@@ -49,14 +49,6 @@ 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>

View File

@@ -185,9 +185,10 @@
"TimeLimit": "00:30:00" "TimeLimit": "00:30:00"
}, },
"DbTriggerParams": { "DbTriggerParams": {
"Envelope": [ "TBSIG_ENVELOPE_HISTORY_AFT_INS" ],
"History": [ "TBSIG_ENVELOPE_HISTORY_AFT_INS" ],
"DocReceiverElement": [ "TBSIG_DOCUMENT_RECEIVER_ELEMENT_AFT_INS", "TBSIG_DOCUMENT_RECEIVER_ELEMENT_AFT_UPD" ], "DocReceiverElement": [ "TBSIG_DOCUMENT_RECEIVER_ELEMENT_AFT_INS", "TBSIG_DOCUMENT_RECEIVER_ELEMENT_AFT_UPD" ],
"Envelope": [ "TBSIG_ENVELOPE_HISTORY_AFT_INS" ],
"EnvelopeHistory": [ "TBSIG_ENVELOPE_HISTORY_AFT_INS" ],
"History": [ "TBSIG_ENVELOPE_HISTORY_AFT_INS" ],
"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": [],

View File

@@ -542,40 +542,6 @@ 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;

View File

@@ -1,109 +0,0 @@
using DigitalData.Core.Exceptions;
using DigitalData.EmailProfilerDispatcher.Abstraction.Entities;
using EnvelopeGenerator.Application.Envelopes.Commands;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace EnvelopeGenerator.Tests.Application;
[TestFixture]
public class DeleteEnvelopeCommandTests : TestBase
{
protected override void ConfigureServices(IServiceCollection services)
{
}
[Test]
public async Task DeleteEnvelope_DraftEnvelope_ShouldSetDeleted_CreateHistory_AndQueueDeletionEmail()
{
CancellationToken cancel = default;
var reason = "Invalid receiver data";
var envelope = this.CreateEnvelope(User.Id);
envelope.Status = EnvelopeStatus.EnvelopeCreated;
envelope = await GetRepository<Envelope>().CreateAsync(envelope, cancel);
var createReceiverCmd = this.CreateReceiverCommand();
(var receiver, _) = await Mediator.Send(createReceiverCmd, cancel);
var envReceiver = this.CreateEnvelopeReceiver(envelope.Id, receiver.Id);
await GetRepository<EnvelopeReceiver>().CreateAsync(envReceiver, cancel);
var result = await Mediator.Send(new DeleteEnvelopeCommand
{
EnvelopeId = envelope.Id,
Reason = reason
}.WithAuth(User.Id), cancel);
Assert.That(result.EnvelopeId, Is.EqualTo(envelope.Id));
Assert.That(result.AppliedStatus, Is.EqualTo(EnvelopeStatus.EnvelopeDeleted));
Assert.That(result.IsNoOp, Is.False);
var histories = await GetRepository<History>().Query
.Where(h => h.EnvelopeId == envelope.Id && h.Status == EnvelopeStatus.EnvelopeDeleted)
.ToListAsync(cancel);
Assert.That(histories, Has.Count.EqualTo(1));
Assert.That(histories[0].Comment, Is.EqualTo(reason));
var emails = await GetRepository<EmailOut>().Query
.Where(e => e.WfId == (int)EnvelopeStatus.MessageDeletionSent && e.ReferenceId == receiver.Id)
.ToListAsync(cancel);
Assert.That(emails, Has.Count.EqualTo(1));
Assert.That(emails[0].EmailAddress, Is.EqualTo(receiver.EmailAddress));
}
[Test]
public async Task DeleteEnvelope_SentEnvelope_ShouldSetWithdrawn()
{
CancellationToken cancel = default;
var envelope = this.CreateEnvelope(User.Id);
envelope.Status = EnvelopeStatus.EnvelopeQueued;
envelope = await GetRepository<Envelope>().CreateAsync(envelope, cancel);
var result = await Mediator.Send(new DeleteEnvelopeCommand
{
EnvelopeId = envelope.Id,
Reason = "Sender requested cancellation"
}.WithAuth(User.Id), cancel);
Assert.That(result.AppliedStatus, Is.EqualTo(EnvelopeStatus.EnvelopeWithdrawn));
var histories = await GetRepository<History>().Query
.Where(h => h.EnvelopeId == envelope.Id && h.Status == EnvelopeStatus.EnvelopeWithdrawn)
.ToListAsync(cancel);
Assert.That(histories, Has.Count.EqualTo(1));
}
[Test]
public void DeleteEnvelope_MissingReason_ShouldThrowBadRequest()
{
Assert.ThrowsAsync<BadRequestException>(async () =>
await Mediator.Send(new DeleteEnvelopeCommand
{
EnvelopeId = 123,
Reason = " "
}.WithAuth(User.Id)));
}
[Test]
public async Task DeleteEnvelope_WrongOwner_ShouldThrowForbidden()
{
CancellationToken cancel = default;
var envelope = this.CreateEnvelope(User.Id);
envelope = await GetRepository<Envelope>().CreateAsync(envelope, cancel);
var otherUserCmd = this.CreateUserCommand();
var otherUser = await GetRepository<DigitalData.UserManager.Domain.Entities.User>().CreateAsync(otherUserCmd, cancel);
Assert.ThrowsAsync<ForbiddenException>(async () =>
await Mediator.Send(new DeleteEnvelopeCommand
{
EnvelopeId = envelope.Id,
Reason = "Not allowed"
}.WithAuth(otherUser.Id), cancel));
}
}

View File

@@ -52,7 +52,7 @@ public class Fake
services.AddEnvelopeGeneratorInfrastructureServices(opt => services.AddEnvelopeGeneratorInfrastructureServices(opt =>
{ {
opt.AddDbContext(dbCtxOpt => dbCtxOpt.UseInMemoryDatabase($"EnvelopeGeneratorTestDb_{Guid.NewGuid():N}")); opt.AddDbContext(dbCtxOpt => dbCtxOpt.UseInMemoryDatabase("EnvelopeGeneratorTestDb"));
opt.AddDbTriggerParams(context.Configuration); opt.AddDbTriggerParams(context.Configuration);
@@ -308,4 +308,4 @@ public static class Extensions
.Select(_ => fake.CreateUserCommand()) .Select(_ => fake.CreateUserCommand())
.ToList(); .ToList();
#endregion #endregion
} }

View File

@@ -38,10 +38,7 @@ public abstract class TestBase : Faker
// Add seed email templates // Add seed email templates
foreach (var temp in SeedEmailTemplates) foreach (var temp in SeedEmailTemplates)
{
temp.LangCode ??= "de";
await repo.CreateAsync(temp); await repo.CreateAsync(temp);
}
} }
[TearDown] [TearDown]
@@ -143,4 +140,4 @@ public abstract class TestBase : Faker
ChangedWhen = DateTime.Parse("2025-05-12 10:43:44.290") ChangedWhen = DateTime.Parse("2025-05-12 10:43:44.290")
} }
}; };
} }

View File

@@ -7,10 +7,9 @@
"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": [],
"EmailTemplate": [ "TBSIG_EMAIL_TEMPLATE_AFT_UPD" ] "EmailTemplate": [ "TBSIG_EMAIL_TEMPLATE_AFT_UPD" ]
} }
} }

View File

@@ -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` architecture** (merged Blazor + Web API host). **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.
**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 ## Migration to EnvelopeGenerator.Server + API
### Mapping Table ### Mapping Table

View File

@@ -1,281 +0,0 @@
# 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.