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.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
|
||||
- `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
|
||||
|
||||
### Run Both Projects (Required)
|
||||
### Run Active Host
|
||||
```powershell
|
||||
# Terminal 1 - API Backend
|
||||
cd EnvelopeGenerator.API
|
||||
dotnet run
|
||||
|
||||
# Terminal 2 - Blazor Frontend
|
||||
# Terminal - Blazor + Web API host
|
||||
cd EnvelopeGenerator.Server\EnvelopeGenerator.Server
|
||||
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
|
||||
```powershell
|
||||
@@ -104,10 +100,10 @@ dotnet build EnvelopeGenerator.sln
|
||||
EnvelopeGenerator.Domain/ # Entities (Envelope, Receiver, Document, etc.)
|
||||
EnvelopeGenerator.Application/ # MediatR CQRS (Commands, Queries, Handlers)
|
||||
EnvelopeGenerator.Infrastructure/ # EF Core, SQL executors, repositories
|
||||
EnvelopeGenerator.API/ # Controllers, endpoints
|
||||
EnvelopeGenerator.Server/
|
||||
EnvelopeGenerator.Server/ # Server-side Blazor components
|
||||
EnvelopeGenerator.Server/ # Server-side Blazor + API controllers
|
||||
├─ Components/Pages/ # @rendermode InteractiveServer
|
||||
├─ Controllers/ # Active Web API endpoints
|
||||
EnvelopeGenerator.Server.Client/ # Client-side WASM components
|
||||
├─ Pages/ # @rendermode InteractiveWebAssembly
|
||||
├─ Services/ # HTTP API clients
|
||||
@@ -156,15 +152,13 @@ float inches = (pixelX / canvasWidth) * pageWidthInches;
|
||||
- **Use case:** External API consumers
|
||||
- **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
|
||||
PUT /api/Envelope/{id} # Update metadata
|
||||
POST /api/Envelope # Create/update/save/send envelope workflow payload
|
||||
DELETE /api/Envelope/{id} # Delete with reason
|
||||
POST /api/Envelope/{id}/document # Upload PDF
|
||||
POST /api/Envelope/{id}/receivers # Add receiver
|
||||
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.
|
||||
@@ -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
|
||||
- `AllowedOrigins` - CORS (includes `http://localhost:5131`, `http://localhost:7192`)
|
||||
- `Cache:SignatureCacheExpiration` - Signature persistence timeout
|
||||
@@ -237,16 +231,15 @@ Routes `/api/*`, `/swagger/*`, `/openapi/*`, `/scalar/*` → `https://localhost:
|
||||
**No automated tests exist yet.**
|
||||
|
||||
Manual testing workflow:
|
||||
1. Start API (`dotnet run` in `EnvelopeGenerator.API`)
|
||||
2. Start EnvelopeGenerator.Server (`dotnet run` in `EnvelopeGenerator.Server\EnvelopeGenerator.Server`)
|
||||
3. Navigate to `https://localhost:5131` (or check console output for port)
|
||||
4. Test sender login at `/sender/login`
|
||||
5. Test receiver flow at `/envelope/login/{envelopeKey}`
|
||||
1. Start EnvelopeGenerator.Server (`dotnet run` in `EnvelopeGenerator.Server\EnvelopeGenerator.Server`)
|
||||
2. Navigate to `https://localhost:5131` (or check console output for port)
|
||||
3. Test sender login at `/sender/login`
|
||||
4. Test receiver flow at `/envelope/login/{envelopeKey}`
|
||||
|
||||
## Database
|
||||
|
||||
**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)
|
||||
- Stored procedures: `PRSIG_*` prefix
|
||||
|
||||
@@ -320,5 +313,4 @@ Each envelope maintains independent authentication state.
|
||||
None required. All config in `appsettings.json`.
|
||||
|
||||
**Local dev ports:**
|
||||
- API: `https://localhost:8088`
|
||||
- WebUI: `https://localhost:5131` or `http://localhost:7192`
|
||||
- App host (UI + API): `https://localhost:5131` or `http://localhost:7192`
|
||||
|
||||
@@ -39,7 +39,7 @@ This project contains:
|
||||
- `EnvelopeGenerator.Domain` — domain models, constants, shared abstractions
|
||||
- `EnvelopeGenerator.Infrastructure` — EF Core and infrastructure services
|
||||
- `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
|
||||
- `EnvelopeGenerator.Service`
|
||||
@@ -121,7 +121,7 @@ Current controller set includes:
|
||||
- `SignatureController`
|
||||
- `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 MediatR;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
@@ -13,23 +12,29 @@ namespace EnvelopeGenerator.Application.Envelopes.Commands;
|
||||
/// </summary>
|
||||
public record CreateEnvelopeCommand : IRequest<EnvelopeDto?>
|
||||
{
|
||||
public int? EnvelopeId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Der Titel des Umschlags. Dies ist ein Pflichtfeld.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public required string Title { get; set; }
|
||||
public string? Title { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Die Nachricht, die im Umschlag enthalten sein soll. Dies ist ein Pflichtfeld.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public required string Message { get; set; }
|
||||
public string? Message { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gibt an, ob die Zwei-Faktor-Authentifizierung für den Umschlag aktiviert ist. Standardmäßig false.
|
||||
/// </summary>
|
||||
public bool TFAEnabled { get; set; } = false;
|
||||
|
||||
public bool Send { get; init; }
|
||||
|
||||
public CreateEnvelopeDocumentDto? Document { get; init; }
|
||||
|
||||
public List<CreateEnvelopeReceiverDto> Receivers { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// ID des Absenders
|
||||
/// </summary>
|
||||
@@ -55,4 +60,14 @@ public record CreateEnvelopeCommand : IRequest<EnvelopeDto?>
|
||||
[JsonIgnore]
|
||||
[NotMapped]
|
||||
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 Microsoft.Extensions.DependencyInjection;
|
||||
using DigitalData.Core.Abstraction.Application.Repository;
|
||||
using DigitalData.Core.Exceptions;
|
||||
using EnvelopeGenerator.Domain.Entities;
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
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;
|
||||
|
||||
@@ -19,6 +24,16 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
|
||||
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
@@ -40,10 +55,321 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
|
||||
/// <returns></returns>
|
||||
public async Task<EnvelopeDto?> Handle(CreateEnvelopeCommand request, CancellationToken cancel)
|
||||
{
|
||||
var envelope = request.UseSQLExecutor
|
||||
? await Executor.CreateEnvelopeAsync(request.UserId, request.Title, request.Message, request.TFAEnabled, cancel)
|
||||
: await Repository.CreateAsync(request, cancel);
|
||||
var now = DateTime.Now;
|
||||
var title = request.Title?.Trim() ?? string.Empty;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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.EnvelopeDeleted;
|
||||
|
||||
var changedWhen = DateTime.Now;
|
||||
envelope.Status = targetStatus;
|
||||
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;
|
||||
if (string.IsNullOrWhiteSpace(userReference))
|
||||
|
||||
@@ -214,4 +214,4 @@ namespace EnvelopeGenerator.Infrastructure
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
using EnvelopeGenerator.Application.Envelopes.Commands;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
@@ -94,4 +95,52 @@ public class EnvelopeService(IHttpClientFactory clientFactory)
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
@using DevExpress.Blazor.PdfViewer
|
||||
@using DevExpress.Blazor.Reporting.Models
|
||||
@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.Services
|
||||
@using Microsoft.AspNetCore.WebUtilities
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.Extensions.Caching.Memory
|
||||
@inject IJSRuntime JSRuntime
|
||||
@@ -13,7 +15,8 @@
|
||||
@inject AppVersionService AppVersion
|
||||
@inject ILogger<EnvelopeSenderEditorPage> Logger
|
||||
@inject EnvelopeReceiverPageDataService ReceiverPageDataService
|
||||
@inject EnvelopeReceiverService EnvelopeReceiverService
|
||||
@inject EnvelopeService EnvelopeService
|
||||
@inject IEnvelopeAuthService EnvelopeAuthService
|
||||
@inject IMemoryCache MemoryCache
|
||||
|
||||
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
|
||||
@@ -169,41 +172,55 @@
|
||||
style="display: none;" />
|
||||
</label>
|
||||
|
||||
@if (_pdfLoaded)
|
||||
@if (_pdfLoaded && _signatureFields.Count > 0)
|
||||
{
|
||||
@* Clear all fields *@
|
||||
@if (_signatureFields.Count > 0)
|
||||
{
|
||||
<button class="pdf-toolbar__btn pdf-toolbar__btn--reset"
|
||||
@onclick="ClearAllFieldsAsync"
|
||||
title="Alle Signaturfelder entfernen">
|
||||
<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 class="pdf-toolbar__btn pdf-toolbar__btn--reset"
|
||||
@onclick="ClearAllFieldsAsync"
|
||||
title="Alle Signaturfelder entfernen">
|
||||
<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>
|
||||
}
|
||||
|
||||
<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>
|
||||
|
||||
@@ -358,7 +375,7 @@
|
||||
|
||||
@* ── Save result popup (success + error) ── *@
|
||||
<DxPopup @bind-Visible="_savePopupVisible"
|
||||
HeaderText="@(_saveErrorMessage is null ? "Umschlag erstellt" : "Fehler")"
|
||||
HeaderText="@(_saveErrorMessage is null ? _saveSuccessHeader : "Fehler")"
|
||||
Width="460px"
|
||||
MaxWidth="95vw"
|
||||
ShowFooter="true"
|
||||
@@ -377,10 +394,10 @@
|
||||
</div>
|
||||
<div>
|
||||
<p style="margin:0 0 0.4rem;font-weight:600;color:#1f2937;font-size:0.95rem;">
|
||||
Umschlag wurde erfolgreich erstellt.
|
||||
@_saveSuccessTitle
|
||||
</p>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -410,12 +427,21 @@
|
||||
<div style="display:flex;gap:0.5rem;justify-content:flex-end;width:100%;padding:0.5rem 0;">
|
||||
@if (_saveErrorMessage is null)
|
||||
{
|
||||
<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 class="btn btn-outline-secondary"
|
||||
@onclick="() => _savePopupVisible = false"
|
||||
style="border-radius:6px;padding:0.5rem 1.25rem;font-weight:500;">
|
||||
Schließen
|
||||
</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
|
||||
{
|
||||
@@ -434,6 +460,9 @@
|
||||
[SupplyParameterFromQuery(Name = "esid")]
|
||||
public string? Esid { get; set; }
|
||||
|
||||
[SupplyParameterFromQuery(Name = "envelopeId")]
|
||||
public int? EnvelopeId { get; set; }
|
||||
|
||||
// ── Constants ──
|
||||
// Signature field size in PDF points (fixed): 1.77" × 1.96"
|
||||
const double SigWidthPt = 1.77 * 72; // 127.44 pt
|
||||
@@ -452,6 +481,9 @@
|
||||
string? _errorMessage;
|
||||
byte[]? _pdfBytes; // Current rendered PDF (original + placeholders burned in)
|
||||
byte[]? _originalPdfBytes; // Pristine upload — never modified, used as base for redraw
|
||||
int? _loadedEnvelopeId;
|
||||
int? _editingEnvelopeId;
|
||||
bool _isLoadingEnvelopeForEdit;
|
||||
|
||||
List<SignatureFieldDraft> _signatureFields = [];
|
||||
ReceiverDraft? _pendingReceiverForPlacement; // Set when user clicks "Signatur hinzufügen"
|
||||
@@ -460,6 +492,10 @@
|
||||
bool _isSaving = false;
|
||||
bool _savePopupVisible = false;
|
||||
string? _saveErrorMessage = null;
|
||||
string _saveSuccessHeader = "Erfolg";
|
||||
string _saveSuccessTitle = "Vorgang erfolgreich abgeschlossen.";
|
||||
string _saveSuccessBody = string.Empty;
|
||||
bool _saveNavigateToDashboard = false;
|
||||
|
||||
// ── Envelope metadata ──
|
||||
string _envelopeTitle = string.Empty;
|
||||
@@ -481,6 +517,7 @@
|
||||
|
||||
// ── Cache key helper ──
|
||||
string SessionKey => $"sender-editor:{Esid}";
|
||||
bool IsEditMode => EnvelopeId.HasValue && EnvelopeId.Value > 0;
|
||||
|
||||
// ── Lifecycle ──
|
||||
|
||||
@@ -500,12 +537,27 @@
|
||||
if (string.IsNullOrWhiteSpace(Esid))
|
||||
{
|
||||
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.
|
||||
if (!string.IsNullOrWhiteSpace(Esid)
|
||||
&& 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 ──
|
||||
async Task OnPdfFileSelectedAsync(InputFileChangeEventArgs e)
|
||||
{
|
||||
@@ -670,8 +806,48 @@
|
||||
NavigationManager.NavigateTo("/sender");
|
||||
}
|
||||
|
||||
// ── Save — POST /api/EnvelopeReceiver ──
|
||||
async Task SaveAsync()
|
||||
async Task SaveDraftAsync()
|
||||
{
|
||||
_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 ──
|
||||
_titleTouched = true;
|
||||
@@ -717,57 +893,38 @@
|
||||
|
||||
try
|
||||
{
|
||||
// ── Build request ──
|
||||
// Document: use the ORIGINAL pdf (without placeholder burn-in) as base64
|
||||
var docBase64 = Convert.ToBase64String(_originalPdfBytes);
|
||||
var envelopeId = _editingEnvelopeId
|
||||
?? EnvelopeId
|
||||
?? _loadedEnvelopeId
|
||||
?? GetEnvelopeIdFromCurrentUri();
|
||||
|
||||
// Build receivers list — each receiver gets their own signature fields
|
||||
// Coordinate conversion: PDF points → inches (DB stores inches)
|
||||
var receiversCmd = _receivers.Select(receiver =>
|
||||
if (!envelopeId.HasValue || envelopeId.Value <= 0)
|
||||
{
|
||||
var fields = _signatureFields
|
||||
.Where(f => f.ReceiverName == receiver.FullName)
|
||||
.Select(f => new DocReceiverElementCreateDto(
|
||||
X: f.XPt / 72.0,
|
||||
Y: f.YPt / 72.0,
|
||||
Page: f.Page))
|
||||
.ToList();
|
||||
var saveResult = await EnvelopeService.UpsertAsync(BuildCreateEnvelopeCommand(send: false))
|
||||
?? throw new InvalidOperationException("Draft save did not return a result.");
|
||||
|
||||
return new ReceiverGetOrCreateCommand
|
||||
{
|
||||
EmailAddress = receiver.Email,
|
||||
Salution = receiver.FullName,
|
||||
PhoneNumber = string.IsNullOrWhiteSpace(receiver.PhoneNumber)
|
||||
? null
|
||||
: receiver.PhoneNumber,
|
||||
DocReceiverElements = fields,
|
||||
};
|
||||
}).ToList();
|
||||
envelopeId = saveResult.Id;
|
||||
EnvelopeId = saveResult.Id;
|
||||
_loadedEnvelopeId = saveResult.Id;
|
||||
_editingEnvelopeId = saveResult.Id;
|
||||
}
|
||||
|
||||
var command = new CreateEnvelopeReceiverCommand
|
||||
{
|
||||
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 sendResult = await EnvelopeService.UpsertAsync(BuildCreateEnvelopeCommand(send: true) with { EnvelopeId = envelopeId })
|
||||
?? throw new InvalidOperationException("Send did not return a result.");
|
||||
|
||||
var result = await EnvelopeReceiverService.CreateAsync(command);
|
||||
|
||||
Logger.LogInformation(
|
||||
"[SenderEditor] Envelope created. Id={Id} SentReceivers={Count}",
|
||||
result?.Id, result?.SentReceiver.Count());
|
||||
Logger.LogInformation("[SenderEditor] Envelope sent from draft. Id={Id} Status={Status}", sendResult.Id, sendResult.Status);
|
||||
|
||||
// Success — show popup; GoToDashboard clears cache and navigates
|
||||
_saveErrorMessage = null;
|
||||
_saveSuccessHeader = "Umschlag gesendet";
|
||||
_saveSuccessTitle = "Umschlag wurde erfolgreich gesendet.";
|
||||
_saveSuccessBody = "Der bestehende Entwurf wurde versendet.";
|
||||
_saveNavigateToDashboard = true;
|
||||
_savePopupVisible = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "[SenderEditor] Failed to create envelope");
|
||||
Logger.LogError(ex, "[SenderEditor] Failed to send envelope draft");
|
||||
_saveErrorMessage = ex.Message;
|
||||
_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 ──
|
||||
void PersistSession()
|
||||
{
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
Neuer Umschlag
|
||||
</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">
|
||||
<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>
|
||||
@@ -728,7 +728,9 @@
|
||||
void EditEnvelope()
|
||||
{
|
||||
if (_selectedEnvelope == null) return;
|
||||
// TODO: Navigate to envelope editor
|
||||
if (!IsEnvelopeEditable(_selectedEnvelope)) return;
|
||||
|
||||
Navigation.NavigateTo($"/sender/editor?envelopeId={_selectedEnvelope.Id}");
|
||||
}
|
||||
|
||||
void DeleteEnvelope()
|
||||
@@ -796,6 +798,12 @@
|
||||
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)
|
||||
{
|
||||
return status switch
|
||||
|
||||
@@ -95,7 +95,6 @@ public class EnvelopeController : ControllerBase
|
||||
/// </summary>
|
||||
/// <param name="command"></param>
|
||||
/// <returns></returns>
|
||||
[NonAction]
|
||||
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> CreateAsync([FromBody] CreateEnvelopeCommand command)
|
||||
@@ -123,4 +122,5 @@ public class EnvelopeController : ControllerBase
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -49,6 +49,14 @@ public class EnvelopeReceiverPageDataService(
|
||||
: 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>
|
||||
/// Loads the current receiver's signature placeholders.
|
||||
/// </summary>
|
||||
|
||||
@@ -187,6 +187,7 @@
|
||||
"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" ],
|
||||
"EmailOut": [ "TBEMLP_EMAIL_OUT_AFT_INS", "TBEMLP_EMAIL_OUT_AFT_UPD" ],
|
||||
"EnvelopeReceiverReadOnly": [ "TBSIG_ENVELOPE_RECEIVER_READ_ONLY_UPD" ],
|
||||
"Receiver": [],
|
||||
|
||||
@@ -542,6 +542,40 @@ body.resizing {
|
||||
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 */
|
||||
.pdf-toolbar__btn--signature-change {
|
||||
display: flex;
|
||||
|
||||
@@ -7,9 +7,10 @@
|
||||
"DbTriggerParams": {
|
||||
"Envelope": [ "TBSIG_ENVELOPE_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" ],
|
||||
"EnvelopeReceiverReadOnly": [ "TBSIG_ENVELOPE_RECEIVER_READ_ONLY_UPD" ],
|
||||
"Receiver": [],
|
||||
"EmailTemplate": [ "TBSIG_EMAIL_TEMPLATE_AFT_UPD" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,8 +36,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.PdfEditor
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.Tests", "EnvelopeGenerator.Tests\EnvelopeGenerator.Tests.csproj", "{224C4845-1CDE-22B7-F3A9-1FF9297F70E8}"
|
||||
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}"
|
||||
EndProject
|
||||
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}.Release|Any CPU.ActiveCfg = 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.Build.0 = Debug|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}
|
||||
{211619F5-AE25-4BA5-A552-BACAFE0632D3} = {9943209E-1744-4944-B1BA-4F87FC1A0EEB}
|
||||
{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}
|
||||
{BF1700D5-592E-4FFA-84E8-5480E289A1F0} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB}
|
||||
{4E6C54DA-576D-0955-2564-9EC890BB8279} = {BF1700D5-592E-4FFA-84E8-5480E289A1F0}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# EnvelopeGenerator.Form — VB.NET Desktop Application Context
|
||||
|
||||
## 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)
|
||||
|
||||
@@ -368,7 +368,7 @@ End Sub
|
||||
|
||||
---
|
||||
|
||||
## Migration to EnvelopeGenerator.Server + API
|
||||
## Migration to EnvelopeGenerator.Server
|
||||
|
||||
### 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