Compare commits
2 Commits
519a8bf3f9
...
feat/serve
| Author | SHA1 | Date | |
|---|---|---|---|
| 380a5f4c0c | |||
| 0f53d0509e |
40
AGENTS.md
40
AGENTS.md
@@ -69,9 +69,9 @@ This section explains every markdown file in the repo: what it contains, why it
|
|||||||
- **EnvelopeGenerator.Server** (Server): `@rendermode InteractiveServer` - PDF viewers requiring DevExpress backend
|
- **EnvelopeGenerator.Server** (Server): `@rendermode InteractiveServer` - PDF viewers requiring DevExpress backend
|
||||||
- **EnvelopeGenerator.Server.Client** (WASM): `@rendermode InteractiveWebAssembly` - Login, dashboards, business logic
|
- **EnvelopeGenerator.Server.Client** (WASM): `@rendermode InteractiveWebAssembly` - Login, dashboards, business logic
|
||||||
|
|
||||||
**Backend + API Host:** `EnvelopeGenerator.Server` (ASP.NET Core 8.0, merged Blazor + Web API host)
|
**Backend:** EnvelopeGenerator.API (ASP.NET Core 8.0)
|
||||||
|
|
||||||
**Proxy:** YARP in `EnvelopeGenerator.Server` is used for external integrations (for example AuthHub forwarding), not for routing app APIs to `EnvelopeGenerator.API`.
|
**Proxy:** YARP in EnvelopeGenerator.Server routes `/api/*` → `localhost:8088` (API)
|
||||||
|
|
||||||
### Deprecated Projects - DO NOT USE
|
### Deprecated Projects - DO NOT USE
|
||||||
- `EnvelopeGenerator.ReceiverUI` - Pure WASM (migrated to Server)
|
- `EnvelopeGenerator.ReceiverUI` - Pure WASM (migrated to Server)
|
||||||
@@ -80,14 +80,18 @@ This section explains every markdown file in the repo: what it contains, why it
|
|||||||
|
|
||||||
## Development Commands
|
## Development Commands
|
||||||
|
|
||||||
### Run Active Host
|
### Run Both Projects (Required)
|
||||||
```powershell
|
```powershell
|
||||||
# Terminal - Blazor + Web API host
|
# Terminal 1 - API Backend
|
||||||
|
cd EnvelopeGenerator.API
|
||||||
|
dotnet run
|
||||||
|
|
||||||
|
# Terminal 2 - Blazor Frontend
|
||||||
cd EnvelopeGenerator.Server\EnvelopeGenerator.Server
|
cd EnvelopeGenerator.Server\EnvelopeGenerator.Server
|
||||||
dotnet run
|
dotnet run
|
||||||
```
|
```
|
||||||
|
|
||||||
**Critical:** `EnvelopeGenerator.Server` is the active runtime host for both UI and API controllers.
|
**Critical:** Both must run simultaneously. EnvelopeGenerator.Server proxy forwards `/api/*` to API.
|
||||||
|
|
||||||
### Build
|
### Build
|
||||||
```powershell
|
```powershell
|
||||||
@@ -100,10 +104,10 @@ dotnet build EnvelopeGenerator.sln
|
|||||||
EnvelopeGenerator.Domain/ # Entities (Envelope, Receiver, Document, etc.)
|
EnvelopeGenerator.Domain/ # Entities (Envelope, Receiver, Document, etc.)
|
||||||
EnvelopeGenerator.Application/ # MediatR CQRS (Commands, Queries, Handlers)
|
EnvelopeGenerator.Application/ # MediatR CQRS (Commands, Queries, Handlers)
|
||||||
EnvelopeGenerator.Infrastructure/ # EF Core, SQL executors, repositories
|
EnvelopeGenerator.Infrastructure/ # EF Core, SQL executors, repositories
|
||||||
|
EnvelopeGenerator.API/ # Controllers, endpoints
|
||||||
EnvelopeGenerator.Server/
|
EnvelopeGenerator.Server/
|
||||||
EnvelopeGenerator.Server/ # Server-side Blazor + API controllers
|
EnvelopeGenerator.Server/ # Server-side Blazor components
|
||||||
├─ Components/Pages/ # @rendermode InteractiveServer
|
├─ Components/Pages/ # @rendermode InteractiveServer
|
||||||
├─ Controllers/ # Active Web API endpoints
|
|
||||||
EnvelopeGenerator.Server.Client/ # Client-side WASM components
|
EnvelopeGenerator.Server.Client/ # Client-side WASM components
|
||||||
├─ Pages/ # @rendermode InteractiveWebAssembly
|
├─ Pages/ # @rendermode InteractiveWebAssembly
|
||||||
├─ Services/ # HTTP API clients
|
├─ Services/ # HTTP API clients
|
||||||
@@ -152,13 +156,15 @@ float inches = (pixelX / canvasWidth) * pageWidthInches;
|
|||||||
- **Use case:** External API consumers
|
- **Use case:** External API consumers
|
||||||
- **Not suitable for:** Step-by-step UI workflow (no draft support, no partial updates)
|
- **Not suitable for:** Step-by-step UI workflow (no draft support, no partial updates)
|
||||||
|
|
||||||
### Sender Workflow Endpoint
|
### Missing Granular Endpoints (Need to Create)
|
||||||
```
|
```
|
||||||
POST /api/Envelope # Create/update/save/send envelope workflow payload
|
POST /api/Envelope/draft # Create draft envelope
|
||||||
|
PUT /api/Envelope/{id} # Update metadata
|
||||||
DELETE /api/Envelope/{id} # Delete with reason
|
DELETE /api/Envelope/{id} # Delete with reason
|
||||||
POST /api/Envelope/{id}/document # Upload PDF
|
POST /api/Envelope/{id}/document # Upload PDF
|
||||||
POST /api/Envelope/{id}/receivers # Add receiver
|
POST /api/Envelope/{id}/receivers # Add receiver
|
||||||
POST /api/Envelope/{id}/signature-fields # Place signature field
|
POST /api/Envelope/{id}/signature-fields # Place signature field
|
||||||
|
POST /api/Envelope/{id}/send # Send to receivers
|
||||||
```
|
```
|
||||||
|
|
||||||
See `FORM_APPLICATION_CONTEXT.md` for detailed workflow requirements.
|
See `FORM_APPLICATION_CONTEXT.md` for detailed workflow requirements.
|
||||||
@@ -193,7 +199,7 @@ Routes `/api/*`, `/swagger/*`, `/openapi/*`, `/scalar/*` → `https://localhost:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Server Host Config (`EnvelopeGenerator.Server/EnvelopeGenerator.Server/appsettings.json`)
|
### API Config (`API/appsettings.json`)
|
||||||
- `ConnectionStrings:Default` - SQL Server DB
|
- `ConnectionStrings:Default` - SQL Server DB
|
||||||
- `AllowedOrigins` - CORS (includes `http://localhost:5131`, `http://localhost:7192`)
|
- `AllowedOrigins` - CORS (includes `http://localhost:5131`, `http://localhost:7192`)
|
||||||
- `Cache:SignatureCacheExpiration` - Signature persistence timeout
|
- `Cache:SignatureCacheExpiration` - Signature persistence timeout
|
||||||
@@ -231,15 +237,16 @@ Routes `/api/*`, `/swagger/*`, `/openapi/*`, `/scalar/*` → `https://localhost:
|
|||||||
**No automated tests exist yet.**
|
**No automated tests exist yet.**
|
||||||
|
|
||||||
Manual testing workflow:
|
Manual testing workflow:
|
||||||
1. Start EnvelopeGenerator.Server (`dotnet run` in `EnvelopeGenerator.Server\EnvelopeGenerator.Server`)
|
1. Start API (`dotnet run` in `EnvelopeGenerator.API`)
|
||||||
2. Navigate to `https://localhost:5131` (or check console output for port)
|
2. Start EnvelopeGenerator.Server (`dotnet run` in `EnvelopeGenerator.Server\EnvelopeGenerator.Server`)
|
||||||
3. Test sender login at `/sender/login`
|
3. Navigate to `https://localhost:5131` (or check console output for port)
|
||||||
4. Test receiver flow at `/envelope/login/{envelopeKey}`
|
4. Test sender login at `/sender/login`
|
||||||
|
5. Test receiver flow at `/envelope/login/{envelopeKey}`
|
||||||
|
|
||||||
## Database
|
## Database
|
||||||
|
|
||||||
**SQL Server** (DD_ECM)
|
**SQL Server** (DD_ECM)
|
||||||
- Connection string in `EnvelopeGenerator.Server/EnvelopeGenerator.Server/appsettings.json`
|
- Connection string in `API/appsettings.json`
|
||||||
- EF Core migrations NOT used (manual SQL scripts)
|
- EF Core migrations NOT used (manual SQL scripts)
|
||||||
- Stored procedures: `PRSIG_*` prefix
|
- Stored procedures: `PRSIG_*` prefix
|
||||||
|
|
||||||
@@ -313,4 +320,5 @@ Each envelope maintains independent authentication state.
|
|||||||
None required. All config in `appsettings.json`.
|
None required. All config in `appsettings.json`.
|
||||||
|
|
||||||
**Local dev ports:**
|
**Local dev ports:**
|
||||||
- App host (UI + API): `https://localhost:5131` or `http://localhost:7192`
|
- API: `https://localhost:8088`
|
||||||
|
- WebUI: `https://localhost:5131` or `http://localhost:7192`
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ This project contains:
|
|||||||
- `EnvelopeGenerator.Domain` — domain models, constants, shared abstractions
|
- `EnvelopeGenerator.Domain` — domain models, constants, shared abstractions
|
||||||
- `EnvelopeGenerator.Infrastructure` — EF Core and infrastructure services
|
- `EnvelopeGenerator.Infrastructure` — EF Core and infrastructure services
|
||||||
- `EnvelopeGenerator.PdfEditor` — PDF-related backend utilities
|
- `EnvelopeGenerator.PdfEditor` — PDF-related backend utilities
|
||||||
- `EnvelopeGenerator.API` — deprecated project (no longer part of the active solution/runtime path)
|
- `EnvelopeGenerator.API` — still exists in the solution, but the current merged app host is `EnvelopeGenerator.Server`
|
||||||
|
|
||||||
### Legacy / Do Not Touch
|
### Legacy / Do Not Touch
|
||||||
- `EnvelopeGenerator.Service`
|
- `EnvelopeGenerator.Service`
|
||||||
@@ -121,7 +121,7 @@ Current controller set includes:
|
|||||||
- `SignatureController`
|
- `SignatureController`
|
||||||
- `TfaRegistrationController`
|
- `TfaRegistrationController`
|
||||||
|
|
||||||
Do not assume API behavior lives in `EnvelopeGenerator.API`; the active merged host contains controller endpoints directly.
|
Do not assume API behavior lives only in `EnvelopeGenerator.API`; the active merged host contains controller endpoints directly.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ public sealed record Signature
|
|||||||
public string? Position
|
public string? Position
|
||||||
{
|
{
|
||||||
get => _position;
|
get => _position;
|
||||||
init => _position = string.IsNullOrWhiteSpace(value) ? value : null;
|
init => _position = string.IsNullOrWhiteSpace(value) ? null : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -62,4 +62,4 @@ public sealed record Signature
|
|||||||
/// <b>Example:</b> "Berlin" ? rendered as "Berlin, 26.01.2025"
|
/// <b>Example:</b> "Berlin" ? rendered as "Berlin, 26.01.2025"
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public required string Place { get; init; }
|
public required string Place { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
using EnvelopeGenerator.Domain.Constants;
|
|
||||||
using EnvelopeGenerator.Domain.Entities;
|
|
||||||
using MediatR;
|
|
||||||
|
|
||||||
namespace EnvelopeGenerator.Application.Common.Notifications.EnvelopeDeleted;
|
|
||||||
|
|
||||||
public record EnvelopeDeletedNotification : INotification, ISendMailNotification
|
|
||||||
{
|
|
||||||
public required Envelope Envelope { get; init; }
|
|
||||||
|
|
||||||
public required EnvelopeReceiver EnvelopeReceiver { get; init; }
|
|
||||||
|
|
||||||
public required string Reason { get; init; }
|
|
||||||
|
|
||||||
public required string SenderReference { get; init; }
|
|
||||||
|
|
||||||
public EmailTemplateType TemplateType => EmailTemplateType.DocumentDeleted;
|
|
||||||
|
|
||||||
public string EmailAddress => EnvelopeReceiver.Receiver?.EmailAddress
|
|
||||||
?? throw new InvalidOperationException("Receiver email is missing for EnvelopeDeletedNotification.");
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
using DigitalData.Core.Abstraction.Application.Repository;
|
|
||||||
using DigitalData.EmailProfilerDispatcher.Abstraction.Entities;
|
|
||||||
using EnvelopeGenerator.Application.Common.Configurations;
|
|
||||||
using EnvelopeGenerator.Domain.Constants;
|
|
||||||
using EnvelopeGenerator.Domain.Entities;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
|
|
||||||
namespace EnvelopeGenerator.Application.Common.Notifications.EnvelopeDeleted.Handlers;
|
|
||||||
|
|
||||||
public class SendDeletedMailHandler : SendMailHandler<EnvelopeDeletedNotification>
|
|
||||||
{
|
|
||||||
public SendDeletedMailHandler(
|
|
||||||
IRepository<EmailTemplate> tempRepo,
|
|
||||||
IRepository<EmailOut> emailOutRepo,
|
|
||||||
IOptions<MailParams> mailParamsOptions,
|
|
||||||
IOptions<DispatcherParams> dispatcherParamsOptions)
|
|
||||||
: base(tempRepo, emailOutRepo, mailParamsOptions, dispatcherParamsOptions)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void ConfigureEmailOut(EnvelopeDeletedNotification notification, EmailOut emailOut)
|
|
||||||
{
|
|
||||||
emailOut.ReferenceString = notification.EmailAddress;
|
|
||||||
emailOut.ReferenceId = notification.EnvelopeReceiver.ReceiverId;
|
|
||||||
emailOut.WfId = (int)EnvelopeStatus.MessageDeletionSent;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override Dictionary<string, string> CreatePlaceHolders(EnvelopeDeletedNotification notification)
|
|
||||||
{
|
|
||||||
var senderName = notification.Envelope.User?.Email;
|
|
||||||
if (string.IsNullOrWhiteSpace(senderName))
|
|
||||||
senderName = notification.SenderReference;
|
|
||||||
|
|
||||||
return new Dictionary<string, string>
|
|
||||||
{
|
|
||||||
["[NAME_RECEIVER]"] = notification.EnvelopeReceiver.Name ?? string.Empty,
|
|
||||||
["[NAME_SENDER]"] = senderName ?? string.Empty,
|
|
||||||
["[DOCUMENT_TITLE]"] = notification.Envelope.Title ?? string.Empty,
|
|
||||||
["[REASON]"] = notification.Reason
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -65,18 +65,21 @@ public static class DependencyInjection
|
|||||||
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, EnvelopeReceiverResolutionBehavior>();
|
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, EnvelopeReceiverResolutionBehavior>();
|
||||||
|
|
||||||
// 1. AnnotationBehavior - Saves annotations (executes second)
|
// 1. AnnotationBehavior - Saves annotations (executes second)
|
||||||
|
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, SaveSignatureBehavior>();
|
||||||
|
|
||||||
|
// 2. AnnotationBehavior - Saves annotations for legacy web flow
|
||||||
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, AnnotationBehavior>();
|
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, AnnotationBehavior>();
|
||||||
|
|
||||||
// 2. DocStatusBehavior - Creates document status (executes third)
|
// 3. DocStatusBehavior - Creates document status
|
||||||
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, DocStatusBehavior>();
|
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, DocStatusBehavior>();
|
||||||
|
|
||||||
// 3. HistoryBehavior - Records history (executes fourth)
|
// 4. HistoryBehavior - Records history
|
||||||
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, HistoryBehavior>();
|
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, HistoryBehavior>();
|
||||||
|
|
||||||
// 4. SendSignedMailBehavior - Sends notification email (executes LAST, only if all previous succeed)
|
// 5. SendSignedMailBehavior - Sends notification email (executes LAST)
|
||||||
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, SendSignedMailBehavior>();
|
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, SendSignedMailBehavior>();
|
||||||
});
|
});
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,11 @@ public class EnvelopeReceiverResolutionBehavior : IPipelineBehavior<SigningComma
|
|||||||
// If EnvelopeReceiver is not provided, query it from database
|
// If EnvelopeReceiver is not provided, query it from database
|
||||||
if (request.EnvelopeReceiver is null)
|
if (request.EnvelopeReceiver is null)
|
||||||
{
|
{
|
||||||
var er = await _erRepo.Query.Where(request, notnull: true).SingleOrDefaultAsync(cancellationToken)
|
var er = await _erRepo.Query
|
||||||
|
.Where(request, notnull: true)
|
||||||
|
.Include(x => x.Envelope)
|
||||||
|
.Include(x => x.Receiver)
|
||||||
|
.SingleOrDefaultAsync(cancellationToken)
|
||||||
?? throw new NotFoundException("EnvelopeReceiver not found");
|
?? throw new NotFoundException("EnvelopeReceiver not found");
|
||||||
|
|
||||||
request.SetEnvelopeReceiver(_mapper.Map<EnvelopeReceiverDto>(er));
|
request.SetEnvelopeReceiver(_mapper.Map<EnvelopeReceiverDto>(er));
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ public class SaveSignatureBehavior : IPipelineBehavior<SigningCommand, Unit>
|
|||||||
throw new BadRequestException($"Signatures are required for saving signature behavior.");
|
throw new BadRequestException($"Signatures are required for saving signature behavior.");
|
||||||
|
|
||||||
var elements = await _elementRepo
|
var elements = await _elementRepo
|
||||||
.Where(e => e.Document.EnvelopeId == request.Envelope.Id)
|
.Where(e => e.Document.EnvelopeId == request.EnvelopeReceiver.EnvelopeId)
|
||||||
.Where(e => e.ReceiverId == request.Receiver.Id)
|
.Where(e => e.ReceiverId == request.EnvelopeReceiver.ReceiverId)
|
||||||
.ToListAsync(cancel);
|
.ToListAsync(cancel);
|
||||||
|
|
||||||
foreach (var element in elements)
|
foreach (var element in elements)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using EnvelopeGenerator.Application.Common.Dto;
|
using DigitalData.Core.Abstraction.Application.Repository;
|
||||||
|
using EnvelopeGenerator.Application.Common.Dto;
|
||||||
using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor;
|
using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
@@ -12,29 +13,23 @@ namespace EnvelopeGenerator.Application.Envelopes.Commands;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public record CreateEnvelopeCommand : IRequest<EnvelopeDto?>
|
public record CreateEnvelopeCommand : IRequest<EnvelopeDto?>
|
||||||
{
|
{
|
||||||
public int? EnvelopeId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Der Titel des Umschlags. Dies ist ein Pflichtfeld.
|
/// Der Titel des Umschlags. Dies ist ein Pflichtfeld.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? Title { get; set; }
|
[Required]
|
||||||
|
public required string Title { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Die Nachricht, die im Umschlag enthalten sein soll. Dies ist ein Pflichtfeld.
|
/// Die Nachricht, die im Umschlag enthalten sein soll. Dies ist ein Pflichtfeld.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? Message { get; set; }
|
[Required]
|
||||||
|
public required string Message { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gibt an, ob die Zwei-Faktor-Authentifizierung für den Umschlag aktiviert ist. Standardmäßig false.
|
/// Gibt an, ob die Zwei-Faktor-Authentifizierung für den Umschlag aktiviert ist. Standardmäßig false.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool TFAEnabled { get; set; } = false;
|
public bool TFAEnabled { get; set; } = false;
|
||||||
|
|
||||||
public bool Send { get; init; }
|
|
||||||
|
|
||||||
public CreateEnvelopeDocumentDto? Document { get; init; }
|
|
||||||
|
|
||||||
public List<CreateEnvelopeReceiverDto> Receivers { get; init; } = new();
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// ID des Absenders
|
/// ID des Absenders
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -60,14 +55,4 @@ public record CreateEnvelopeCommand : IRequest<EnvelopeDto?>
|
|||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
[NotMapped]
|
[NotMapped]
|
||||||
public bool UseSQLExecutor { get; set; } = true;
|
public bool UseSQLExecutor { get; set; } = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public record CreateEnvelopeDocumentDto(string DataAsBase64);
|
|
||||||
|
|
||||||
public record CreateEnvelopeFieldDto(double X, double Y, int Page);
|
|
||||||
|
|
||||||
public record CreateEnvelopeReceiverDto(
|
|
||||||
string EmailAddress,
|
|
||||||
string? Name,
|
|
||||||
string? PhoneNumber,
|
|
||||||
List<CreateEnvelopeFieldDto> Fields);
|
|
||||||
@@ -2,14 +2,9 @@
|
|||||||
using MediatR;
|
using MediatR;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using DigitalData.Core.Abstraction.Application.Repository;
|
using DigitalData.Core.Abstraction.Application.Repository;
|
||||||
using DigitalData.Core.Exceptions;
|
|
||||||
using EnvelopeGenerator.Domain.Entities;
|
using EnvelopeGenerator.Domain.Entities;
|
||||||
using EnvelopeGenerator.Application.Common.Dto;
|
using EnvelopeGenerator.Application.Common.Dto;
|
||||||
using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor;
|
using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor;
|
||||||
using EnvelopeGenerator.Application.Histories.Commands;
|
|
||||||
using EnvelopeGenerator.Domain.Constants;
|
|
||||||
using EnvelopeGenerator.Domain;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace EnvelopeGenerator.Application.Envelopes.Commands;
|
namespace EnvelopeGenerator.Application.Envelopes.Commands;
|
||||||
|
|
||||||
@@ -24,16 +19,6 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
|
|||||||
|
|
||||||
private IRepository<Envelope> Repository => _provider.GetRequiredService<IRepository<Envelope>>();
|
private IRepository<Envelope> Repository => _provider.GetRequiredService<IRepository<Envelope>>();
|
||||||
|
|
||||||
private IRepository<Document> DocumentRepository => _provider.GetRequiredService<IRepository<Document>>();
|
|
||||||
|
|
||||||
private IRepository<EnvelopeReceiver> EnvelopeReceiverRepository => _provider.GetRequiredService<IRepository<EnvelopeReceiver>>();
|
|
||||||
|
|
||||||
private IRepository<Receiver> ReceiverRepository => _provider.GetRequiredService<IRepository<Receiver>>();
|
|
||||||
|
|
||||||
private IRepository<DocReceiverElement> DocReceiverElementRepository => _provider.GetRequiredService<IRepository<DocReceiverElement>>();
|
|
||||||
|
|
||||||
private IMediator Mediator => _provider.GetRequiredService<IMediator>();
|
|
||||||
|
|
||||||
private readonly IMapper _mapper;
|
private readonly IMapper _mapper;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -55,321 +40,10 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
|
|||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public async Task<EnvelopeDto?> Handle(CreateEnvelopeCommand request, CancellationToken cancel)
|
public async Task<EnvelopeDto?> Handle(CreateEnvelopeCommand request, CancellationToken cancel)
|
||||||
{
|
{
|
||||||
var now = DateTime.Now;
|
var envelope = request.UseSQLExecutor
|
||||||
var title = request.Title?.Trim() ?? string.Empty;
|
? await Executor.CreateEnvelopeAsync(request.UserId, request.Title, request.Message, request.TFAEnabled, cancel)
|
||||||
var message = request.Message?.Trim() ?? string.Empty;
|
: await Repository.CreateAsync(request, cancel);
|
||||||
|
|
||||||
Envelope envelope;
|
|
||||||
if (request.EnvelopeId is int envelopeId)
|
|
||||||
{
|
|
||||||
envelope = await Repository.Query
|
|
||||||
.Include(e => e.User)
|
|
||||||
.FirstOrDefaultAsync(e => e.Id == envelopeId, cancel)
|
|
||||||
?? throw new BadRequestException($"Envelope {envelopeId} not found.");
|
|
||||||
|
|
||||||
if (envelope.UserId != request.UserId)
|
|
||||||
throw new ForbiddenException();
|
|
||||||
|
|
||||||
await Repository.UpdateAsync(e =>
|
|
||||||
{
|
|
||||||
e.Title = title;
|
|
||||||
e.Message = message;
|
|
||||||
e.TfaEnabled = request.TFAEnabled;
|
|
||||||
e.Status = EnvelopeStatus.EnvelopeSaved;
|
|
||||||
e.ChangedWhen = now;
|
|
||||||
}, e => e.Id == envelopeId, cancel);
|
|
||||||
|
|
||||||
envelope.Title = title;
|
|
||||||
envelope.Message = message;
|
|
||||||
envelope.TfaEnabled = request.TFAEnabled;
|
|
||||||
envelope.Status = EnvelopeStatus.EnvelopeSaved;
|
|
||||||
envelope.ChangedWhen = now;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
envelope = request.UseSQLExecutor
|
|
||||||
? await Executor.CreateEnvelopeAsync(request.UserId, title, message, request.TFAEnabled, cancel)
|
|
||||||
: await Repository.CreateAsync(new Envelope
|
|
||||||
{
|
|
||||||
UserId = request.UserId,
|
|
||||||
Status = EnvelopeStatus.EnvelopeCreated,
|
|
||||||
Uuid = Guid.NewGuid().ToString(),
|
|
||||||
Title = title,
|
|
||||||
Message = message,
|
|
||||||
AddedWhen = now,
|
|
||||||
ChangedWhen = now,
|
|
||||||
Comment = string.Empty,
|
|
||||||
Language = "de-DE",
|
|
||||||
TfaEnabled = request.TFAEnabled,
|
|
||||||
UseAccessCode = false,
|
|
||||||
SendReminderEmails = false
|
|
||||||
}, cancel);
|
|
||||||
|
|
||||||
await Mediator.Send(new CreateHistoryCommand
|
|
||||||
{
|
|
||||||
EnvelopeId = envelope.Id,
|
|
||||||
UserReference = await ResolveSenderReferenceAsync(envelope, cancel),
|
|
||||||
Status = EnvelopeStatus.EnvelopeCreated
|
|
||||||
}, cancel);
|
|
||||||
}
|
|
||||||
|
|
||||||
await UpsertDocumentAsync(envelope.Id, request.Document, now, cancel);
|
|
||||||
await UpsertReceiversAndFieldsAsync(envelope.Id, request.Receivers, now, cancel);
|
|
||||||
|
|
||||||
if (request.Send)
|
|
||||||
await SendEnvelopeAsync(envelope, cancel);
|
|
||||||
|
|
||||||
envelope = await Repository.Query
|
|
||||||
.Include(e => e.User)
|
|
||||||
.Include(e => e.EnvelopeReceivers)
|
|
||||||
.ThenInclude(er => er.Receiver)
|
|
||||||
.FirstAsync(e => e.Id == envelope.Id, cancel);
|
|
||||||
|
|
||||||
return _mapper.Map<EnvelopeDto>(envelope);
|
return _mapper.Map<EnvelopeDto>(envelope);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
private async Task<string> ResolveSenderReferenceAsync(Envelope envelope, CancellationToken cancel)
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrWhiteSpace(envelope.User?.Email))
|
|
||||||
return envelope.User.Email;
|
|
||||||
|
|
||||||
var senderEmail = await Repository.Query
|
|
||||||
.Where(e => e.Id == envelope.Id)
|
|
||||||
.Select(e => e.User.Email)
|
|
||||||
.FirstOrDefaultAsync(cancel);
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(senderEmail))
|
|
||||||
throw new BadRequestException("Envelope sender email is missing.");
|
|
||||||
|
|
||||||
return senderEmail;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task UpsertDocumentAsync(int envelopeId, CreateEnvelopeDocumentDto? document, DateTime now, CancellationToken cancel)
|
|
||||||
{
|
|
||||||
if (document is null || string.IsNullOrWhiteSpace(document.DataAsBase64))
|
|
||||||
return;
|
|
||||||
|
|
||||||
byte[] bytes;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
bytes = Convert.FromBase64String(document.DataAsBase64);
|
|
||||||
}
|
|
||||||
catch (FormatException)
|
|
||||||
{
|
|
||||||
throw new BadRequestException("Document payload is not valid base64.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var existingDocumentId = await DocumentRepository.Query
|
|
||||||
.Where(d => d.EnvelopeId == envelopeId)
|
|
||||||
.Select(d => (int?)d.Id)
|
|
||||||
.FirstOrDefaultAsync(cancel);
|
|
||||||
|
|
||||||
if (existingDocumentId is null)
|
|
||||||
{
|
|
||||||
await DocumentRepository.CreateAsync(new Document
|
|
||||||
{
|
|
||||||
EnvelopeId = envelopeId,
|
|
||||||
AddedWhen = now,
|
|
||||||
ByteData = bytes,
|
|
||||||
Filename = string.Empty,
|
|
||||||
Filepath = string.Empty,
|
|
||||||
FileNameOriginal = string.Empty
|
|
||||||
}, cancel);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await DocumentRepository.UpdateAsync(d =>
|
|
||||||
{
|
|
||||||
d.ByteData = bytes;
|
|
||||||
}, d => d.Id == existingDocumentId.Value, cancel);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task UpsertReceiversAndFieldsAsync(int envelopeId, IReadOnlyCollection<CreateEnvelopeReceiverDto> receivers, DateTime now, CancellationToken cancel)
|
|
||||||
{
|
|
||||||
var filteredReceivers = receivers
|
|
||||||
.Where(r => !string.IsNullOrWhiteSpace(r.EmailAddress))
|
|
||||||
.Select(r => r with
|
|
||||||
{
|
|
||||||
EmailAddress = r.EmailAddress.Trim().ToLowerInvariant(),
|
|
||||||
Name = r.Name?.Trim(),
|
|
||||||
PhoneNumber = string.IsNullOrWhiteSpace(r.PhoneNumber) ? null : r.PhoneNumber.Trim(),
|
|
||||||
Fields = r.Fields ?? new List<CreateEnvelopeFieldDto>()
|
|
||||||
})
|
|
||||||
.GroupBy(r => r.EmailAddress, StringComparer.OrdinalIgnoreCase)
|
|
||||||
.Select(g => g.First())
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
if (filteredReceivers.Count == 0)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var document = await DocumentRepository.Query
|
|
||||||
.FirstOrDefaultAsync(d => d.EnvelopeId == envelopeId, cancel);
|
|
||||||
|
|
||||||
if (document is null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var currentLinks = await EnvelopeReceiverRepository.Query
|
|
||||||
.Include(er => er.Receiver)
|
|
||||||
.Where(er => er.EnvelopeId == envelopeId)
|
|
||||||
.ToListAsync(cancel);
|
|
||||||
|
|
||||||
var linksByEmail = currentLinks
|
|
||||||
.Where(er => !string.IsNullOrWhiteSpace(er.Receiver?.EmailAddress))
|
|
||||||
.GroupBy(er => er.Receiver!.EmailAddress, StringComparer.OrdinalIgnoreCase)
|
|
||||||
.ToDictionary(g => g.Key.ToLowerInvariant(), g => g.First(), StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
var sequence = currentLinks.Count > 0 ? currentLinks.Max(er => er.Sequence) : 0;
|
|
||||||
|
|
||||||
foreach (var receiverDraft in filteredReceivers)
|
|
||||||
{
|
|
||||||
var linkResult = await GetOrCreateEnvelopeReceiverAsync(envelopeId, receiverDraft, linksByEmail, now, sequence, cancel);
|
|
||||||
var link = linkResult.Link;
|
|
||||||
sequence = linkResult.NextSequence;
|
|
||||||
|
|
||||||
await DocReceiverElementRepository.DeleteAsync(query => query
|
|
||||||
.Where(e => e.DocumentId == document.Id)
|
|
||||||
.Where(e => e.ReceiverId == link.ReceiverId), cancel);
|
|
||||||
|
|
||||||
foreach (var field in receiverDraft.Fields.Where(f => f.Page > 0))
|
|
||||||
{
|
|
||||||
await DocReceiverElementRepository.CreateAsync(new DocReceiverElement
|
|
||||||
{
|
|
||||||
DocumentId = document.Id,
|
|
||||||
ReceiverId = link.ReceiverId,
|
|
||||||
ElementType = 1,
|
|
||||||
X = field.X,
|
|
||||||
Y = field.Y,
|
|
||||||
Width = 1.77,
|
|
||||||
Height = 1.96,
|
|
||||||
Page = field.Page,
|
|
||||||
Required = true,
|
|
||||||
ReadOnly = false,
|
|
||||||
AnnotationIndex = 0,
|
|
||||||
AddedWhen = now,
|
|
||||||
FullName = link.Name,
|
|
||||||
Position = string.Empty,
|
|
||||||
Place = string.Empty,
|
|
||||||
Tooltip = string.Empty
|
|
||||||
}, cancel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<(EnvelopeReceiver Link, int NextSequence)> GetOrCreateEnvelopeReceiverAsync(
|
|
||||||
int envelopeId,
|
|
||||||
CreateEnvelopeReceiverDto receiverDraft,
|
|
||||||
IDictionary<string, EnvelopeReceiver> linksByEmail,
|
|
||||||
DateTime now,
|
|
||||||
int sequence,
|
|
||||||
CancellationToken cancel)
|
|
||||||
{
|
|
||||||
if (linksByEmail.TryGetValue(receiverDraft.EmailAddress, out var existingLink))
|
|
||||||
{
|
|
||||||
var nextName = string.IsNullOrWhiteSpace(receiverDraft.Name) ? existingLink.Name : receiverDraft.Name!;
|
|
||||||
var nextPhoneNumber = receiverDraft.PhoneNumber ?? existingLink.PhoneNumber;
|
|
||||||
|
|
||||||
await EnvelopeReceiverRepository.UpdateAsync(er =>
|
|
||||||
{
|
|
||||||
er.Name = nextName;
|
|
||||||
er.PhoneNumber = nextPhoneNumber;
|
|
||||||
er.ChangedWhen = now;
|
|
||||||
}, er => er.EnvelopeId == existingLink.EnvelopeId && er.ReceiverId == existingLink.ReceiverId, cancel);
|
|
||||||
|
|
||||||
existingLink.Name = nextName;
|
|
||||||
existingLink.PhoneNumber = nextPhoneNumber;
|
|
||||||
return (existingLink, sequence);
|
|
||||||
}
|
|
||||||
|
|
||||||
var receiver = await ReceiverRepository.Query
|
|
||||||
.FirstOrDefaultAsync(r => r.EmailAddress != null && r.EmailAddress.ToLower() == receiverDraft.EmailAddress, cancel);
|
|
||||||
|
|
||||||
if (receiver is null)
|
|
||||||
{
|
|
||||||
receiver = await ReceiverRepository.CreateAsync(new Receiver
|
|
||||||
{
|
|
||||||
EmailAddress = receiverDraft.EmailAddress,
|
|
||||||
Signature = receiverDraft.EmailAddress.ToUpperInvariant().GetChecksum(),
|
|
||||||
AddedWhen = now,
|
|
||||||
TotpSecretkey = string.Empty,
|
|
||||||
TfaRegDeadline = null
|
|
||||||
}, cancel);
|
|
||||||
}
|
|
||||||
|
|
||||||
sequence++;
|
|
||||||
var link = await EnvelopeReceiverRepository.CreateAsync(new EnvelopeReceiver
|
|
||||||
{
|
|
||||||
EnvelopeId = envelopeId,
|
|
||||||
ReceiverId = receiver.Id,
|
|
||||||
Sequence = sequence,
|
|
||||||
Name = receiverDraft.Name ?? receiverDraft.EmailAddress,
|
|
||||||
JobTitle = string.Empty,
|
|
||||||
CompanyName = string.Empty,
|
|
||||||
PrivateMessage = string.Empty,
|
|
||||||
AccessCode = string.Empty,
|
|
||||||
AddedWhen = now,
|
|
||||||
ChangedWhen = now,
|
|
||||||
PhoneNumber = receiverDraft.PhoneNumber ?? string.Empty
|
|
||||||
}, cancel);
|
|
||||||
|
|
||||||
link.Receiver = receiver;
|
|
||||||
linksByEmail[receiverDraft.EmailAddress] = link;
|
|
||||||
return (link, sequence);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SendEnvelopeAsync(Envelope envelope, CancellationToken cancel)
|
|
||||||
{
|
|
||||||
var hasDocument = await DocumentRepository.Query
|
|
||||||
.AnyAsync(d => d.EnvelopeId == envelope.Id && d.ByteData != null && d.ByteData.Length > 0, cancel);
|
|
||||||
|
|
||||||
if (!hasDocument)
|
|
||||||
throw new BadRequestException("Cannot send envelope without a document.");
|
|
||||||
|
|
||||||
var links = await EnvelopeReceiverRepository.Query
|
|
||||||
.Include(er => er.Receiver)
|
|
||||||
.Where(er => er.EnvelopeId == envelope.Id)
|
|
||||||
.ToListAsync(cancel);
|
|
||||||
|
|
||||||
if (links.Count == 0)
|
|
||||||
throw new BadRequestException("Cannot send envelope without receivers.");
|
|
||||||
|
|
||||||
var receiverIds = links.Select(l => l.ReceiverId).Distinct().ToList();
|
|
||||||
var documentIds = await DocumentRepository.Query
|
|
||||||
.Where(d => d.EnvelopeId == envelope.Id)
|
|
||||||
.Select(d => d.Id)
|
|
||||||
.ToListAsync(cancel);
|
|
||||||
|
|
||||||
var receiverFieldCounts = await DocReceiverElementRepository.Query
|
|
||||||
.Where(e => documentIds.Contains(e.DocumentId))
|
|
||||||
.Where(e => receiverIds.Contains(e.ReceiverId))
|
|
||||||
.GroupBy(e => e.ReceiverId)
|
|
||||||
.Select(g => new { ReceiverId = g.Key, Count = g.Count() })
|
|
||||||
.ToDictionaryAsync(x => x.ReceiverId, x => x.Count, cancel);
|
|
||||||
|
|
||||||
var receiversWithoutFields = links
|
|
||||||
.Where(link => !receiverFieldCounts.TryGetValue(link.ReceiverId, out var count) || count <= 0)
|
|
||||||
.Select(link => link.Name)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
if (receiversWithoutFields.Count > 0)
|
|
||||||
throw new BadRequestException("Cannot send envelope because some receivers have no signature fields.");
|
|
||||||
|
|
||||||
var changedWhen = DateTime.Now;
|
|
||||||
await Repository.UpdateAsync(e =>
|
|
||||||
{
|
|
||||||
e.Status = EnvelopeStatus.EnvelopeQueued;
|
|
||||||
e.ChangedWhen = changedWhen;
|
|
||||||
}, e => e.Id == envelope.Id, cancel);
|
|
||||||
|
|
||||||
var userReference = await ResolveSenderReferenceAsync(envelope, cancel);
|
|
||||||
|
|
||||||
await Mediator.Send(new CreateHistoryCommand
|
|
||||||
{
|
|
||||||
EnvelopeId = envelope.Id,
|
|
||||||
UserReference = userReference,
|
|
||||||
Status = EnvelopeStatus.EnvelopeQueued
|
|
||||||
}, cancel);
|
|
||||||
|
|
||||||
envelope.Status = EnvelopeStatus.EnvelopeQueued;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using EnvelopeGenerator.Domain.Constants;
|
|
||||||
using MediatR;
|
|
||||||
|
|
||||||
namespace EnvelopeGenerator.Application.Envelopes.Commands;
|
|
||||||
|
|
||||||
public record DeleteEnvelopeResultDto(int EnvelopeId, EnvelopeStatus AppliedStatus, bool IsNoOp);
|
|
||||||
|
|
||||||
public record DeleteEnvelopeCommand : IRequest<DeleteEnvelopeResultDto>
|
|
||||||
{
|
|
||||||
[Required]
|
|
||||||
public int EnvelopeId { get; init; }
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
[MinLength(1)]
|
|
||||||
public string Reason { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
internal int UserId { get; private set; }
|
|
||||||
|
|
||||||
public DeleteEnvelopeCommand WithAuth(int userId)
|
|
||||||
{
|
|
||||||
UserId = userId;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
using EnvelopeGenerator.Application.Common.Notifications.EnvelopeDeleted;
|
|
||||||
using DigitalData.Core.Abstraction.Application.Repository;
|
|
||||||
using DigitalData.Core.Exceptions;
|
|
||||||
using EnvelopeGenerator.Application.Histories.Commands;
|
|
||||||
using EnvelopeGenerator.Domain.Constants;
|
|
||||||
using EnvelopeGenerator.Domain.Entities;
|
|
||||||
using MediatR;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using System.Linq;
|
|
||||||
|
|
||||||
namespace EnvelopeGenerator.Application.Envelopes.Commands;
|
|
||||||
|
|
||||||
public class DeleteEnvelopeCommandHandler : IRequestHandler<DeleteEnvelopeCommand, DeleteEnvelopeResultDto>
|
|
||||||
{
|
|
||||||
private readonly IRepository<Envelope> _envelopeRepository;
|
|
||||||
private readonly IMediator _mediator;
|
|
||||||
|
|
||||||
public DeleteEnvelopeCommandHandler(
|
|
||||||
IRepository<Envelope> envelopeRepository,
|
|
||||||
IMediator mediator)
|
|
||||||
{
|
|
||||||
_envelopeRepository = envelopeRepository;
|
|
||||||
_mediator = mediator;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<DeleteEnvelopeResultDto> Handle(DeleteEnvelopeCommand request, CancellationToken cancel)
|
|
||||||
{
|
|
||||||
var reason = request.Reason?.Trim();
|
|
||||||
if (string.IsNullOrWhiteSpace(reason))
|
|
||||||
throw new BadRequestException("Please provide a reason.");
|
|
||||||
|
|
||||||
var envelope = await _envelopeRepository.Query
|
|
||||||
.Include(e => e.User)
|
|
||||||
.Include(e => e.EnvelopeReceivers)
|
|
||||||
.ThenInclude(er => er.Receiver)
|
|
||||||
.FirstOrDefaultAsync(e => e.Id == request.EnvelopeId, cancel)
|
|
||||||
?? throw new NotFoundException($"Envelope {request.EnvelopeId} not found.");
|
|
||||||
|
|
||||||
if (envelope.UserId != request.UserId)
|
|
||||||
throw new ForbiddenException();
|
|
||||||
|
|
||||||
if (envelope.Status is EnvelopeStatus.EnvelopeDeleted or EnvelopeStatus.EnvelopeWithdrawn)
|
|
||||||
return new DeleteEnvelopeResultDto(envelope.Id, envelope.Status, IsNoOp: true);
|
|
||||||
|
|
||||||
var targetStatus = envelope.Status > EnvelopeStatus.EnvelopeSaved
|
|
||||||
? EnvelopeStatus.EnvelopeWithdrawn
|
|
||||||
: EnvelopeStatus.EnvelopeDeleted;
|
|
||||||
|
|
||||||
var changedWhen = DateTime.Now;
|
|
||||||
envelope.Status = targetStatus;
|
|
||||||
envelope.Comment = reason;
|
|
||||||
envelope.ChangedWhen = changedWhen;
|
|
||||||
|
|
||||||
await _envelopeRepository.UpdateAsync(e =>
|
|
||||||
{
|
|
||||||
e.Status = targetStatus;
|
|
||||||
e.Comment = reason;
|
|
||||||
e.ChangedWhen = changedWhen;
|
|
||||||
}, e => e.Id == envelope.Id, cancel);
|
|
||||||
|
|
||||||
var userReference = envelope.User?.Email;
|
|
||||||
if (string.IsNullOrWhiteSpace(userReference))
|
|
||||||
throw new BadRequestException("Envelope sender email is missing.");
|
|
||||||
|
|
||||||
await _mediator.Send(new CreateHistoryCommand
|
|
||||||
{
|
|
||||||
EnvelopeId = envelope.Id,
|
|
||||||
UserReference = userReference,
|
|
||||||
Status = targetStatus,
|
|
||||||
Comment = reason
|
|
||||||
}, cancel);
|
|
||||||
|
|
||||||
foreach (var envelopeReceiver in envelope.EnvelopeReceivers ?? Enumerable.Empty<EnvelopeReceiver>())
|
|
||||||
{
|
|
||||||
if (envelopeReceiver.Receiver is null)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
await _mediator.Publish(new EnvelopeDeletedNotification
|
|
||||||
{
|
|
||||||
Envelope = envelope,
|
|
||||||
EnvelopeReceiver = envelopeReceiver,
|
|
||||||
Reason = reason,
|
|
||||||
SenderReference = userReference
|
|
||||||
}, cancel);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new DeleteEnvelopeResultDto(envelope.Id, targetStatus, IsNoOp: false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -214,4 +214,4 @@ namespace EnvelopeGenerator.Infrastructure
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -204,16 +204,24 @@ public abstract class EGDbContextBase : DbContext
|
|||||||
|
|
||||||
#region Trigger
|
#region Trigger
|
||||||
// Configure entities to handle database triggers
|
// Configure entities to handle database triggers
|
||||||
void AddTrigger<T>() where T : class => _triggers
|
void AddTrigger<T>() where T : class
|
||||||
.Where(t => t.Key == typeof(T).Name)
|
{
|
||||||
.SelectMany(t => t.Value)
|
var entityName = typeof(T).Name;
|
||||||
.ForEach(tName =>
|
|
||||||
|
var configuredTriggerNames = _triggers
|
||||||
|
.Where(t => t.Key == entityName)
|
||||||
|
.SelectMany(t => t.Value)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var triggerName in configuredTriggerNames)
|
||||||
{
|
{
|
||||||
#if NET
|
#if NET
|
||||||
modelBuilder.Entity<T>().ToTable(tb => tb.HasTrigger(tName));
|
modelBuilder.Entity<T>().ToTable(tb => tb.HasTrigger(triggerName));
|
||||||
#endif
|
#endif
|
||||||
_logger?.LogInformation("Trigger '{triggerName}' has been added to the '{entityName}' entity.", tName, typeof(T).Name);
|
_logger?.LogInformation("Trigger '{triggerName}' has been added to the '{entityName}' entity.", triggerName, entityName);
|
||||||
});
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: call add trigger methods with attributes and reflection
|
// TODO: call add trigger methods with attributes and reflection
|
||||||
AddTrigger<Config>();
|
AddTrigger<Config>();
|
||||||
@@ -243,4 +251,4 @@ public abstract class EGDbContextBase : DbContext
|
|||||||
|
|
||||||
#if NETFRAMEWORK
|
#if NETFRAMEWORK
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace EnvelopeGenerator.Server.Client.Models;
|
||||||
|
|
||||||
|
public sealed record SignatureSubmitDto
|
||||||
|
{
|
||||||
|
public required int Id { get; init; }
|
||||||
|
public required string DataUrl { get; init; }
|
||||||
|
public required string FullName { get; init; }
|
||||||
|
public string? Position { get; init; }
|
||||||
|
public required string Place { get; init; }
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
using EnvelopeGenerator.Application.Common.Dto;
|
using EnvelopeGenerator.Application.Common.Dto;
|
||||||
using EnvelopeGenerator.Application.Envelopes.Commands;
|
|
||||||
using Microsoft.AspNetCore.WebUtilities;
|
using Microsoft.AspNetCore.WebUtilities;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
@@ -13,8 +12,6 @@ namespace EnvelopeGenerator.Server.Client.Services;
|
|||||||
public class EnvelopeService(IHttpClientFactory clientFactory)
|
public class EnvelopeService(IHttpClientFactory clientFactory)
|
||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
|
private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
|
||||||
private sealed record DeleteEnvelopeRequest(string Reason);
|
|
||||||
private sealed record ErrorResponse(string? Message);
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fetches envelopes from the API with optional filters.
|
/// Fetches envelopes from the API with optional filters.
|
||||||
@@ -64,83 +61,4 @@ public class EnvelopeService(IHttpClientFactory clientFactory)
|
|||||||
|
|
||||||
return await response.Content.ReadFromJsonAsync<IEnumerable<EnvelopeDto>>(_jsonOptions, cancel);
|
return await response.Content.ReadFromJsonAsync<IEnumerable<EnvelopeDto>>(_jsonOptions, cancel);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeleteAsync(int envelopeId, string reason, CancellationToken cancel = default)
|
|
||||||
{
|
|
||||||
var httpClient = clientFactory.CreateClient("EnvelopeGenerator.Server");
|
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Delete, $"/api/Envelope/{envelopeId}")
|
|
||||||
{
|
|
||||||
Content = JsonContent.Create(new DeleteEnvelopeRequest(reason), options: _jsonOptions)
|
|
||||||
};
|
|
||||||
|
|
||||||
var response = await httpClient.SendAsync(request, cancel);
|
|
||||||
if (response.IsSuccessStatusCode)
|
|
||||||
return;
|
|
||||||
|
|
||||||
string? message = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var error = await response.Content.ReadFromJsonAsync<ErrorResponse>(_jsonOptions, cancel);
|
|
||||||
message = error?.Message;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
message = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var statusCode = (int)response.StatusCode;
|
|
||||||
var reasonPhrase = response.ReasonPhrase ?? "Unknown error";
|
|
||||||
throw new HttpRequestException(
|
|
||||||
$"Failed to delete envelope. Status: {statusCode} ({reasonPhrase}){(string.IsNullOrWhiteSpace(message) ? string.Empty : $" - {message}")}",
|
|
||||||
null,
|
|
||||||
response.StatusCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<EnvelopeDto?> UpsertAsync(
|
|
||||||
CreateEnvelopeCommand request,
|
|
||||||
CancellationToken cancel = default)
|
|
||||||
{
|
|
||||||
var httpClient = clientFactory.CreateClient("EnvelopeGenerator.Server");
|
|
||||||
var payload = new
|
|
||||||
{
|
|
||||||
EnvelopeId = request.EnvelopeId,
|
|
||||||
Title = request.Title,
|
|
||||||
Message = request.Message,
|
|
||||||
TFAEnabled = request.TFAEnabled,
|
|
||||||
Send = request.Send,
|
|
||||||
Document = request.Document is null
|
|
||||||
? null
|
|
||||||
: new { DataAsBase64 = request.Document.DataAsBase64 },
|
|
||||||
Receivers = request.Receivers.Select(r => new
|
|
||||||
{
|
|
||||||
EmailAddress = r.EmailAddress,
|
|
||||||
Name = r.Name,
|
|
||||||
PhoneNumber = r.PhoneNumber,
|
|
||||||
Fields = r.Fields.Select(f => new { X = f.X, Y = f.Y, Page = f.Page }).ToList()
|
|
||||||
}).ToList()
|
|
||||||
};
|
|
||||||
|
|
||||||
var response = await httpClient.PostAsJsonAsync("/api/Envelope", payload, _jsonOptions, cancel);
|
|
||||||
|
|
||||||
if (response.IsSuccessStatusCode)
|
|
||||||
return await response.Content.ReadFromJsonAsync<EnvelopeDto>(_jsonOptions, cancel);
|
|
||||||
|
|
||||||
string? message = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var error = await response.Content.ReadFromJsonAsync<ErrorResponse>(_jsonOptions, cancel);
|
|
||||||
message = error?.Message;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
message = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var statusCode = (int)response.StatusCode;
|
|
||||||
var reasonPhrase = response.ReasonPhrase ?? "Unknown error";
|
|
||||||
throw new HttpRequestException(
|
|
||||||
$"Failed to save envelope. Status: {statusCode} ({reasonPhrase}){(string.IsNullOrWhiteSpace(message) ? string.Empty : $" - {message}")}",
|
|
||||||
null,
|
|
||||||
response.StatusCode);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using EnvelopeGenerator.Application.Common.Dto;
|
||||||
using EnvelopeGenerator.Server.Client.Models;
|
using EnvelopeGenerator.Server.Client.Models;
|
||||||
|
|
||||||
namespace EnvelopeGenerator.Server.Client.Services;
|
namespace EnvelopeGenerator.Server.Client.Services;
|
||||||
@@ -21,4 +22,32 @@ public class SignatureService(IHttpClientFactory httpClientFactory)
|
|||||||
var result = await response.Content.ReadFromJsonAsync<List<SignatureDto>>(_jsonOptions, cancel);
|
var result = await response.Content.ReadFromJsonAsync<List<SignatureDto>>(_jsonOptions, cancel);
|
||||||
return result ?? [];
|
return result ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task SubmitAsync(string envelopeKey, IEnumerable<SignatureSubmitDto> signatures, CancellationToken cancel = default)
|
||||||
|
{
|
||||||
|
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||||
|
|
||||||
|
var payload = signatures
|
||||||
|
.Select(signature => new Signature
|
||||||
|
{
|
||||||
|
Id = signature.Id,
|
||||||
|
DataUrl = signature.DataUrl,
|
||||||
|
FullName = signature.FullName,
|
||||||
|
Position = signature.Position,
|
||||||
|
Place = signature.Place
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var response = await http.PostAsJsonAsync(
|
||||||
|
$"/api/Signature/submit/{Uri.EscapeDataString(envelopeKey)}",
|
||||||
|
payload,
|
||||||
|
cancel);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var body = await response.Content.ReadAsStringAsync(cancel);
|
||||||
|
throw new HttpRequestException(
|
||||||
|
$"Failed to submit signature for envelope {envelopeKey}: {(int)response.StatusCode} {response.StatusCode} - {body}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,9 @@
|
|||||||
@using DevExpress.Blazor.PdfViewer
|
@using DevExpress.Blazor.PdfViewer
|
||||||
@using DevExpress.Blazor.Reporting.Models
|
@using DevExpress.Blazor.Reporting.Models
|
||||||
@using DevExpress.Blazor
|
@using DevExpress.Blazor
|
||||||
@using EnvelopeGenerator.Application.Common.Dto
|
@using EnvelopeGenerator.Application.EnvelopeReceivers.Commands
|
||||||
@using EnvelopeGenerator.Application.Envelopes.Commands
|
|
||||||
@using EnvelopeGenerator.Server.Client.Services
|
@using EnvelopeGenerator.Server.Client.Services
|
||||||
@using EnvelopeGenerator.Server.Services
|
@using EnvelopeGenerator.Server.Services
|
||||||
@using Microsoft.AspNetCore.WebUtilities
|
|
||||||
@using Microsoft.AspNetCore.Components.Forms
|
@using Microsoft.AspNetCore.Components.Forms
|
||||||
@using Microsoft.Extensions.Caching.Memory
|
@using Microsoft.Extensions.Caching.Memory
|
||||||
@inject IJSRuntime JSRuntime
|
@inject IJSRuntime JSRuntime
|
||||||
@@ -15,8 +13,7 @@
|
|||||||
@inject AppVersionService AppVersion
|
@inject AppVersionService AppVersion
|
||||||
@inject ILogger<EnvelopeSenderEditorPage> Logger
|
@inject ILogger<EnvelopeSenderEditorPage> Logger
|
||||||
@inject EnvelopeReceiverPageDataService ReceiverPageDataService
|
@inject EnvelopeReceiverPageDataService ReceiverPageDataService
|
||||||
@inject EnvelopeService EnvelopeService
|
@inject EnvelopeReceiverService EnvelopeReceiverService
|
||||||
@inject IEnvelopeAuthService EnvelopeAuthService
|
|
||||||
@inject IMemoryCache MemoryCache
|
@inject IMemoryCache MemoryCache
|
||||||
|
|
||||||
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
|
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
|
||||||
@@ -172,55 +169,41 @@
|
|||||||
style="display: none;" />
|
style="display: none;" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@if (_pdfLoaded && _signatureFields.Count > 0)
|
@if (_pdfLoaded)
|
||||||
{
|
{
|
||||||
<button class="pdf-toolbar__btn pdf-toolbar__btn--reset"
|
@* Clear all fields *@
|
||||||
@onclick="ClearAllFieldsAsync"
|
@if (_signatureFields.Count > 0)
|
||||||
title="Alle Signaturfelder entfernen">
|
{
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
<button class="pdf-toolbar__btn pdf-toolbar__btn--reset"
|
||||||
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z" />
|
@onclick="ClearAllFieldsAsync"
|
||||||
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1 0-2h3a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1h3a1 1 0 0 1 1 1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3h11V2h-11v1z" />
|
title="Alle Signaturfelder entfernen">
|
||||||
</svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z" />
|
||||||
|
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1 0-2h3a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1h3a1 1 0 0 1 1 1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3h11V2h-11v1z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* Save *@
|
||||||
|
<button class="pdf-toolbar__btn pdf-toolbar__btn--success"
|
||||||
|
@onclick="SaveAsync"
|
||||||
|
disabled="@_isSaving"
|
||||||
|
title="Speichern"
|
||||||
|
style="background: linear-gradient(135deg,#059669,#047857); color:#fff; border-radius:6px; padding:0.3rem 0.75rem; font-size:0.75rem; font-weight:600; border:none;">
|
||||||
|
@if (_isSaving)
|
||||||
|
{
|
||||||
|
<span class="spinner-border spinner-border-sm me-1" role="status"
|
||||||
|
style="width:0.8rem;height:0.8rem;border-width:2px;"></span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||||
|
<path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
Speichern
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|
||||||
<button class="pdf-toolbar__btn pdf-toolbar__btn--draft"
|
|
||||||
@onclick="SaveDraftAsync"
|
|
||||||
disabled="@_isSaving"
|
|
||||||
title="Speichern">
|
|
||||||
@if (_isSaving)
|
|
||||||
{
|
|
||||||
<span class="spinner-border spinner-border-sm me-1" role="status"
|
|
||||||
style="width:0.8rem;height:0.8rem;border-width:2px;"></span>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
||||||
<path d="M2 2a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V5.414A2 2 0 0 0 15.414 4L12 0.586A2 2 0 0 0 10.586 0H2zm9 1.5v2A1.5 1.5 0 0 1 9.5 7h-3A1.5 1.5 0 0 1 5 5.5v-4h5.586a1 1 0 0 1 .707.293L14.207 4.707A1 1 0 0 1 14.5 5.414V12a.5.5 0 0 1-.5.5H2a.5.5 0 0 1-.5-.5V4A.5.5 0 0 1 2 3.5h9z"/>
|
|
||||||
<path d="M5.5 1.5v4a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-4h-4zM4 9a1 1 0 0 0-1 1v3h10v-3a1 1 0 0 0-1-1H4z"/>
|
|
||||||
</svg>
|
|
||||||
}
|
|
||||||
<span class="pdf-toolbar__btn-text">Speichern</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button class="pdf-toolbar__btn pdf-toolbar__btn--success"
|
|
||||||
@onclick="SendAsync"
|
|
||||||
disabled="@_isSaving"
|
|
||||||
title="Umschlag senden"
|
|
||||||
style="background: linear-gradient(135deg,#059669,#047857); color:#fff; border-radius:6px; padding:0.3rem 0.75rem; font-size:0.75rem; font-weight:600; border:none;">
|
|
||||||
@if (_isSaving)
|
|
||||||
{
|
|
||||||
<span class="spinner-border spinner-border-sm me-1" role="status"
|
|
||||||
style="width:0.8rem;height:0.8rem;border-width:2px;"></span>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
||||||
<path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z" />
|
|
||||||
</svg>
|
|
||||||
}
|
|
||||||
Senden
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -375,7 +358,7 @@
|
|||||||
|
|
||||||
@* ── Save result popup (success + error) ── *@
|
@* ── Save result popup (success + error) ── *@
|
||||||
<DxPopup @bind-Visible="_savePopupVisible"
|
<DxPopup @bind-Visible="_savePopupVisible"
|
||||||
HeaderText="@(_saveErrorMessage is null ? _saveSuccessHeader : "Fehler")"
|
HeaderText="@(_saveErrorMessage is null ? "Umschlag erstellt" : "Fehler")"
|
||||||
Width="460px"
|
Width="460px"
|
||||||
MaxWidth="95vw"
|
MaxWidth="95vw"
|
||||||
ShowFooter="true"
|
ShowFooter="true"
|
||||||
@@ -394,10 +377,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p style="margin:0 0 0.4rem;font-weight:600;color:#1f2937;font-size:0.95rem;">
|
<p style="margin:0 0 0.4rem;font-weight:600;color:#1f2937;font-size:0.95rem;">
|
||||||
@_saveSuccessTitle
|
Umschlag wurde erfolgreich erstellt.
|
||||||
</p>
|
</p>
|
||||||
<p style="margin:0;color:#6b7280;font-size:0.85rem;line-height:1.5;">
|
<p style="margin:0;color:#6b7280;font-size:0.85rem;line-height:1.5;">
|
||||||
@_saveSuccessBody
|
Der Umschlag wurde gespeichert und die Empfänger wurden benachrichtigt.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -427,21 +410,12 @@
|
|||||||
<div style="display:flex;gap:0.5rem;justify-content:flex-end;width:100%;padding:0.5rem 0;">
|
<div style="display:flex;gap:0.5rem;justify-content:flex-end;width:100%;padding:0.5rem 0;">
|
||||||
@if (_saveErrorMessage is null)
|
@if (_saveErrorMessage is null)
|
||||||
{
|
{
|
||||||
<button class="btn btn-outline-secondary"
|
<button class="btn btn-primary"
|
||||||
@onclick="() => _savePopupVisible = false"
|
@onclick="GoToDashboard"
|
||||||
style="border-radius:6px;padding:0.5rem 1.25rem;font-weight:500;">
|
style="background:linear-gradient(135deg,#059669,#047857);border:none;border-radius:6px;
|
||||||
Schließen
|
padding:0.5rem 1.5rem;font-weight:600;color:#fff;">
|
||||||
|
Zur Übersicht
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@if (_saveNavigateToDashboard)
|
|
||||||
{
|
|
||||||
<button class="btn btn-primary"
|
|
||||||
@onclick="GoToDashboard"
|
|
||||||
style="background:linear-gradient(135deg,#059669,#047857);border:none;border-radius:6px;
|
|
||||||
padding:0.5rem 1.5rem;font-weight:600;color:#fff;">
|
|
||||||
Zur Übersicht
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -460,9 +434,6 @@
|
|||||||
[SupplyParameterFromQuery(Name = "esid")]
|
[SupplyParameterFromQuery(Name = "esid")]
|
||||||
public string? Esid { get; set; }
|
public string? Esid { get; set; }
|
||||||
|
|
||||||
[SupplyParameterFromQuery(Name = "envelopeId")]
|
|
||||||
public int? EnvelopeId { get; set; }
|
|
||||||
|
|
||||||
// ── Constants ──
|
// ── Constants ──
|
||||||
// Signature field size in PDF points (fixed): 1.77" × 1.96"
|
// Signature field size in PDF points (fixed): 1.77" × 1.96"
|
||||||
const double SigWidthPt = 1.77 * 72; // 127.44 pt
|
const double SigWidthPt = 1.77 * 72; // 127.44 pt
|
||||||
@@ -481,9 +452,6 @@
|
|||||||
string? _errorMessage;
|
string? _errorMessage;
|
||||||
byte[]? _pdfBytes; // Current rendered PDF (original + placeholders burned in)
|
byte[]? _pdfBytes; // Current rendered PDF (original + placeholders burned in)
|
||||||
byte[]? _originalPdfBytes; // Pristine upload — never modified, used as base for redraw
|
byte[]? _originalPdfBytes; // Pristine upload — never modified, used as base for redraw
|
||||||
int? _loadedEnvelopeId;
|
|
||||||
int? _editingEnvelopeId;
|
|
||||||
bool _isLoadingEnvelopeForEdit;
|
|
||||||
|
|
||||||
List<SignatureFieldDraft> _signatureFields = [];
|
List<SignatureFieldDraft> _signatureFields = [];
|
||||||
ReceiverDraft? _pendingReceiverForPlacement; // Set when user clicks "Signatur hinzufügen"
|
ReceiverDraft? _pendingReceiverForPlacement; // Set when user clicks "Signatur hinzufügen"
|
||||||
@@ -492,10 +460,6 @@
|
|||||||
bool _isSaving = false;
|
bool _isSaving = false;
|
||||||
bool _savePopupVisible = false;
|
bool _savePopupVisible = false;
|
||||||
string? _saveErrorMessage = null;
|
string? _saveErrorMessage = null;
|
||||||
string _saveSuccessHeader = "Erfolg";
|
|
||||||
string _saveSuccessTitle = "Vorgang erfolgreich abgeschlossen.";
|
|
||||||
string _saveSuccessBody = string.Empty;
|
|
||||||
bool _saveNavigateToDashboard = false;
|
|
||||||
|
|
||||||
// ── Envelope metadata ──
|
// ── Envelope metadata ──
|
||||||
string _envelopeTitle = string.Empty;
|
string _envelopeTitle = string.Empty;
|
||||||
@@ -517,7 +481,6 @@
|
|||||||
|
|
||||||
// ── Cache key helper ──
|
// ── Cache key helper ──
|
||||||
string SessionKey => $"sender-editor:{Esid}";
|
string SessionKey => $"sender-editor:{Esid}";
|
||||||
bool IsEditMode => EnvelopeId.HasValue && EnvelopeId.Value > 0;
|
|
||||||
|
|
||||||
// ── Lifecycle ──
|
// ── Lifecycle ──
|
||||||
|
|
||||||
@@ -537,27 +500,12 @@
|
|||||||
if (string.IsNullOrWhiteSpace(Esid))
|
if (string.IsNullOrWhiteSpace(Esid))
|
||||||
{
|
{
|
||||||
var sid = Guid.NewGuid().ToString("N");
|
var sid = Guid.NewGuid().ToString("N");
|
||||||
var editQuery = EnvelopeId.HasValue && EnvelopeId.Value > 0
|
NavigationManager.NavigateTo($"/sender/editor?esid={sid}", forceLoad: false);
|
||||||
? $"&envelopeId={EnvelopeId.Value}"
|
|
||||||
: string.Empty;
|
|
||||||
NavigationManager.NavigateTo($"/sender/editor?esid={sid}{editQuery}", forceLoad: false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task OnParametersSetAsync()
|
protected override void OnParametersSet()
|
||||||
{
|
{
|
||||||
if (EnvelopeId.HasValue && EnvelopeId.Value > 0)
|
|
||||||
_editingEnvelopeId = EnvelopeId.Value;
|
|
||||||
|
|
||||||
if (IsEditMode && _loadedEnvelopeId != EnvelopeId)
|
|
||||||
{
|
|
||||||
await LoadEnvelopeForEditAsync(EnvelopeId!.Value);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (IsEditMode)
|
|
||||||
return;
|
|
||||||
|
|
||||||
// After the esid is set via query param, try to restore from cache.
|
// After the esid is set via query param, try to restore from cache.
|
||||||
if (!string.IsNullOrWhiteSpace(Esid)
|
if (!string.IsNullOrWhiteSpace(Esid)
|
||||||
&& MemoryCache.TryGetValue(SessionKey, out EditorSessionData? cached)
|
&& MemoryCache.TryGetValue(SessionKey, out EditorSessionData? cached)
|
||||||
@@ -577,90 +525,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task LoadEnvelopeForEditAsync(int envelopeId)
|
|
||||||
{
|
|
||||||
if (_isLoadingEnvelopeForEdit)
|
|
||||||
return;
|
|
||||||
|
|
||||||
_isLoadingEnvelopeForEdit = true;
|
|
||||||
_errorMessage = null;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var user = EnvelopeAuthService.GetCurrentUser();
|
|
||||||
if (user?.Identity is null || !user.Identity.IsAuthenticated)
|
|
||||||
throw new InvalidOperationException("Sender is not authenticated.");
|
|
||||||
|
|
||||||
var envelopes = await EnvelopeService.GetAsync(id: envelopeId);
|
|
||||||
var envelope = envelopes?.FirstOrDefault()
|
|
||||||
?? throw new InvalidOperationException($"Envelope {envelopeId} not found.");
|
|
||||||
|
|
||||||
var document = await ReceiverPageDataService.GetDocumentDetailsOfSenderAsync(user, envelopeId);
|
|
||||||
|
|
||||||
_envelopeTitle = envelope.Title ?? string.Empty;
|
|
||||||
_envelopeMessage = envelope.Message ?? string.Empty;
|
|
||||||
|
|
||||||
var envelopeReceivers = envelope.EnvelopeReceivers?.ToList() ?? [];
|
|
||||||
_receivers = envelopeReceivers
|
|
||||||
.Select((er, index) => new ReceiverDraft(
|
|
||||||
Guid.NewGuid(),
|
|
||||||
er.Name ?? er.Receiver?.EmailAddress ?? $"Receiver {er.ReceiverId}",
|
|
||||||
er.Receiver?.EmailAddress ?? string.Empty,
|
|
||||||
string.Empty,
|
|
||||||
ReceiverPalette[index % ReceiverPalette.Length]))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var receiverById = envelopeReceivers.ToDictionary(er => er.ReceiverId, er => er);
|
|
||||||
var colorByEmail = _receivers
|
|
||||||
.Where(r => !string.IsNullOrWhiteSpace(r.Email))
|
|
||||||
.GroupBy(r => r.Email, StringComparer.OrdinalIgnoreCase)
|
|
||||||
.ToDictionary(g => g.Key, g => g.First().Color, StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
_signatureFields = (document?.Elements ?? Enumerable.Empty<DocReceiverElementDto>())
|
|
||||||
.Select(element =>
|
|
||||||
{
|
|
||||||
receiverById.TryGetValue(element.ReceiverId, out var envelopeReceiver);
|
|
||||||
|
|
||||||
var receiverName = envelopeReceiver?.Name
|
|
||||||
?? envelopeReceiver?.Receiver?.EmailAddress
|
|
||||||
?? element.FullName
|
|
||||||
?? $"Receiver {element.ReceiverId}";
|
|
||||||
|
|
||||||
var receiverEmail = envelopeReceiver?.Receiver?.EmailAddress;
|
|
||||||
var color = (!string.IsNullOrWhiteSpace(receiverEmail) && colorByEmail.TryGetValue(receiverEmail, out var receiverColor))
|
|
||||||
? receiverColor
|
|
||||||
: ReceiverPalette[Math.Abs(element.ReceiverId) % ReceiverPalette.Length];
|
|
||||||
|
|
||||||
return new SignatureFieldDraft(
|
|
||||||
XPt: element.X * 72.0,
|
|
||||||
YPt: element.Y * 72.0,
|
|
||||||
Page: element.Page,
|
|
||||||
ReceiverName: receiverName,
|
|
||||||
Color: color);
|
|
||||||
})
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
_originalPdfBytes = document?.ByteData;
|
|
||||||
_pdfLoaded = _originalPdfBytes is { Length: > 0 };
|
|
||||||
_pdfBytes = _pdfLoaded
|
|
||||||
? DrawPlaceholders(_originalPdfBytes!, _signatureFields)
|
|
||||||
: null;
|
|
||||||
_fileName = $"Envelope-{envelopeId}.pdf";
|
|
||||||
_pendingReceiverForPlacement = null;
|
|
||||||
_loadedEnvelopeId = envelopeId;
|
|
||||||
_editingEnvelopeId = envelopeId;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.LogError(ex, "Failed to load envelope {EnvelopeId} for edit mode", envelopeId);
|
|
||||||
_errorMessage = $"Fehler beim Laden des Umschlags #{envelopeId}: {ex.Message}";
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_isLoadingEnvelopeForEdit = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── PDF upload ──
|
// ── PDF upload ──
|
||||||
async Task OnPdfFileSelectedAsync(InputFileChangeEventArgs e)
|
async Task OnPdfFileSelectedAsync(InputFileChangeEventArgs e)
|
||||||
{
|
{
|
||||||
@@ -806,48 +670,8 @@
|
|||||||
NavigationManager.NavigateTo("/sender");
|
NavigationManager.NavigateTo("/sender");
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task SaveDraftAsync()
|
// ── Save — POST /api/EnvelopeReceiver ──
|
||||||
{
|
async Task SaveAsync()
|
||||||
_isSaving = true;
|
|
||||||
_saveErrorMessage = null;
|
|
||||||
await InvokeAsync(StateHasChanged);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var previousEnvelopeId = EnvelopeId;
|
|
||||||
var command = BuildCreateEnvelopeCommand(send: false);
|
|
||||||
Logger.LogInformation("[SenderEditor] Saving draft. EnvelopeId={EnvelopeId} IsEditMode={IsEditMode}", command.EnvelopeId, IsEditMode);
|
|
||||||
var result = await EnvelopeService.UpsertAsync(command);
|
|
||||||
|
|
||||||
if (result is null)
|
|
||||||
throw new InvalidOperationException("Draft save did not return a result.");
|
|
||||||
|
|
||||||
EnvelopeId = result.Id;
|
|
||||||
_loadedEnvelopeId = result.Id;
|
|
||||||
_editingEnvelopeId = result.Id;
|
|
||||||
|
|
||||||
if (previousEnvelopeId != result.Id)
|
|
||||||
{
|
|
||||||
var sid = string.IsNullOrWhiteSpace(Esid) ? Guid.NewGuid().ToString("N") : Esid;
|
|
||||||
NavigationManager.NavigateTo($"/sender/editor?esid={sid}&envelopeId={result.Id}", forceLoad: false);
|
|
||||||
}
|
|
||||||
|
|
||||||
NavigationManager.NavigateTo("/sender", forceLoad: false);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.LogError(ex, "[SenderEditor] Failed to save draft");
|
|
||||||
_saveErrorMessage = ex.Message;
|
|
||||||
_savePopupVisible = true;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_isSaving = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Send envelope ──
|
|
||||||
async Task SendAsync()
|
|
||||||
{
|
{
|
||||||
// ── Validation ──
|
// ── Validation ──
|
||||||
_titleTouched = true;
|
_titleTouched = true;
|
||||||
@@ -893,38 +717,57 @@
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var envelopeId = _editingEnvelopeId
|
// ── Build request ──
|
||||||
?? EnvelopeId
|
// Document: use the ORIGINAL pdf (without placeholder burn-in) as base64
|
||||||
?? _loadedEnvelopeId
|
var docBase64 = Convert.ToBase64String(_originalPdfBytes);
|
||||||
?? GetEnvelopeIdFromCurrentUri();
|
|
||||||
|
|
||||||
if (!envelopeId.HasValue || envelopeId.Value <= 0)
|
// Build receivers list — each receiver gets their own signature fields
|
||||||
|
// Coordinate conversion: PDF points → inches (DB stores inches)
|
||||||
|
var receiversCmd = _receivers.Select(receiver =>
|
||||||
{
|
{
|
||||||
var saveResult = await EnvelopeService.UpsertAsync(BuildCreateEnvelopeCommand(send: false))
|
var fields = _signatureFields
|
||||||
?? throw new InvalidOperationException("Draft save did not return a result.");
|
.Where(f => f.ReceiverName == receiver.FullName)
|
||||||
|
.Select(f => new DocReceiverElementCreateDto(
|
||||||
|
X: f.XPt / 72.0,
|
||||||
|
Y: f.YPt / 72.0,
|
||||||
|
Page: f.Page))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
envelopeId = saveResult.Id;
|
return new ReceiverGetOrCreateCommand
|
||||||
EnvelopeId = saveResult.Id;
|
{
|
||||||
_loadedEnvelopeId = saveResult.Id;
|
EmailAddress = receiver.Email,
|
||||||
_editingEnvelopeId = saveResult.Id;
|
Salution = receiver.FullName,
|
||||||
}
|
PhoneNumber = string.IsNullOrWhiteSpace(receiver.PhoneNumber)
|
||||||
|
? null
|
||||||
|
: receiver.PhoneNumber,
|
||||||
|
DocReceiverElements = fields,
|
||||||
|
};
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
var sendResult = await EnvelopeService.UpsertAsync(BuildCreateEnvelopeCommand(send: true) with { EnvelopeId = envelopeId })
|
var command = new CreateEnvelopeReceiverCommand
|
||||||
?? throw new InvalidOperationException("Send did not return a result.");
|
{
|
||||||
|
Title = _envelopeTitle.Trim(),
|
||||||
|
Message = string.IsNullOrWhiteSpace(_envelopeMessage)
|
||||||
|
? "Bitte unterzeichnen Sie das beigefügte Dokument."
|
||||||
|
: _envelopeMessage.Trim(),
|
||||||
|
TFAEnabled = false,
|
||||||
|
Document = new DocumentCreateCommand { DataAsBase64 = docBase64 },
|
||||||
|
Receivers = receiversCmd,
|
||||||
|
};
|
||||||
|
|
||||||
Logger.LogInformation("[SenderEditor] Envelope sent from draft. Id={Id} Status={Status}", sendResult.Id, sendResult.Status);
|
var result = await EnvelopeReceiverService.CreateAsync(command);
|
||||||
|
|
||||||
|
Logger.LogInformation(
|
||||||
|
"[SenderEditor] Envelope created. Id={Id} SentReceivers={Count}",
|
||||||
|
result?.Id, result?.SentReceiver.Count());
|
||||||
|
|
||||||
// Success — show popup; GoToDashboard clears cache and navigates
|
// Success — show popup; GoToDashboard clears cache and navigates
|
||||||
_saveErrorMessage = null;
|
_saveErrorMessage = null;
|
||||||
_saveSuccessHeader = "Umschlag gesendet";
|
|
||||||
_saveSuccessTitle = "Umschlag wurde erfolgreich gesendet.";
|
|
||||||
_saveSuccessBody = "Der bestehende Entwurf wurde versendet.";
|
|
||||||
_saveNavigateToDashboard = true;
|
|
||||||
_savePopupVisible = true;
|
_savePopupVisible = true;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Logger.LogError(ex, "[SenderEditor] Failed to send envelope draft");
|
Logger.LogError(ex, "[SenderEditor] Failed to create envelope");
|
||||||
_saveErrorMessage = ex.Message;
|
_saveErrorMessage = ex.Message;
|
||||||
_savePopupVisible = true;
|
_savePopupVisible = true;
|
||||||
}
|
}
|
||||||
@@ -934,71 +777,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
CreateEnvelopeCommand BuildCreateEnvelopeCommand(bool send)
|
|
||||||
{
|
|
||||||
var envelopeId = _editingEnvelopeId
|
|
||||||
?? EnvelopeId
|
|
||||||
?? _loadedEnvelopeId
|
|
||||||
?? GetEnvelopeIdFromCurrentUri();
|
|
||||||
|
|
||||||
if (IsEditMode && envelopeId is null)
|
|
||||||
throw new InvalidOperationException("Edit mode envelope id is missing.");
|
|
||||||
|
|
||||||
var docBase64 = _originalPdfBytes is { Length: > 0 }
|
|
||||||
? Convert.ToBase64String(_originalPdfBytes)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
var receiverCommands = _receivers.Select(receiver =>
|
|
||||||
{
|
|
||||||
var fields = _signatureFields
|
|
||||||
.Where(f => f.ReceiverName == receiver.FullName)
|
|
||||||
.Select(f => new CreateEnvelopeFieldDto(
|
|
||||||
X: f.XPt / 72.0,
|
|
||||||
Y: f.YPt / 72.0,
|
|
||||||
Page: f.Page))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return new CreateEnvelopeReceiverDto(
|
|
||||||
EmailAddress: receiver.Email,
|
|
||||||
Name: receiver.FullName,
|
|
||||||
PhoneNumber: string.IsNullOrWhiteSpace(receiver.PhoneNumber) ? null : receiver.PhoneNumber,
|
|
||||||
Fields: fields);
|
|
||||||
}).ToList();
|
|
||||||
|
|
||||||
return new CreateEnvelopeCommand
|
|
||||||
{
|
|
||||||
EnvelopeId = envelopeId,
|
|
||||||
Title = _envelopeTitle,
|
|
||||||
Message = _envelopeMessage,
|
|
||||||
TFAEnabled = false,
|
|
||||||
Send = send,
|
|
||||||
Document = string.IsNullOrWhiteSpace(docBase64) ? null : new CreateEnvelopeDocumentDto(docBase64),
|
|
||||||
Receivers = receiverCommands
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
int? GetEnvelopeIdFromCurrentUri()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri);
|
|
||||||
if (string.IsNullOrWhiteSpace(uri.Query))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
var query = QueryHelpers.ParseQuery(uri.Query);
|
|
||||||
if (!query.TryGetValue("envelopeId", out var raw))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return int.TryParse(raw.ToString(), out var id) && id > 0
|
|
||||||
? id
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Cache persistence ──
|
// ── Cache persistence ──
|
||||||
void PersistSession()
|
void PersistSession()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
Neuer Umschlag
|
Neuer Umschlag
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button class="sender-btn" @onclick="EditEnvelope" disabled="@(_selectedEnvelope == null || !IsEnvelopeEditable(_selectedEnvelope))" title="Ausgewählten Umschlag bearbeiten">
|
<button class="sender-btn" @onclick="EditEnvelope" disabled="@(_selectedEnvelope == null || IsEnvelopeSent(_selectedEnvelope))" title="Ausgewählten Umschlag bearbeiten">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||||
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z" />
|
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -521,54 +521,6 @@
|
|||||||
</BodyContentTemplate>
|
</BodyContentTemplate>
|
||||||
</DxPopup>
|
</DxPopup>
|
||||||
|
|
||||||
<DxPopup @bind-Visible="@_deletePopupVisible"
|
|
||||||
HeaderText="Umschlag löschen"
|
|
||||||
Width="560px"
|
|
||||||
MaxWidth="95vw"
|
|
||||||
ShowFooter="true"
|
|
||||||
CloseOnOutsideClick="false"
|
|
||||||
ShowCloseButton="true"
|
|
||||||
CloseOnEscape="true"
|
|
||||||
ShowHeader="true"
|
|
||||||
CssClass="sender-delete-popup">
|
|
||||||
<BodyContentTemplate>
|
|
||||||
<div style="display:flex;flex-direction:column;gap:0.75rem;">
|
|
||||||
<p style="margin:0;color:#374151;font-size:0.9rem;line-height:1.45;">
|
|
||||||
Bitte geben Sie einen Grund an, warum der Umschlag gelöscht/zurückgezogen werden soll.
|
|
||||||
</p>
|
|
||||||
@if (_selectedEnvelope is not null)
|
|
||||||
{
|
|
||||||
<p style="margin:0;color:#6b7280;font-size:0.825rem;">
|
|
||||||
Umschlag: <strong>#@_selectedEnvelope.Id - @_selectedEnvelope.Title</strong>
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
<DxMemo @bind-Text="_deleteReason"
|
|
||||||
NullText="Begründung eingeben..."
|
|
||||||
Rows="5"
|
|
||||||
MaxLength="2000"
|
|
||||||
CssClass="w-100" />
|
|
||||||
@if (!string.IsNullOrWhiteSpace(_deleteErrorMessage))
|
|
||||||
{
|
|
||||||
<div class="alert alert-danger" style="margin:0;padding:0.5rem 0.75rem;font-size:0.85rem;">
|
|
||||||
@_deleteErrorMessage
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</BodyContentTemplate>
|
|
||||||
<FooterContentTemplate>
|
|
||||||
<div style="display:flex;justify-content:flex-end;gap:0.5rem;width:100%;">
|
|
||||||
<DxButton Text="Abbrechen"
|
|
||||||
RenderStyle="ButtonRenderStyle.Secondary"
|
|
||||||
Click="CloseDeletePopup"
|
|
||||||
Enabled="@(!_isDeletingEnvelope)" />
|
|
||||||
<DxButton Text="Löschen"
|
|
||||||
RenderStyle="ButtonRenderStyle.Danger"
|
|
||||||
Click="ConfirmDeleteEnvelopeAsync"
|
|
||||||
Enabled="@(!_isDeletingEnvelope)" />
|
|
||||||
</div>
|
|
||||||
</FooterContentTemplate>
|
|
||||||
</DxPopup>
|
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private IEnumerable<EnvelopeDto>? _allEnvelopes;
|
private IEnumerable<EnvelopeDto>? _allEnvelopes;
|
||||||
private IEnumerable<EnvelopeDto>? _activeEnvelopes;
|
private IEnumerable<EnvelopeDto>? _activeEnvelopes;
|
||||||
@@ -597,11 +549,6 @@
|
|||||||
private bool _exportSelectedRowsOnly = false;
|
private bool _exportSelectedRowsOnly = false;
|
||||||
private bool _isExporting = false;
|
private bool _isExporting = false;
|
||||||
|
|
||||||
private bool _deletePopupVisible = false;
|
|
||||||
private bool _isDeletingEnvelope = false;
|
|
||||||
private string _deleteReason = string.Empty;
|
|
||||||
private string? _deleteErrorMessage;
|
|
||||||
|
|
||||||
// Stable keys used to store/restore each grid's layout in UserPreferences.GridLayouts
|
// Stable keys used to store/restore each grid's layout in UserPreferences.GridLayouts
|
||||||
private const string GridKeyActive = "sender.active-envelopes";
|
private const string GridKeyActive = "sender.active-envelopes";
|
||||||
private const string GridKeyCompleted = "sender.completed-envelopes";
|
private const string GridKeyCompleted = "sender.completed-envelopes";
|
||||||
@@ -728,59 +675,13 @@
|
|||||||
void EditEnvelope()
|
void EditEnvelope()
|
||||||
{
|
{
|
||||||
if (_selectedEnvelope == null) return;
|
if (_selectedEnvelope == null) return;
|
||||||
if (!IsEnvelopeEditable(_selectedEnvelope)) return;
|
// TODO: Navigate to envelope editor
|
||||||
|
|
||||||
Navigation.NavigateTo($"/sender/editor?envelopeId={_selectedEnvelope.Id}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void DeleteEnvelope()
|
void DeleteEnvelope()
|
||||||
{
|
{
|
||||||
if (_selectedEnvelope == null) return;
|
if (_selectedEnvelope == null) return;
|
||||||
_deleteReason = string.Empty;
|
// TODO: Show delete confirmation dialog
|
||||||
_deleteErrorMessage = null;
|
|
||||||
_deletePopupVisible = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
void CloseDeletePopup()
|
|
||||||
{
|
|
||||||
if (_isDeletingEnvelope)
|
|
||||||
return;
|
|
||||||
|
|
||||||
_deletePopupVisible = false;
|
|
||||||
_deleteErrorMessage = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async Task ConfirmDeleteEnvelopeAsync()
|
|
||||||
{
|
|
||||||
if (_selectedEnvelope is null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(_deleteReason))
|
|
||||||
{
|
|
||||||
_deleteErrorMessage = "Bitte geben Sie einen Löschgrund an.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_isDeletingEnvelope = true;
|
|
||||||
_deleteErrorMessage = null;
|
|
||||||
await InvokeAsync(StateHasChanged);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await EnvelopeService.DeleteAsync(_selectedEnvelope.Id, _deleteReason.Trim());
|
|
||||||
_deletePopupVisible = false;
|
|
||||||
_selectedEnvelope = null;
|
|
||||||
await LoadEnvelopesAsync();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_deleteErrorMessage = ex.Message;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_isDeletingEnvelope = false;
|
|
||||||
await InvokeAsync(StateHasChanged);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task LogoutAsync()
|
async Task LogoutAsync()
|
||||||
@@ -798,12 +699,6 @@
|
|||||||
return status >= EnvelopeStatus.EnvelopeQueued;
|
return status >= EnvelopeStatus.EnvelopeQueued;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool IsEnvelopeEditable(EnvelopeDto envelope)
|
|
||||||
{
|
|
||||||
var status = (EnvelopeStatus)envelope.Status;
|
|
||||||
return status is EnvelopeStatus.EnvelopeCreated or EnvelopeStatus.EnvelopeSaved;
|
|
||||||
}
|
|
||||||
|
|
||||||
(string Label, string CssClass, string DotColor) GetStatusInfo(EnvelopeStatus status)
|
(string Label, string CssClass, string DotColor) GetStatusInfo(EnvelopeStatus status)
|
||||||
{
|
{
|
||||||
return status switch
|
return status switch
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
|
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
|
||||||
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverAuthorizationService ReceiverAuthorizationService
|
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverAuthorizationService ReceiverAuthorizationService
|
||||||
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverPageDataService PageDataService
|
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverPageDataService PageDataService
|
||||||
|
@inject SignatureService SignatureService
|
||||||
@inject AppVersionService AppVersion
|
@inject AppVersionService AppVersion
|
||||||
@inject IMemoryCache MemoryCache
|
@inject IMemoryCache MemoryCache
|
||||||
@inject ILogger<ReceiverSignedPage> Logger
|
@inject ILogger<ReceiverSignedPage> Logger
|
||||||
@@ -122,6 +123,12 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(_submitErrorMessage))
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger mb-0" role="alert" style="font-size: 0.85rem;">
|
||||||
|
@_submitErrorMessage
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</BodyContentTemplate>
|
</BodyContentTemplate>
|
||||||
<FooterContentTemplate>
|
<FooterContentTemplate>
|
||||||
<div class="d-flex gap-2 justify-content-end w-100" style="padding: 0.5rem 0;">
|
<div class="d-flex gap-2 justify-content-end w-100" style="padding: 0.5rem 0;">
|
||||||
@@ -161,17 +168,48 @@
|
|||||||
// ----- Submit / logout state -----
|
// ----- Submit / logout state -----
|
||||||
bool _isLoggingOut = false;
|
bool _isLoggingOut = false;
|
||||||
bool _submitConfirmVisible = false;
|
bool _submitConfirmVisible = false;
|
||||||
|
string? _submitErrorMessage;
|
||||||
|
|
||||||
void OpenSubmitConfirmPopup() => _submitConfirmVisible = true;
|
void OpenSubmitConfirmPopup() => _submitConfirmVisible = true;
|
||||||
|
|
||||||
async Task SubmitAndLogoutAsync()
|
async Task SubmitAndLogoutAsync()
|
||||||
{
|
{
|
||||||
if (_isLoggingOut) return;
|
if (_isLoggingOut) return;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(EnvelopeKey) || _sig is null)
|
||||||
|
return;
|
||||||
|
|
||||||
_isLoggingOut = true;
|
_isLoggingOut = true;
|
||||||
_submitConfirmVisible = false;
|
_submitConfirmVisible = false;
|
||||||
|
_submitErrorMessage = null;
|
||||||
await InvokeAsync(StateHasChanged);
|
await InvokeAsync(StateHasChanged);
|
||||||
await AuthService.LogoutEnvelopeReceiverAsync(EnvelopeKey!);
|
|
||||||
Navigation.NavigateTo("/", forceLoad: true);
|
try
|
||||||
|
{
|
||||||
|
if (_signatures.Count == 0)
|
||||||
|
throw new InvalidOperationException("No signature fields found for receiver.");
|
||||||
|
|
||||||
|
var payload = _signatures.Select(field => new SignatureSubmitDto
|
||||||
|
{
|
||||||
|
Id = field.Id,
|
||||||
|
DataUrl = _sig.DataUrl,
|
||||||
|
FullName = _sig.FullName,
|
||||||
|
Position = string.IsNullOrWhiteSpace(_sig.Position) ? null : _sig.Position,
|
||||||
|
Place = _sig.Place
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
await SignatureService.SubmitAsync(EnvelopeKey, payload);
|
||||||
|
|
||||||
|
await AuthService.LogoutEnvelopeReceiverAsync(EnvelopeKey);
|
||||||
|
Navigation.NavigateTo("/", forceLoad: true);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "Failed to finalize signature for {EnvelopeKey}", EnvelopeKey);
|
||||||
|
_submitErrorMessage = "Die Unterschrift konnte nicht abgeschlossen werden. Bitte versuchen Sie es erneut.";
|
||||||
|
_isLoggingOut = false;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
|
|||||||
@@ -27,8 +27,6 @@ namespace EnvelopeGenerator.Server.Controllers;
|
|||||||
[Authorize]
|
[Authorize]
|
||||||
public class EnvelopeController : ControllerBase
|
public class EnvelopeController : ControllerBase
|
||||||
{
|
{
|
||||||
public record DeleteEnvelopeRequest(string Reason);
|
|
||||||
|
|
||||||
private readonly ILogger<EnvelopeController> _logger;
|
private readonly ILogger<EnvelopeController> _logger;
|
||||||
private readonly IMediator _mediator;
|
private readonly IMediator _mediator;
|
||||||
|
|
||||||
@@ -95,6 +93,7 @@ public class EnvelopeController : ControllerBase
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="command"></param>
|
/// <param name="command"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
|
[NonAction]
|
||||||
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
|
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> CreateAsync([FromBody] CreateEnvelopeCommand command)
|
public async Task<IActionResult> CreateAsync([FromBody] CreateEnvelopeCommand command)
|
||||||
@@ -109,18 +108,4 @@ public class EnvelopeController : ControllerBase
|
|||||||
else
|
else
|
||||||
return Ok(res);
|
return Ok(res);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
|
|
||||||
[HttpDelete("{id:int}")]
|
|
||||||
public async Task<IActionResult> DeleteAsync([FromRoute] int id, [FromBody] DeleteEnvelopeRequest request)
|
|
||||||
{
|
|
||||||
var result = await _mediator.Send(new DeleteEnvelopeCommand
|
|
||||||
{
|
|
||||||
EnvelopeId = id,
|
|
||||||
Reason = request?.Reason ?? string.Empty
|
|
||||||
}.WithAuth(User.GetId()));
|
|
||||||
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
using EnvelopeGenerator.Server.Extensions;
|
using EnvelopeGenerator.Server.Extensions;
|
||||||
using EnvelopeGenerator.Application.Common.Dto;
|
using EnvelopeGenerator.Application.Common.Dto;
|
||||||
using EnvelopeGenerator.Application.Common.Extensions;
|
using EnvelopeGenerator.Application.Common.Extensions;
|
||||||
|
using EnvelopeGenerator.Application.DocReceiverElements.Commands;
|
||||||
using EnvelopeGenerator.Application.Documents.Queries;
|
using EnvelopeGenerator.Application.Documents.Queries;
|
||||||
|
using EnvelopeGenerator.Application.EnvelopeReceivers.Queries;
|
||||||
|
using EnvelopeGenerator.Application.Histories.Queries;
|
||||||
using EnvelopeGenerator.Domain.Constants;
|
using EnvelopeGenerator.Domain.Constants;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
@@ -18,13 +21,15 @@ namespace EnvelopeGenerator.Server.Controllers;
|
|||||||
public class SignatureController : ControllerBase
|
public class SignatureController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IMediator _mediator;
|
private readonly IMediator _mediator;
|
||||||
|
private readonly ILogger<SignatureController> _logger;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of <see cref="SignatureController"/>.
|
/// Initializes a new instance of <see cref="SignatureController"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public SignatureController(IMediator mediator)
|
public SignatureController(IMediator mediator, ILogger<SignatureController> logger)
|
||||||
{
|
{
|
||||||
_mediator = mediator;
|
_mediator = mediator;
|
||||||
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
//TODO: update to use signature query
|
//TODO: update to use signature query
|
||||||
@@ -54,4 +59,46 @@ public class SignatureController : ControllerBase
|
|||||||
else
|
else
|
||||||
return Ok(rcvSignatures);
|
return Ok(rcvSignatures);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Finalizes the receiver signing flow and persists signature data to TBSIG_DOCUMENT_RECEIVER_ELEMENT.
|
||||||
|
/// Also triggers status/history/mail side effects through SigningCommand pipeline.
|
||||||
|
/// </summary>
|
||||||
|
[Authorize(Policy = AuthPolicy.Receiver)]
|
||||||
|
[HttpPost("submit/{envelopeKey}")]
|
||||||
|
public async Task<IActionResult> Submit(
|
||||||
|
[FromRoute] string envelopeKey,
|
||||||
|
[FromBody] IEnumerable<Signature>? signatures,
|
||||||
|
CancellationToken cancel)
|
||||||
|
{
|
||||||
|
if (signatures is null)
|
||||||
|
return BadRequest("Signature payload is required.");
|
||||||
|
|
||||||
|
var uuid = User.EnvelopeUuid();
|
||||||
|
var receiverSignature = User.ReceiverSignature();
|
||||||
|
|
||||||
|
if (await _mediator.IsSignedAsync(uuid, receiverSignature, cancel))
|
||||||
|
return Problem(statusCode: StatusCodes.Status409Conflict);
|
||||||
|
|
||||||
|
if (await _mediator.AnyHistoryAsync(uuid, new[] { EnvelopeStatus.EnvelopeRejected, EnvelopeStatus.DocumentRejected }, cancel))
|
||||||
|
return Problem(statusCode: StatusCodes.Status423Locked);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _mediator.Send(new SigningCommand
|
||||||
|
{
|
||||||
|
Envelope = new() { Uuid = uuid },
|
||||||
|
Receiver = new() { Signature = receiverSignature },
|
||||||
|
ReceiverAppType = ReceiverAppType.ReceiverUI,
|
||||||
|
Signatures = signatures.ToList()
|
||||||
|
}, cancel);
|
||||||
|
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to submit signatures for envelope {EnvelopeKey}", envelopeKey);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,11 +69,6 @@ public class ExceptionHandlingMiddleware
|
|||||||
message = notFoundEx.Message;
|
message = notFoundEx.Message;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case ForbiddenException forbiddenEx:
|
|
||||||
context.Response.StatusCode = (int)HttpStatusCode.Forbidden;
|
|
||||||
message = forbiddenEx.Message;
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
logger.LogError(exception, "Unhandled exception occurred.");
|
logger.LogError(exception, "Unhandled exception occurred.");
|
||||||
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
|
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
"commandName": "Project",
|
"commandName": "Project",
|
||||||
"dotnetRunMessages": true,
|
"dotnetRunMessages": true,
|
||||||
"launchBrowser": true,
|
"launchBrowser": true,
|
||||||
"applicationUrl": "https://localhost:8088;http://localhost:5131",
|
"applicationUrl": "https://localhost:8089;http://localhost:5132",
|
||||||
"environmentVariables": {
|
"environmentVariables": {
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -49,14 +49,6 @@ public class EnvelopeReceiverPageDataService(
|
|||||||
: throw new NotFoundException($"Document for envelope {envelopeId} not found.");
|
: throw new NotFoundException($"Document for envelope {envelopeId} not found.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Loads the full document DTO (including receiver elements) for the authenticated sender.
|
|
||||||
/// </summary>
|
|
||||||
public async Task<DocumentDto?> GetDocumentDetailsOfSenderAsync(ClaimsPrincipal user, int envelopeId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await mediator.Send(new ReadDocumentQuery(EnvelopeId: envelopeId, UserId: user.SenderId()), cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Loads the current receiver's signature placeholders.
|
/// Loads the current receiver's signature placeholders.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -185,9 +185,10 @@
|
|||||||
"TimeLimit": "00:30:00"
|
"TimeLimit": "00:30:00"
|
||||||
},
|
},
|
||||||
"DbTriggerParams": {
|
"DbTriggerParams": {
|
||||||
"Envelope": [ "TBSIG_ENVELOPE_HISTORY_AFT_INS" ],
|
|
||||||
"History": [ "TBSIG_ENVELOPE_HISTORY_AFT_INS" ],
|
|
||||||
"DocReceiverElement": [ "TBSIG_DOCUMENT_RECEIVER_ELEMENT_AFT_INS", "TBSIG_DOCUMENT_RECEIVER_ELEMENT_AFT_UPD" ],
|
"DocReceiverElement": [ "TBSIG_DOCUMENT_RECEIVER_ELEMENT_AFT_INS", "TBSIG_DOCUMENT_RECEIVER_ELEMENT_AFT_UPD" ],
|
||||||
|
"Envelope": [ "TBSIG_ENVELOPE_HISTORY_AFT_INS" ],
|
||||||
|
"EnvelopeHistory": [ "TBSIG_ENVELOPE_HISTORY_AFT_INS" ],
|
||||||
|
"History": [ "TBSIG_ENVELOPE_HISTORY_AFT_INS" ],
|
||||||
"EmailOut": [ "TBEMLP_EMAIL_OUT_AFT_INS", "TBEMLP_EMAIL_OUT_AFT_UPD" ],
|
"EmailOut": [ "TBEMLP_EMAIL_OUT_AFT_INS", "TBEMLP_EMAIL_OUT_AFT_UPD" ],
|
||||||
"EnvelopeReceiverReadOnly": [ "TBSIG_ENVELOPE_RECEIVER_READ_ONLY_UPD" ],
|
"EnvelopeReceiverReadOnly": [ "TBSIG_ENVELOPE_RECEIVER_READ_ONLY_UPD" ],
|
||||||
"Receiver": [],
|
"Receiver": [],
|
||||||
|
|||||||
@@ -542,40 +542,6 @@ body.resizing {
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Draft save button for sender editor */
|
|
||||||
.pdf-toolbar__btn--draft {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
min-width: auto;
|
|
||||||
padding: 0.3rem 0.75rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #0f4c81;
|
|
||||||
border: 1px solid rgba(14, 165, 233, 0.34);
|
|
||||||
background: linear-gradient(135deg, rgba(14, 165, 233, 0.12) 0%, rgba(2, 132, 199, 0.1) 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pdf-toolbar__btn--draft:hover:not(:disabled) {
|
|
||||||
color: #ffffff;
|
|
||||||
border-color: transparent;
|
|
||||||
background: linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%);
|
|
||||||
box-shadow: 0 4px 12px rgba(2, 132, 199, 0.28);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pdf-toolbar__btn--draft:disabled {
|
|
||||||
color: #64748b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pdf-toolbar__btn--draft svg {
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pdf-toolbar__btn--draft .pdf-toolbar__btn-text {
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Signature Change Button */
|
/* Signature Change Button */
|
||||||
.pdf-toolbar__btn--signature-change {
|
.pdf-toolbar__btn--signature-change {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
using DigitalData.Core.Exceptions;
|
|
||||||
using DigitalData.EmailProfilerDispatcher.Abstraction.Entities;
|
|
||||||
using EnvelopeGenerator.Application.Envelopes.Commands;
|
|
||||||
using EnvelopeGenerator.Domain.Constants;
|
|
||||||
using EnvelopeGenerator.Domain.Entities;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
|
|
||||||
namespace EnvelopeGenerator.Tests.Application;
|
|
||||||
|
|
||||||
[TestFixture]
|
|
||||||
public class DeleteEnvelopeCommandTests : TestBase
|
|
||||||
{
|
|
||||||
protected override void ConfigureServices(IServiceCollection services)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task DeleteEnvelope_DraftEnvelope_ShouldSetDeleted_CreateHistory_AndQueueDeletionEmail()
|
|
||||||
{
|
|
||||||
CancellationToken cancel = default;
|
|
||||||
var reason = "Invalid receiver data";
|
|
||||||
|
|
||||||
var envelope = this.CreateEnvelope(User.Id);
|
|
||||||
envelope.Status = EnvelopeStatus.EnvelopeCreated;
|
|
||||||
envelope = await GetRepository<Envelope>().CreateAsync(envelope, cancel);
|
|
||||||
|
|
||||||
var createReceiverCmd = this.CreateReceiverCommand();
|
|
||||||
(var receiver, _) = await Mediator.Send(createReceiverCmd, cancel);
|
|
||||||
|
|
||||||
var envReceiver = this.CreateEnvelopeReceiver(envelope.Id, receiver.Id);
|
|
||||||
await GetRepository<EnvelopeReceiver>().CreateAsync(envReceiver, cancel);
|
|
||||||
|
|
||||||
var result = await Mediator.Send(new DeleteEnvelopeCommand
|
|
||||||
{
|
|
||||||
EnvelopeId = envelope.Id,
|
|
||||||
Reason = reason
|
|
||||||
}.WithAuth(User.Id), cancel);
|
|
||||||
|
|
||||||
Assert.That(result.EnvelopeId, Is.EqualTo(envelope.Id));
|
|
||||||
Assert.That(result.AppliedStatus, Is.EqualTo(EnvelopeStatus.EnvelopeDeleted));
|
|
||||||
Assert.That(result.IsNoOp, Is.False);
|
|
||||||
|
|
||||||
var histories = await GetRepository<History>().Query
|
|
||||||
.Where(h => h.EnvelopeId == envelope.Id && h.Status == EnvelopeStatus.EnvelopeDeleted)
|
|
||||||
.ToListAsync(cancel);
|
|
||||||
Assert.That(histories, Has.Count.EqualTo(1));
|
|
||||||
Assert.That(histories[0].Comment, Is.EqualTo(reason));
|
|
||||||
|
|
||||||
var emails = await GetRepository<EmailOut>().Query
|
|
||||||
.Where(e => e.WfId == (int)EnvelopeStatus.MessageDeletionSent && e.ReferenceId == receiver.Id)
|
|
||||||
.ToListAsync(cancel);
|
|
||||||
Assert.That(emails, Has.Count.EqualTo(1));
|
|
||||||
Assert.That(emails[0].EmailAddress, Is.EqualTo(receiver.EmailAddress));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task DeleteEnvelope_SentEnvelope_ShouldSetWithdrawn()
|
|
||||||
{
|
|
||||||
CancellationToken cancel = default;
|
|
||||||
|
|
||||||
var envelope = this.CreateEnvelope(User.Id);
|
|
||||||
envelope.Status = EnvelopeStatus.EnvelopeQueued;
|
|
||||||
envelope = await GetRepository<Envelope>().CreateAsync(envelope, cancel);
|
|
||||||
|
|
||||||
var result = await Mediator.Send(new DeleteEnvelopeCommand
|
|
||||||
{
|
|
||||||
EnvelopeId = envelope.Id,
|
|
||||||
Reason = "Sender requested cancellation"
|
|
||||||
}.WithAuth(User.Id), cancel);
|
|
||||||
|
|
||||||
Assert.That(result.AppliedStatus, Is.EqualTo(EnvelopeStatus.EnvelopeWithdrawn));
|
|
||||||
|
|
||||||
var histories = await GetRepository<History>().Query
|
|
||||||
.Where(h => h.EnvelopeId == envelope.Id && h.Status == EnvelopeStatus.EnvelopeWithdrawn)
|
|
||||||
.ToListAsync(cancel);
|
|
||||||
Assert.That(histories, Has.Count.EqualTo(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void DeleteEnvelope_MissingReason_ShouldThrowBadRequest()
|
|
||||||
{
|
|
||||||
Assert.ThrowsAsync<BadRequestException>(async () =>
|
|
||||||
await Mediator.Send(new DeleteEnvelopeCommand
|
|
||||||
{
|
|
||||||
EnvelopeId = 123,
|
|
||||||
Reason = " "
|
|
||||||
}.WithAuth(User.Id)));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task DeleteEnvelope_WrongOwner_ShouldThrowForbidden()
|
|
||||||
{
|
|
||||||
CancellationToken cancel = default;
|
|
||||||
|
|
||||||
var envelope = this.CreateEnvelope(User.Id);
|
|
||||||
envelope = await GetRepository<Envelope>().CreateAsync(envelope, cancel);
|
|
||||||
|
|
||||||
var otherUserCmd = this.CreateUserCommand();
|
|
||||||
var otherUser = await GetRepository<DigitalData.UserManager.Domain.Entities.User>().CreateAsync(otherUserCmd, cancel);
|
|
||||||
|
|
||||||
Assert.ThrowsAsync<ForbiddenException>(async () =>
|
|
||||||
await Mediator.Send(new DeleteEnvelopeCommand
|
|
||||||
{
|
|
||||||
EnvelopeId = envelope.Id,
|
|
||||||
Reason = "Not allowed"
|
|
||||||
}.WithAuth(otherUser.Id), cancel));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -52,7 +52,7 @@ public class Fake
|
|||||||
|
|
||||||
services.AddEnvelopeGeneratorInfrastructureServices(opt =>
|
services.AddEnvelopeGeneratorInfrastructureServices(opt =>
|
||||||
{
|
{
|
||||||
opt.AddDbContext(dbCtxOpt => dbCtxOpt.UseInMemoryDatabase($"EnvelopeGeneratorTestDb_{Guid.NewGuid():N}"));
|
opt.AddDbContext(dbCtxOpt => dbCtxOpt.UseInMemoryDatabase("EnvelopeGeneratorTestDb"));
|
||||||
|
|
||||||
opt.AddDbTriggerParams(context.Configuration);
|
opt.AddDbTriggerParams(context.Configuration);
|
||||||
|
|
||||||
@@ -308,4 +308,4 @@ public static class Extensions
|
|||||||
.Select(_ => fake.CreateUserCommand())
|
.Select(_ => fake.CreateUserCommand())
|
||||||
.ToList();
|
.ToList();
|
||||||
#endregion
|
#endregion
|
||||||
}
|
}
|
||||||
@@ -38,10 +38,7 @@ public abstract class TestBase : Faker
|
|||||||
|
|
||||||
// Add seed email templates
|
// Add seed email templates
|
||||||
foreach (var temp in SeedEmailTemplates)
|
foreach (var temp in SeedEmailTemplates)
|
||||||
{
|
|
||||||
temp.LangCode ??= "de";
|
|
||||||
await repo.CreateAsync(temp);
|
await repo.CreateAsync(temp);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[TearDown]
|
[TearDown]
|
||||||
@@ -143,4 +140,4 @@ public abstract class TestBase : Faker
|
|||||||
ChangedWhen = DateTime.Parse("2025-05-12 10:43:44.290")
|
ChangedWhen = DateTime.Parse("2025-05-12 10:43:44.290")
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -7,10 +7,9 @@
|
|||||||
"DbTriggerParams": {
|
"DbTriggerParams": {
|
||||||
"Envelope": [ "TBSIG_ENVELOPE_AFT_INS" ],
|
"Envelope": [ "TBSIG_ENVELOPE_AFT_INS" ],
|
||||||
"History": [ "TBSIG_ENVELOPE_HISTORY_AFT_INS" ],
|
"History": [ "TBSIG_ENVELOPE_HISTORY_AFT_INS" ],
|
||||||
"DocReceiverElement": [ "TBSIG_DOCUMENT_RECEIVER_ELEMENT_AFT_INS", "TBSIG_DOCUMENT_RECEIVER_ELEMENT_AFT_UPD" ],
|
|
||||||
"EmailOut": [ "TBEMLP_EMAIL_OUT_AFT_INS", "TBEMLP_EMAIL_OUT_AFT_UPD" ],
|
"EmailOut": [ "TBEMLP_EMAIL_OUT_AFT_INS", "TBEMLP_EMAIL_OUT_AFT_UPD" ],
|
||||||
"EnvelopeReceiverReadOnly": [ "TBSIG_ENVELOPE_RECEIVER_READ_ONLY_UPD" ],
|
"EnvelopeReceiverReadOnly": [ "TBSIG_ENVELOPE_RECEIVER_READ_ONLY_UPD" ],
|
||||||
"Receiver": [],
|
"Receiver": [],
|
||||||
"EmailTemplate": [ "TBSIG_EMAIL_TEMPLATE_AFT_UPD" ]
|
"EmailTemplate": [ "TBSIG_EMAIL_TEMPLATE_AFT_UPD" ]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# EnvelopeGenerator.Form — VB.NET Desktop Application Context
|
# EnvelopeGenerator.Form — VB.NET Desktop Application Context
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
**Legacy Windows Forms application** for envelope creation, management, and signature field placement. Built with **DevExpress components** and **GdPicture14** for PDF manipulation. This application is being **migrated to `EnvelopeGenerator.Server` architecture** (merged Blazor + Web API host).
|
**Legacy Windows Forms application** for envelope creation, management, and signature field placement. Built with **DevExpress components** and **GdPicture14** for PDF manipulation. This application is being **migrated to EnvelopeGenerator.Server + API** architecture.
|
||||||
|
|
||||||
**Primary Libraries:** DevExpress XtraGrid/XtraEditors, GdPicture14, VB.NET (.NET Framework 4.6.2)
|
**Primary Libraries:** DevExpress XtraGrid/XtraEditors, GdPicture14, VB.NET (.NET Framework 4.6.2)
|
||||||
|
|
||||||
@@ -368,7 +368,7 @@ End Sub
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Migration to EnvelopeGenerator.Server
|
## Migration to EnvelopeGenerator.Server + API
|
||||||
|
|
||||||
### Mapping Table
|
### Mapping Table
|
||||||
|
|
||||||
|
|||||||
@@ -1,281 +0,0 @@
|
|||||||
# Sender Save/Edit Migration Plan (Legacy Form -> Blazor Server)
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
Migrate the legacy `frmEnvelopeEditor` save/edit workflow into current architecture so that:
|
|
||||||
|
|
||||||
1. Sender can **save incomplete envelope as draft** (`EnvelopeSaved = 1002`) from `EnvelopeSenderEditorPage.razor`.
|
|
||||||
2. Sender can **resume editing** saved/created envelopes from `EnvelopeSenderPage.razor` via **Bearbeiten** and continue where they left off.
|
|
||||||
|
|
||||||
This plan introduces a distinct draft flow and a same-ID send-from-draft flow, aligned with legacy Form app semantics.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
### In scope
|
|
||||||
- Add separate **Save** and **Send** actions in sender editor.
|
|
||||||
- Persist draft data server-side with partial/incomplete payload support.
|
|
||||||
- Re-open existing draft from dashboard and restore editor state.
|
|
||||||
- Preserve existing delete/withdraw logic and status model.
|
|
||||||
- Add endpoints and application commands required for progressive sender workflow.
|
|
||||||
|
|
||||||
### Out of scope (this ticket)
|
|
||||||
- Full redesign of editor UX.
|
|
||||||
- Multi-document support parity with `frmOrderFiles`.
|
|
||||||
- New DB schema changes (unless separately approved).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Current Gap Analysis
|
|
||||||
|
|
||||||
## Current behavior (problem)
|
|
||||||
- `EnvelopeSenderEditorPage.razor` has only one save path (`SaveAsync`) that behaves like "send":
|
|
||||||
- requires title + PDF + receivers + fields.
|
|
||||||
- calls `EnvelopeReceiverService.CreateAsync(...)` -> `/api/EnvelopeReceiver` monolithic create/send pipeline.
|
|
||||||
- `EnvelopeSenderPage.razor` `EditEnvelope()` is TODO and does not navigate to editable persisted draft data.
|
|
||||||
|
|
||||||
## Legacy behavior (target)
|
|
||||||
- `frmEnvelopeEditor` has both:
|
|
||||||
- **Save**: persists envelope without full validation/sending.
|
|
||||||
- **Send**: validates required business data then sends invitations.
|
|
||||||
- Dashboard "Bearbeiten" opens selected unsent envelope and restores work state.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architectural Decisions
|
|
||||||
|
|
||||||
1. **Split command model by intent**
|
|
||||||
- Draft persistence must not reuse monolithic `/api/EnvelopeReceiver` create+send endpoint.
|
|
||||||
- Introduce dedicated sender workflow commands/endpoints for draft lifecycle.
|
|
||||||
|
|
||||||
2. **Progressive persistence**
|
|
||||||
- Persist envelope metadata, document, receivers, and fields incrementally.
|
|
||||||
- Draft save accepts partial data and updates only provided segments.
|
|
||||||
|
|
||||||
3. **Status ownership**
|
|
||||||
- Draft operations set status to `EnvelopeSaved` (1002).
|
|
||||||
- Send operation transitions to queued/sent path (existing 1003+ behavior).
|
|
||||||
|
|
||||||
4. **Editor route strategy**
|
|
||||||
- Keep `/sender/editor` for new envelope.
|
|
||||||
- Support edit mode via query: `/sender/editor?envelopeId={id}` (or route variant in follow-up if preferred).
|
|
||||||
|
|
||||||
5. **No DB schema migration by default**
|
|
||||||
- Use existing tables (`TBSIG_ENVELOPE`, `TBSIG_DOCUMENT`, `TBSIG_ENVELOPE_RECEIVER`, `TBSIG_DOC_RECEIVER_ELEMENT`).
|
|
||||||
- If a field is missing for a requirement, handle in application logic first and escalate only if unavoidable.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Target Workflow
|
|
||||||
|
|
||||||
## A) New envelope (draft first)
|
|
||||||
1. User opens `/sender/editor`.
|
|
||||||
2. User enters any subset of data.
|
|
||||||
3. User clicks **Save**.
|
|
||||||
4. System creates or updates draft envelope and sets status `EnvelopeSaved`.
|
|
||||||
5. User gets confirmation and remains in editor (or optional return to dashboard).
|
|
||||||
|
|
||||||
## B) New envelope (direct send)
|
|
||||||
1. User fills required data.
|
|
||||||
2. User clicks **Send**.
|
|
||||||
3. System validates complete data and sends invitations.
|
|
||||||
4. Status transitions to queued/sent flow.
|
|
||||||
|
|
||||||
## C) Edit existing draft/created
|
|
||||||
1. User selects row in `/sender` with `EnvelopeCreated` or `EnvelopeSaved`.
|
|
||||||
2. User clicks **Bearbeiten**.
|
|
||||||
3. App navigates to `/sender/editor?envelopeId={id}`.
|
|
||||||
4. Editor loads persisted data (metadata, PDF, receivers, fields).
|
|
||||||
5. User can save again (draft) or send.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Detailed Implementation Plan
|
|
||||||
|
|
||||||
## Phase 1 - Contracts and Application Use Cases
|
|
||||||
|
|
||||||
### 1.1 Add sender draft commands (Application)
|
|
||||||
- `CreateEnvelopeCommand` (create/update draft with partial data)
|
|
||||||
- `ReadEnvelopeDraftQuery` (load full editable projection by envelope id)
|
|
||||||
- `CreateEnvelopeCommand` (same request, `Send=true` for final send)
|
|
||||||
|
|
||||||
Each command should enforce sender ownership (`UserId`) and return structured result DTOs.
|
|
||||||
|
|
||||||
### 1.2 Validation policy split
|
|
||||||
- **Draft Save validation (minimal):**
|
|
||||||
- title optional at first save? choose one policy and keep consistent.
|
|
||||||
- no requirement for receivers/fields/document.
|
|
||||||
- **Send validation (strict):**
|
|
||||||
- title required
|
|
||||||
- document required
|
|
||||||
- at least one receiver
|
|
||||||
- required signature field mapping per receiver (business rule)
|
|
||||||
|
|
||||||
### 1.3 History policy
|
|
||||||
- On draft save/update: optional lightweight history entry (`EnvelopeSaved`) if legacy parity requires it.
|
|
||||||
- On send: keep current history/send pipeline behavior.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 2 - Server Endpoints
|
|
||||||
|
|
||||||
Implement in `EnvelopeGenerator.Server` only.
|
|
||||||
|
|
||||||
### 2.1 Draft endpoints
|
|
||||||
- `POST /api/Envelope`
|
|
||||||
- Create/update draft envelope (partial payload allowed), and send when `Send=true`.
|
|
||||||
- `GET /api/Envelope/{id}/draft`
|
|
||||||
- Read full editable aggregate for editor hydration.
|
|
||||||
|
|
||||||
### 2.2 Authorization + errors
|
|
||||||
- 400 validation issues
|
|
||||||
- 403 sender mismatch
|
|
||||||
- 404 envelope not found
|
|
||||||
- 409 invalid state transition (optional, if needed)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 3 - Sender Editor UI (`EnvelopeSenderEditorPage.razor`)
|
|
||||||
|
|
||||||
### 3.1 Action buttons
|
|
||||||
- Keep current action as **Send** (rename from "Speichern" to "Senden").
|
|
||||||
- Add separate **Save** button next to Send.
|
|
||||||
|
|
||||||
### 3.2 Editor mode state
|
|
||||||
- Add `_editingEnvelopeId` from query param `envelopeId`.
|
|
||||||
- `null` => create mode, value => edit mode.
|
|
||||||
|
|
||||||
### 3.3 Load existing draft
|
|
||||||
- On parameter set / first render in edit mode:
|
|
||||||
- call draft read endpoint.
|
|
||||||
- hydrate title, message, PDF bytes, receiver list, field list.
|
|
||||||
- map DB inches <-> PDF points correctly:
|
|
||||||
- DB inches -> UI points: `pt = inches * 72`
|
|
||||||
- UI points -> DB inches: `inches = pt / 72`
|
|
||||||
|
|
||||||
### 3.4 Save behavior (new)
|
|
||||||
- Save button calls `SaveDraftAsync()`:
|
|
||||||
- no strict send validations.
|
|
||||||
- create draft if no id yet, otherwise update.
|
|
||||||
- force status `EnvelopeSaved`.
|
|
||||||
- keep user in editor and show non-blocking success feedback.
|
|
||||||
|
|
||||||
### 3.5 Send behavior (existing path replacement)
|
|
||||||
- Send button calls `SendAsync()`:
|
|
||||||
- strict validation.
|
|
||||||
- if no draft id: create draft first then send, or direct send command.
|
|
||||||
- if edit mode: send current draft id.
|
|
||||||
|
|
||||||
### 3.6 Session cache coexistence
|
|
||||||
- Keep `esid` memory cache for transient UX resilience.
|
|
||||||
- In edit mode, server data is source of truth; cache is fallback only.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 4 - Dashboard Edit Resume (`EnvelopeSenderPage.razor`)
|
|
||||||
|
|
||||||
### 4.1 Edit button enablement
|
|
||||||
- Enable **Bearbeiten** for statuses:
|
|
||||||
- `EnvelopeCreated`
|
|
||||||
- `EnvelopeSaved`
|
|
||||||
- Keep disabled for sent/queued/completed/deleted/withdrawn/rejected.
|
|
||||||
|
|
||||||
### 4.2 Navigation
|
|
||||||
- Implement `EditEnvelope()`:
|
|
||||||
- `Navigation.NavigateTo($"/sender/editor?envelopeId={_selectedEnvelope.Id}")`.
|
|
||||||
|
|
||||||
### 4.3 Optional UX
|
|
||||||
- Double-click on editable rows can open editor instead of preview (or keep current preview behavior per product decision).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 5 - Data Mapping Rules
|
|
||||||
|
|
||||||
1. **Document**
|
|
||||||
- Persist original PDF bytes (no visual placeholder burn-in).
|
|
||||||
2. **Receivers**
|
|
||||||
- Draft can contain receivers with/without fields.
|
|
||||||
3. **Fields**
|
|
||||||
- Store in DB inches with fixed signature size semantics.
|
|
||||||
4. **Ownership**
|
|
||||||
- Sender can read/update/send only own envelopes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Suggested DTO Shape (Draft)
|
|
||||||
|
|
||||||
Use one aggregate DTO for editor hydration/save/update:
|
|
||||||
|
|
||||||
- `EnvelopeDraftDto`
|
|
||||||
- `EnvelopeId`
|
|
||||||
- `Status`
|
|
||||||
- `Title`
|
|
||||||
- `Message`
|
|
||||||
- `DocumentBase64` (or document id + fetch endpoint)
|
|
||||||
- `Receivers[]`
|
|
||||||
- `ReceiverId?`
|
|
||||||
- `Name`
|
|
||||||
- `Email`
|
|
||||||
- `Phone`
|
|
||||||
- `Fields[] { XInches, YInches, Page }`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Migration Strategy
|
|
||||||
|
|
||||||
## Step A (safe vertical slice)
|
|
||||||
- Add read/edit route query handling and dashboard navigation first.
|
|
||||||
- Hydrate editor from existing envelope data where possible.
|
|
||||||
|
|
||||||
## Step B (draft save)
|
|
||||||
- Implement draft create/update endpoints + app commands.
|
|
||||||
- Wire Save button.
|
|
||||||
|
|
||||||
## Step C (send from draft)
|
|
||||||
- Move Send to explicit command/endpoint.
|
|
||||||
- Ensure existing email/history behavior remains intact.
|
|
||||||
|
|
||||||
## Step D (hardening)
|
|
||||||
- Add tests + manual QA checklist.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing Plan
|
|
||||||
|
|
||||||
## Automated (Application)
|
|
||||||
- Save draft with partial data -> status `EnvelopeSaved`.
|
|
||||||
- Edit draft updates existing records, no duplicate orphan data.
|
|
||||||
- Send draft with missing required data -> validation error.
|
|
||||||
- Send draft complete -> queued/sent flow success.
|
|
||||||
- Ownership checks -> 403 behavior.
|
|
||||||
|
|
||||||
## Manual (UI)
|
|
||||||
1. New editor -> save with only title.
|
|
||||||
2. Reopen from dashboard -> state restored.
|
|
||||||
3. Add PDF only -> save -> reopen -> PDF still present.
|
|
||||||
4. Add receivers/fields incrementally -> save repeatedly.
|
|
||||||
5. Final send -> status transitions and receiver notifications.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Risks and Mitigations
|
|
||||||
|
|
||||||
1. **Monolithic endpoint coupling risk**
|
|
||||||
- Mitigation: draft/send dedicated endpoints.
|
|
||||||
2. **Data duplication on repeated save**
|
|
||||||
- Mitigation: idempotent upsert logic for receivers/fields.
|
|
||||||
3. **Coordinate regression**
|
|
||||||
- Mitigation: centralize inches/points conversion helpers.
|
|
||||||
4. **Trigger side effects/history duplication**
|
|
||||||
- Mitigation: verify DB-trigger interactions per status events.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Definition of Done
|
|
||||||
|
|
||||||
- Editor has two distinct actions: **Save** and **Send**.
|
|
||||||
- Save allows incomplete payload and persists with `EnvelopeSaved` status.
|
|
||||||
- Dashboard **Bearbeiten** opens editor for `EnvelopeCreated`/`EnvelopeSaved` and restores state.
|
|
||||||
- Sender can continue from last saved point and eventually send.
|
|
||||||
- Server endpoints support save/edit/send draft workflow with same envelope id.
|
|
||||||
- No DB schema changes introduced without explicit approval.
|
|
||||||
Reference in New Issue
Block a user