diff --git a/EnvelopeGenerator.Application/Envelopes/Commands/SaveEnvelopeDraftCommand.cs b/EnvelopeGenerator.Application/Envelopes/Commands/SaveEnvelopeDraftCommand.cs new file mode 100644 index 00000000..64c7dc47 --- /dev/null +++ b/EnvelopeGenerator.Application/Envelopes/Commands/SaveEnvelopeDraftCommand.cs @@ -0,0 +1,39 @@ +using EnvelopeGenerator.Domain.Constants; +using MediatR; + +namespace EnvelopeGenerator.Application.Envelopes.Commands; + +public record SaveEnvelopeDraftResultDto(int EnvelopeId, EnvelopeStatus Status); + +public record SaveEnvelopeDraftCommand : IRequest +{ + public int? EnvelopeId { get; init; } + + public string? Title { get; init; } + + public string? Message { get; init; } + + public bool TFAEnabled { get; init; } + + public SaveEnvelopeDraftDocumentDto? Document { get; init; } + + public List Receivers { get; init; } = new(); + + internal int UserId { get; private set; } + + public SaveEnvelopeDraftCommand WithAuth(int userId) + { + UserId = userId; + return this; + } +} + +public record SaveEnvelopeDraftDocumentDto(string DataAsBase64); + +public record SaveEnvelopeDraftFieldDto(double X, double Y, int Page); + +public record SaveEnvelopeDraftReceiverDto( + string EmailAddress, + string? Name, + string? PhoneNumber, + List Fields); diff --git a/EnvelopeGenerator.Application/Envelopes/Commands/SaveEnvelopeDraftCommandHandler.cs b/EnvelopeGenerator.Application/Envelopes/Commands/SaveEnvelopeDraftCommandHandler.cs new file mode 100644 index 00000000..fd561c20 --- /dev/null +++ b/EnvelopeGenerator.Application/Envelopes/Commands/SaveEnvelopeDraftCommandHandler.cs @@ -0,0 +1,268 @@ +using DigitalData.Core.Abstraction.Application.Repository; +using DigitalData.Core.Exceptions; +using EnvelopeGenerator.Domain; +using EnvelopeGenerator.Domain.Constants; +using EnvelopeGenerator.Domain.Entities; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace EnvelopeGenerator.Application.Envelopes.Commands; + +public class SaveEnvelopeDraftCommandHandler : IRequestHandler +{ + private readonly IRepository _envelopeRepository; + private readonly IRepository _documentRepository; + private readonly IRepository _envelopeReceiverRepository; + private readonly IRepository _receiverRepository; + private readonly IRepository _docReceiverElementRepository; + + public SaveEnvelopeDraftCommandHandler( + IRepository envelopeRepository, + IRepository documentRepository, + IRepository envelopeReceiverRepository, + IRepository receiverRepository, + IRepository docReceiverElementRepository) + { + _envelopeRepository = envelopeRepository; + _documentRepository = documentRepository; + _envelopeReceiverRepository = envelopeReceiverRepository; + _receiverRepository = receiverRepository; + _docReceiverElementRepository = docReceiverElementRepository; + } + + public async Task Handle(SaveEnvelopeDraftCommand request, CancellationToken cancel) + { + var now = DateTime.Now; + var title = request.Title?.Trim() ?? string.Empty; + var message = request.Message?.Trim() ?? string.Empty; + int targetEnvelopeId; + + if (request.EnvelopeId is int envelopeId) + { + var envelopeInfo = await _envelopeRepository.Query + .Where(e => e.Id == envelopeId) + .Select(e => new { e.Id, e.UserId }) + .FirstOrDefaultAsync(cancel) + ?? throw new NotFoundException($"Envelope {envelopeId} not found."); + + if (envelopeInfo.UserId != request.UserId) + throw new ForbiddenException(); + + await _envelopeRepository.UpdateAsync(e => + { + e.Title = title; + e.Message = message; + e.TfaEnabled = request.TFAEnabled; + e.Status = EnvelopeStatus.EnvelopeSaved; + e.ChangedWhen = now; + }, e => e.Id == envelopeId, cancel); + + targetEnvelopeId = envelopeInfo.Id; + } + else + { + var envelope = new Envelope + { + UserId = request.UserId, + Status = EnvelopeStatus.EnvelopeSaved, + 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 + }; + + envelope = await _envelopeRepository.CreateAsync(envelope, cancel); + targetEnvelopeId = envelope.Id; + } + + await UpsertDocumentAsync(targetEnvelopeId, request.Document, now, cancel); + await UpsertReceiversAndFieldsAsync(targetEnvelopeId, request.Receivers, now, cancel); + + return new SaveEnvelopeDraftResultDto(targetEnvelopeId, EnvelopeStatus.EnvelopeSaved); + } + + private async Task UpsertDocumentAsync(int envelopeId, SaveEnvelopeDraftDocumentDto? 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 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() + }) + .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 linksByReceiverId = currentLinks.ToDictionary(er => er.ReceiverId, er => er); + 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, linksByReceiverId, 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, + SaveEnvelopeDraftReceiverDto receiverDraft, + IDictionary linksByEmail, + IDictionary linksByReceiverId, + 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; + + existingLink.Name = nextName; + existingLink.PhoneNumber = nextPhoneNumber; + existingLink.ChangedWhen = now; + + await _envelopeReceiverRepository.UpdateAsync(er => + { + er.Name = nextName; + er.PhoneNumber = nextPhoneNumber; + er.ChangedWhen = now; + }, er => er.EnvelopeId == existingLink.EnvelopeId && er.ReceiverId == existingLink.ReceiverId, cancel); + 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 = 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 + }; + + link = await _envelopeReceiverRepository.CreateAsync(link, cancel); + link.Receiver = receiver; + + linksByReceiverId[link.ReceiverId] = link; + linksByEmail[receiverDraft.EmailAddress] = link; + return (link, sequence); + } +} diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/EnvelopeService.cs b/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/EnvelopeService.cs index 18d38dbc..fda994ee 100644 --- a/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/EnvelopeService.cs +++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/EnvelopeService.cs @@ -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,51 @@ public class EnvelopeService(IHttpClientFactory clientFactory) null, response.StatusCode); } + + public async Task SaveDraftAsync( + SaveEnvelopeDraftCommand 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, + 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/draft", payload, _jsonOptions, cancel); + + if (response.IsSuccessStatusCode) + return await response.Content.ReadFromJsonAsync(_jsonOptions, cancel); + + string? message = null; + try + { + var error = await response.Content.ReadFromJsonAsync(_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 draft. Status: {statusCode} ({reasonPhrase}){(string.IsNullOrWhiteSpace(message) ? string.Empty : $" - {message}")}", + null, + response.StatusCode); + } } diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/EnvelopeController.cs b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/EnvelopeController.cs index b6a600af..ebf4c068 100644 --- a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/EnvelopeController.cs +++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/EnvelopeController.cs @@ -123,4 +123,12 @@ public class EnvelopeController : ControllerBase return Ok(result); } + + [Authorize(AuthenticationSchemes = AuthScheme.Sender)] + [HttpPost("draft")] + public async Task SaveDraftAsync([FromBody] SaveEnvelopeDraftCommand command) + { + var result = await _mediator.Send(command.WithAuth(User.GetId())); + return Ok(result); + } }