feat(sender): route editor save and send through unified envelope upsert API

- expose POST /api/Envelope for sender-authenticated create/update/send operations

- replace client draft-specific call with EnvelopeService.UpsertAsync(CreateEnvelopeCommand)

- update sender editor save/send flow to persist draft first and send by envelope id

- allow editor hydration without document by making sender document fetch nullable
This commit is contained in:
2026-09-26 13:11:44 +02:00
parent 4d2538cc2c
commit 4dba965c75
4 changed files with 46 additions and 77 deletions

View File

@@ -96,8 +96,8 @@ public class EnvelopeService(IHttpClientFactory clientFactory)
response.StatusCode); response.StatusCode);
} }
public async Task<SaveEnvelopeDraftResultDto?> SaveDraftAsync( public async Task<EnvelopeDto?> UpsertAsync(
SaveEnvelopeDraftCommand request, CreateEnvelopeCommand request,
CancellationToken cancel = default) CancellationToken cancel = default)
{ {
var httpClient = clientFactory.CreateClient("EnvelopeGenerator.Server"); var httpClient = clientFactory.CreateClient("EnvelopeGenerator.Server");
@@ -107,6 +107,7 @@ public class EnvelopeService(IHttpClientFactory clientFactory)
Title = request.Title, Title = request.Title,
Message = request.Message, Message = request.Message,
TFAEnabled = request.TFAEnabled, TFAEnabled = request.TFAEnabled,
Send = request.Send,
Document = request.Document is null Document = request.Document is null
? null ? null
: new { DataAsBase64 = request.Document.DataAsBase64 }, : new { DataAsBase64 = request.Document.DataAsBase64 },
@@ -119,10 +120,10 @@ public class EnvelopeService(IHttpClientFactory clientFactory)
}).ToList() }).ToList()
}; };
var response = await httpClient.PostAsJsonAsync("/api/Envelope/draft", payload, _jsonOptions, cancel); var response = await httpClient.PostAsJsonAsync("/api/Envelope", payload, _jsonOptions, cancel);
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
return await response.Content.ReadFromJsonAsync<SaveEnvelopeDraftResultDto>(_jsonOptions, cancel); return await response.Content.ReadFromJsonAsync<EnvelopeDto>(_jsonOptions, cancel);
string? message = null; string? message = null;
try try
@@ -138,7 +139,7 @@ public class EnvelopeService(IHttpClientFactory clientFactory)
var statusCode = (int)response.StatusCode; var statusCode = (int)response.StatusCode;
var reasonPhrase = response.ReasonPhrase ?? "Unknown error"; var reasonPhrase = response.ReasonPhrase ?? "Unknown error";
throw new HttpRequestException( throw new HttpRequestException(
$"Failed to save envelope draft. Status: {statusCode} ({reasonPhrase}){(string.IsNullOrWhiteSpace(message) ? string.Empty : $" - {message}")}", $"Failed to save envelope. Status: {statusCode} ({reasonPhrase}){(string.IsNullOrWhiteSpace(message) ? string.Empty : $" - {message}")}",
null, null,
response.StatusCode); response.StatusCode);
} }

View File

@@ -5,7 +5,6 @@
@using DevExpress.Blazor @using DevExpress.Blazor
@using EnvelopeGenerator.Application.Common.Dto @using EnvelopeGenerator.Application.Common.Dto
@using EnvelopeGenerator.Application.Envelopes.Commands @using EnvelopeGenerator.Application.Envelopes.Commands
@using EnvelopeGenerator.Application.EnvelopeReceivers.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.WebUtilities
@@ -16,7 +15,6 @@
@inject AppVersionService AppVersion @inject AppVersionService AppVersion
@inject ILogger<EnvelopeSenderEditorPage> Logger @inject ILogger<EnvelopeSenderEditorPage> Logger
@inject EnvelopeReceiverPageDataService ReceiverPageDataService @inject EnvelopeReceiverPageDataService ReceiverPageDataService
@inject EnvelopeReceiverService EnvelopeReceiverService
@inject EnvelopeService EnvelopeService @inject EnvelopeService EnvelopeService
@inject IEnvelopeAuthService EnvelopeAuthService @inject IEnvelopeAuthService EnvelopeAuthService
@inject IMemoryCache MemoryCache @inject IMemoryCache MemoryCache
@@ -598,8 +596,6 @@
?? throw new InvalidOperationException($"Envelope {envelopeId} not found."); ?? throw new InvalidOperationException($"Envelope {envelopeId} not found.");
var document = await ReceiverPageDataService.GetDocumentDetailsOfSenderAsync(user, envelopeId); var document = await ReceiverPageDataService.GetDocumentDetailsOfSenderAsync(user, envelopeId);
if (document.ByteData is null || document.ByteData.Length == 0)
throw new InvalidOperationException($"Document for envelope {envelopeId} is missing.");
_envelopeTitle = envelope.Title ?? string.Empty; _envelopeTitle = envelope.Title ?? string.Empty;
_envelopeMessage = envelope.Message ?? string.Empty; _envelopeMessage = envelope.Message ?? string.Empty;
@@ -620,7 +616,7 @@
.GroupBy(r => r.Email, StringComparer.OrdinalIgnoreCase) .GroupBy(r => r.Email, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.First().Color, StringComparer.OrdinalIgnoreCase); .ToDictionary(g => g.Key, g => g.First().Color, StringComparer.OrdinalIgnoreCase);
_signatureFields = (document.Elements ?? Enumerable.Empty<DocReceiverElementDto>()) _signatureFields = (document?.Elements ?? Enumerable.Empty<DocReceiverElementDto>())
.Select(element => .Select(element =>
{ {
receiverById.TryGetValue(element.ReceiverId, out var envelopeReceiver); receiverById.TryGetValue(element.ReceiverId, out var envelopeReceiver);
@@ -644,9 +640,11 @@
}) })
.ToList(); .ToList();
_originalPdfBytes = document.ByteData; _originalPdfBytes = document?.ByteData;
_pdfBytes = DrawPlaceholders(_originalPdfBytes, _signatureFields); _pdfLoaded = _originalPdfBytes is { Length: > 0 };
_pdfLoaded = true; _pdfBytes = _pdfLoaded
? DrawPlaceholders(_originalPdfBytes!, _signatureFields)
: null;
_fileName = $"Envelope-{envelopeId}.pdf"; _fileName = $"Envelope-{envelopeId}.pdf";
_pendingReceiverForPlacement = null; _pendingReceiverForPlacement = null;
_loadedEnvelopeId = envelopeId; _loadedEnvelopeId = envelopeId;
@@ -817,21 +815,21 @@
try try
{ {
var previousEnvelopeId = EnvelopeId; var previousEnvelopeId = EnvelopeId;
var command = BuildSaveDraftCommand(); var command = BuildCreateEnvelopeCommand(send: false);
Logger.LogInformation("[SenderEditor] Saving draft. EnvelopeId={EnvelopeId} IsEditMode={IsEditMode}", command.EnvelopeId, IsEditMode); Logger.LogInformation("[SenderEditor] Saving draft. EnvelopeId={EnvelopeId} IsEditMode={IsEditMode}", command.EnvelopeId, IsEditMode);
var result = await EnvelopeService.SaveDraftAsync(command); var result = await EnvelopeService.UpsertAsync(command);
if (result is null) if (result is null)
throw new InvalidOperationException("Draft save did not return a result."); throw new InvalidOperationException("Draft save did not return a result.");
EnvelopeId = result.EnvelopeId; EnvelopeId = result.Id;
_loadedEnvelopeId = result.EnvelopeId; _loadedEnvelopeId = result.Id;
_editingEnvelopeId = result.EnvelopeId; _editingEnvelopeId = result.Id;
if (previousEnvelopeId != result.EnvelopeId) if (previousEnvelopeId != result.Id)
{ {
var sid = string.IsNullOrWhiteSpace(Esid) ? Guid.NewGuid().ToString("N") : Esid; var sid = string.IsNullOrWhiteSpace(Esid) ? Guid.NewGuid().ToString("N") : Esid;
NavigationManager.NavigateTo($"/sender/editor?esid={sid}&envelopeId={result.EnvelopeId}", forceLoad: false); NavigationManager.NavigateTo($"/sender/editor?esid={sid}&envelopeId={result.Id}", forceLoad: false);
} }
NavigationManager.NavigateTo("/sender", forceLoad: false); NavigationManager.NavigateTo("/sender", forceLoad: false);
@@ -848,7 +846,7 @@
} }
} }
// ── Send — POST /api/EnvelopeReceiver ── // ── Send envelope ──
async Task SendAsync() async Task SendAsync()
{ {
// ── Validation ── // ── Validation ──
@@ -895,61 +893,38 @@
try try
{ {
// ── Build request ── var envelopeId = _editingEnvelopeId
// Document: use the ORIGINAL pdf (without placeholder burn-in) as base64 ?? EnvelopeId
var docBase64 = Convert.ToBase64String(_originalPdfBytes); ?? _loadedEnvelopeId
?? GetEnvelopeIdFromCurrentUri();
// Build receivers list — each receiver gets their own signature fields if (!envelopeId.HasValue || envelopeId.Value <= 0)
// Coordinate conversion: PDF points → inches (DB stores inches)
var receiversCmd = _receivers.Select(receiver =>
{ {
var fields = _signatureFields var saveResult = await EnvelopeService.UpsertAsync(BuildCreateEnvelopeCommand(send: false))
.Where(f => f.ReceiverName == receiver.FullName) ?? throw new InvalidOperationException("Draft save did not return a result.");
.Select(f => new DocReceiverElementCreateDto(
X: f.XPt / 72.0,
Y: f.YPt / 72.0,
Page: f.Page))
.ToList();
return new ReceiverGetOrCreateCommand envelopeId = saveResult.Id;
{ EnvelopeId = saveResult.Id;
EmailAddress = receiver.Email, _loadedEnvelopeId = saveResult.Id;
Salution = receiver.FullName, _editingEnvelopeId = saveResult.Id;
PhoneNumber = string.IsNullOrWhiteSpace(receiver.PhoneNumber) }
? null
: receiver.PhoneNumber,
DocReceiverElements = fields,
};
}).ToList();
var command = new CreateEnvelopeReceiverCommand var sendResult = await EnvelopeService.UpsertAsync(BuildCreateEnvelopeCommand(send: true) with { EnvelopeId = envelopeId })
{ ?? throw new InvalidOperationException("Send did not return a result.");
Title = _envelopeTitle.Trim(),
Message = string.IsNullOrWhiteSpace(_envelopeMessage)
? "Bitte unterzeichnen Sie das beigefügte Dokument."
: _envelopeMessage.Trim(),
TFAEnabled = false,
Document = new DocumentCreateCommand { DataAsBase64 = docBase64 },
Receivers = receiversCmd,
};
var result = await EnvelopeReceiverService.CreateAsync(command); Logger.LogInformation("[SenderEditor] Envelope sent from draft. Id={Id} Status={Status}", sendResult.Id, sendResult.Status);
Logger.LogInformation(
"[SenderEditor] Envelope created. Id={Id} SentReceivers={Count}",
result?.Id, result?.SentReceiver.Count());
// Success — show popup; GoToDashboard clears cache and navigates // Success — show popup; GoToDashboard clears cache and navigates
_saveErrorMessage = null; _saveErrorMessage = null;
_saveSuccessHeader = "Umschlag erstellt"; _saveSuccessHeader = "Umschlag gesendet";
_saveSuccessTitle = "Umschlag wurde erfolgreich erstellt."; _saveSuccessTitle = "Umschlag wurde erfolgreich gesendet.";
_saveSuccessBody = "Der Umschlag wurde gespeichert und die Empfänger wurden benachrichtigt."; _saveSuccessBody = "Der bestehende Entwurf wurde versendet.";
_saveNavigateToDashboard = true; _saveNavigateToDashboard = true;
_savePopupVisible = true; _savePopupVisible = true;
} }
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, "[SenderEditor] Failed to create envelope"); Logger.LogError(ex, "[SenderEditor] Failed to send envelope draft");
_saveErrorMessage = ex.Message; _saveErrorMessage = ex.Message;
_savePopupVisible = true; _savePopupVisible = true;
} }
@@ -959,7 +934,7 @@
} }
} }
SaveEnvelopeDraftCommand BuildSaveDraftCommand() CreateEnvelopeCommand BuildCreateEnvelopeCommand(bool send)
{ {
var envelopeId = _editingEnvelopeId var envelopeId = _editingEnvelopeId
?? EnvelopeId ?? EnvelopeId
@@ -977,26 +952,27 @@
{ {
var fields = _signatureFields var fields = _signatureFields
.Where(f => f.ReceiverName == receiver.FullName) .Where(f => f.ReceiverName == receiver.FullName)
.Select(f => new SaveEnvelopeDraftFieldDto( .Select(f => new CreateEnvelopeFieldDto(
X: f.XPt / 72.0, X: f.XPt / 72.0,
Y: f.YPt / 72.0, Y: f.YPt / 72.0,
Page: f.Page)) Page: f.Page))
.ToList(); .ToList();
return new SaveEnvelopeDraftReceiverDto( return new CreateEnvelopeReceiverDto(
EmailAddress: receiver.Email, EmailAddress: receiver.Email,
Name: receiver.FullName, Name: receiver.FullName,
PhoneNumber: string.IsNullOrWhiteSpace(receiver.PhoneNumber) ? null : receiver.PhoneNumber, PhoneNumber: string.IsNullOrWhiteSpace(receiver.PhoneNumber) ? null : receiver.PhoneNumber,
Fields: fields); Fields: fields);
}).ToList(); }).ToList();
return new SaveEnvelopeDraftCommand return new CreateEnvelopeCommand
{ {
EnvelopeId = envelopeId, EnvelopeId = envelopeId,
Title = _envelopeTitle, Title = _envelopeTitle,
Message = _envelopeMessage, Message = _envelopeMessage,
TFAEnabled = false, TFAEnabled = false,
Document = string.IsNullOrWhiteSpace(docBase64) ? null : new SaveEnvelopeDraftDocumentDto(docBase64), Send = send,
Document = string.IsNullOrWhiteSpace(docBase64) ? null : new CreateEnvelopeDocumentDto(docBase64),
Receivers = receiverCommands Receivers = receiverCommands
}; };
} }

View File

@@ -95,7 +95,6 @@ public class EnvelopeController : ControllerBase
/// </summary> /// </summary>
/// <param name="command"></param> /// <param name="command"></param>
/// <returns></returns> /// <returns></returns>
[NonAction]
[Authorize(AuthenticationSchemes = AuthScheme.Sender)] [Authorize(AuthenticationSchemes = AuthScheme.Sender)]
[HttpPost] [HttpPost]
public async Task<IActionResult> CreateAsync([FromBody] CreateEnvelopeCommand command) public async Task<IActionResult> CreateAsync([FromBody] CreateEnvelopeCommand command)
@@ -124,11 +123,4 @@ public class EnvelopeController : ControllerBase
return Ok(result); return Ok(result);
} }
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
[HttpPost("draft")]
public async Task<IActionResult> SaveDraftAsync([FromBody] SaveEnvelopeDraftCommand command)
{
var result = await _mediator.Send(command.WithAuth(User.GetId()));
return Ok(result);
}
} }

View File

@@ -52,7 +52,7 @@ public class EnvelopeReceiverPageDataService(
/// <summary> /// <summary>
/// Loads the full document DTO (including receiver elements) for the authenticated sender. /// Loads the full document DTO (including receiver elements) for the authenticated sender.
/// </summary> /// </summary>
public async Task<DocumentDto> GetDocumentDetailsOfSenderAsync(ClaimsPrincipal user, int envelopeId, CancellationToken cancellationToken = default) public async Task<DocumentDto?> GetDocumentDetailsOfSenderAsync(ClaimsPrincipal user, int envelopeId, CancellationToken cancellationToken = default)
{ {
return await mediator.Send(new ReadDocumentQuery(EnvelopeId: envelopeId, UserId: user.SenderId()), cancellationToken); return await mediator.Send(new ReadDocumentQuery(EnvelopeId: envelopeId, UserId: user.SenderId()), cancellationToken);
} }