Compare commits

..

32 Commits

Author SHA1 Message Date
0cf603fc79 Swap steps 1 and 3 in CreateEnvelopeWizardPopup
Reorganize the wizard steps to prioritize "Receivers" (step 1)
before "Message and Options" (step 3). Update `_currentStep`
logic, conditional checks, and validation to reflect the new
step order. Adjust `GetStepTitle` and `GetStepDescription`
methods to align with the updated step titles and descriptions.
Ensure user input handling and step-skipping behavior are
consistent with the new flow.
2026-11-05 16:18:17 +01:00
e3d2225a44 Refactor and enhance envelope wizard with MudBlazor
Replaced custom wizard UI with MudBlazor's MudStepper for a modern and consistent design. Added dynamic step titles, descriptions, and forms for envelope creation, including advanced settings like certification, reminders, and email notifications. Integrated PDF upload with preview functionality.

Refactored `CreateEnvelopeWizardPopup.razor` for better maintainability and modularity. Removed redundant code and centralized step-related logic. Improved localization, accessibility, and user experience.

Updated `EnvelopeGenerator.Server.Client.csproj` and `EnvelopeGenerator.Server.csproj` to include MudBlazor. Registered MudBlazor services in `Program.cs` and added required CSS/JS in `App.razor`. Updated styles in `sender-page.css` to align with the new stepper design.
2026-11-05 15:22:21 +01:00
595bbf3f25 chore(db): back up email templates before redesign
Store the original template subjects and bodies as restore scripts so the redesigned notifications can be reverted against DD_ECM if needed.
2026-11-05 12:03:44 +01:00
ee7d01ed7f test(notification): add email notification preview endpoints
Expose signed and received notification endpoints, with reset random payload variants, to exercise rendering and delivery without running a full envelope workflow.

Fall back to a fixed recipient when no email is supplied.
2026-11-05 12:03:40 +01:00
85104511fd feat(notification): distinguish read confirmation from signature
Add IsReadAndConfirm to DocumentSignedNotification and derive the action, status, and final action labels from it so read-only envelopes do not report a signature.

Pass the envelope read-and-confirm mode when finalizing so the signed email matches the receiver workflow.
2026-11-05 12:03:35 +01:00
1b87528cca refactor(notification): publish emails through messaging client
Send notifications via IMessagingServiceClient and PublishEmailViaSmtpCommand instead of the static EmailSender. The sender address now comes from NotificationOptions and attachments are added through the command builder.

Rename the Notify parameter to receiverEmail so the call site contract is explicit.
2026-11-05 12:03:31 +01:00
2913514da8 feat(notification): make messaging consumer configurable
Replace the hard-coded RabbitMQ setup with NotificationOptions bound from a configurable section. The new ConsumerType switch wires either the HTTP API client or the RabbitMQ client, and startup now fails with a clear error when required settings are missing.

Bump DigitalData.MessagingService.Client to 3.0.1 for the abstraction used by both transports.
2026-11-05 12:03:26 +01:00
ce7e70162b Remove ReplacePlaceHolders method
The private static method `ReplacePlaceHolders` was removed from `CreateEnvelopeCommandHandler.cs`. This method was used to replace placeholders in a string with values from a dictionary. Its removal indicates that this functionality is either no longer needed or has been refactored elsewhere in the codebase.
2026-10-29 15:57:29 +01:00
5e00b6e9ec Refactor notification and email handling
Refactored `DocumentReceivedNotification` to use new properties `Origin`, `EnvelopeUuid`, and `ReceiverSignature` for constructing `EnvelopeURL`. Updated `LinkToDocument` and `LinkToDocumentText` to derive from `EnvelopeURL`.

Simplified `CreateEnvelopeCommandHandler` with record-like syntax, removing private fields and directly injecting dependencies.

Renamed and refactored `QueueInvitationEmailsAsync` to `SendInvitationEmailsAsync`, using `EnvNtfService.Notify` for email notifications, centralizing logic and improving clarity.

Improved error handling in email sending with logging. Updated variable names for clarity and cleaned up `using` directives.
2026-10-29 15:54:07 +01:00
f0b4c8d00c Refactor ContractType property to use enum
Updated the `ContractType` property in the `EnvelopeTypeDto` class from a nullable integer (`int?`) to a nullable `ContractType` enum (`ContractType?`). This change enhances type safety and clarifies the expected values for the property.
2026-10-29 15:52:38 +01:00
abf616c115 Add IRequestContext interface and RequestContext class
Introduce the IRequestContext interface with an Origin property
in the EnvelopeGenerator.Application.Common.Interfaces namespace.
Implement this interface in the new RequestContext class within
EnvelopeGenerator.Server.Services, using IHttpContextAccessor to
access the Origin header. Register IRequestContext with the DI
container in Program.cs using AddScoped. Update using directives
to include necessary namespaces and reorganize for clarity.
2026-10-29 15:51:58 +01:00
42585e1f2c Make User property conditionally nullable
Modified the User property in Envelope.cs to be conditionally nullable using the nullable preprocessor directive. This change allows the property to be nullable when the nullable directive is defined, enhancing flexibility and compatibility with different C# versions and project configurations.
2026-10-29 15:51:19 +01:00
ff10fc6b0a Refactor ContractType property in EnvelopeType.cs
Added a using directive for `EnvelopeGenerator.Domain.Constants` to utilize constants from that namespace. Changed the `ContractType` property from a nullable integer (`int?`) to a nullable `ContractType` enum or class, enhancing type safety and clarity.
2026-10-29 13:35:48 +01:00
7222f7e9a7 Refactor ContractType to use enum
Changed the `ContractType` property in `EnvelopeDto`, `CreateEnvelopeCommandHandler`, and `Envelope` classes from a nullable `int` to a nullable `ContractType` enum. This improves type safety and consistency across the codebase by using a specific enum type instead of a generic integer. Updated logic in `CreateEnvelopeCommandHandler` to remove unnecessary casting.
2026-10-29 13:11:44 +01:00
d913b949e0 Add DTOs and tests for placeholder replacement
Added new record types `CreateEnvelopeDocumentDto`, `CreateEnvelopeFieldDto`, and `CreateEnvelopeReceiverDto` with XML documentation in `CreateEnvelopeCommand.cs`. Marked `ConfigService` as obsolete in `CreateEnvelopeCommandHandler.cs`. Modified `CertificationType` usage in `Fake.cs` to use the enum directly. Updated `EnvelopeGenerator.Tests.csproj` with a new project reference and compile directives. Introduced `MailTemplatePlaceholderDbTests` and `ReplacePlaceholderExtensionsTests` to verify placeholder replacement functionality. Added supporting model classes for testing. These changes improve code functionality and test coverage.
2026-10-29 09:53:52 +01:00
a35e05d016 Refactor Attachments property and syntax
Changed the `Attachments` property type in `EnvelopeNotificationBase.cs` from `List` to `IReadOnlyList` to enhance immutability and thread safety. Updated the `FinalizeEnvelopeCommandHandler` in `FinalizeEnvelopeCommand.cs` to use a single-line syntax for `Attachments` initialization, improving code readability.
2026-10-28 16:28:17 +01:00
8aabc3e233 Refactor notification system
Introduced `EnvelopeNotificationBase` as a new base class for notifications, providing a unified structure with an abstract `GetTemplateType` method and optional attachments. Added `DocumentReceivedNotification` and updated `DocumentSignedNotification` to inherit from this base class, streamlining attachment handling and template type determination.

Refactored `IEnvelopeNotificationService` to use a generic `Notify` method, allowing for flexibility with different notification types. Updated `FinalizeEnvelopeCommandHandler` to align with the new attachment handling.

Added `EnvelopeNotificationExtensions` for converting attachments to `EmailAttachmentDto` objects and introduced `ReplacePlaceholderExtensions` for dynamic placeholder replacement in email templates using reflection. Removed outdated placeholder logic from `EnvelopeNotificationService`.
2026-10-28 16:25:19 +01:00
8b7f99eb76 Prevent email on deleted envelopes; bump version
Added a check in SendMailHandler to prevent sending emails for deleted envelopes with a status of EnvelopeSaved or lower. Updated project version in EnvelopeGenerator.Server.csproj from 1.7.0 to 1.8.0 to reflect these changes.
2026-10-28 13:15:20 +01:00
91e90b322b Add caching to PdfRenderController
Implemented caching in the PdfRenderController using IMemoryCache to improve performance by avoiding redundant PDF rendering. Added a new constructor parameter for IMemoryCache and a static TimeSpan field for cache duration. Introduced a private method, BuildCacheKey, to generate unique cache keys based on the SHA256 hash of PDF bytes. Updated the RenderPages method to check for cached pages before rendering and store results in the cache.
2026-10-27 21:57:35 +01:00
74e55e9206 Refactor and optimize envelope handling
Refactored `ReadDocumentDetailsQueryHandler` to remove AutoMapper, improving performance by manually selecting necessary data. Simplified `SenderEditorPage.razor` by removing bootstrap logic and updating the UI for a more compact design. Streamlined envelope creation in `SenderPage.razor` by eliminating the bootstrap token. Updated styles in `envelope-viewer.css` for better alignment and readability.
2026-10-27 21:56:39 +01:00
fc5ad7ea3c Add receiver removal functionality to SenderEditor
Enhanced the `SenderEditorPage.razor` by injecting the `EnvelopeReceiverService` to manage envelope receivers. Updated the UI to include a remove button for each receiver, invoking the new `RemoveReceiverAsync` method. This method handles the deletion of receivers from both the UI and the server.

Modified `ReceiverDraft` and `SignatureFieldDraft` models to include `ReceiverId` and `ReceiverDraftId` for better tracking. Updated logic for identifying receivers without signature fields and creating receiver commands to use these new identifiers.

Added `DeleteAsync` method in `EnvelopeReceiverService.cs` to send DELETE requests for removing receivers. Updated `envelope-viewer.css` to style the new remove button with distinct appearance and hover effects.
2026-10-27 19:02:22 +01:00
ff5582c9b5 Add RemoveEnvelopeReceiverCommand and refactor controller
Introduced `RemoveEnvelopeReceiverCommand` and its handler to
facilitate the removal of envelope receivers. Updated the
`EnvelopeReceiverController` to use C# 9's record-like syntax
for dependency injection, eliminating explicit private fields.
Added a `RemoveAsync` method to handle DELETE requests for
removing envelope receivers. Enhanced code documentation with
additional XML comments.
2026-10-27 18:32:47 +01:00
6523cca945 Refactor certification type option values
Updated the `value` attributes of `<option>` elements in `CreateEnvelopeWizardPopup.razor` and `SenderEditorPage.razor` to use `@CertificationType.AdvancedElectronicSignature` and `@CertificationType.ElectronicSignature` instead of hardcoded values "1" and "2". This enhances code maintainability and readability by leveraging descriptive and potentially type-safe values.
2026-10-27 16:11:55 +01:00
28d0b6f81c Add feature to open receiver envelope URLs
Introduce a new feature allowing users to open URLs for receiver envelopes directly from the sender page. This includes:

- Injecting `EnvelopeReceiverService` in `SenderPage.razor`.
- Adding a UI button to open receiver envelope URLs.
- Implementing `OpenReceiverEnvelopeUrlAsync` for URL handling.
- Extending `EnvelopeReceiverService` with methods for URL retrieval and feature check.
- Updating `EnvelopeReceiverController` with new endpoints for URL access.
- Introducing `SenderFeatureAccessOptions` for managing feature access.
- Configuring feature access in `Program.cs` and `appsettings.json`.
- Restricting feature usage to specified email addresses.
2026-10-27 11:37:25 +01:00
5df38754cb Add configurable SignatureFieldParams class
Introduce `SignatureFieldParams` class to manage signature field dimensions with configurable `Width` and `Height`. Integrate this configuration into the service collection and replace hardcoded dimensions in `CreateEnvelopeCommandHandler.cs` and `SenderEditorPage.razor`. Update `appsettings.json` to include the new configuration section, allowing runtime adjustments.
2026-10-27 09:18:11 +01:00
c2b7579717 Refactor CertificationType to use enum
Changed CertificationType from int? to CertificationType? enum across multiple files, including EnvelopeDto, EnvelopeTypeDto, and CreateEnvelopeCommand, to improve type safety. Removed the NormalizeCertificationType method as it is no longer necessary. Updated components and records to use the enum directly, enhancing code clarity and reducing potential errors. Added necessary using directives for the enum.
2026-10-26 16:59:49 +01:00
ae1ecbc15f Normalize certification type handling
Introduce `NormalizeCertificationType` method to ensure consistent handling of certification types across the application. Update `CreateEnvelopeCommandHandler`, `CreateEnvelopeWizardPopup.razor`, and `SenderEditorPage.razor` to use this method. Modify `CertificationType` enum to include `ElectronicSignature` and remove `QualifiedSignature`. Update UI components to reflect these changes by removing the `QualifiedSignature` option.
2026-10-26 16:58:06 +01:00
4dcfe67038 Increase grid columns in sender wizard progress
Updated the CSS grid layout for the `.sender-create-wizard__progress` class from 4 to 5 columns. This change allows for an additional column in the progress indicator, potentially accommodating more items or steps in the sender creation wizard.
2026-10-26 15:55:57 +01:00
c812d4dc1f Refactor EnvelopeController for DI and clarity
Refactored the `EnvelopeController` to use C# 9's record syntax for dependency injection, directly injecting `ILogger<EnvelopeController>` and `IMediator` into the constructor. Removed private fields `_logger` and `_mediator`, replacing them with constructor-injected properties. Updated methods to use these properties for consistency. Moved `DeleteEnvelopeRequest` to a top-level class for better organization. Enhanced XML documentation for improved clarity.
2026-10-26 15:49:33 +01:00
8d27718062 Add IncludeDocAndDocRes option to envelope queries
Introduced a new boolean property `IncludeDocAndDocRes` in the `ReadEnvelopeQuery` class to control the inclusion of document and document result details in query results. Updated `ReadEnvelopeQueryHandler` to handle this property, returning a simplified `EnvelopeDto` when set to `false`.

Modified `SenderPage.razor` to exclude document details by passing `includeDocAndDocRes: false` to `EnvelopeService.GetAsync()`. Enhanced `EnvelopeService` to accept and process the `includeDocAndDocRes` parameter, ensuring API requests respect this preference.
2026-10-26 15:24:18 +01:00
c746612d80 Merge branch 'feat/server-hsm-integr' 2026-10-26 14:45:59 +01:00
6dfcc1b2be Update property accessor and resource management
Modified the `ByteData` property in `DocumentDto.cs` to use a `set` accessor instead of `init`, enabling updates after initialization.

Updated resource manager references in `frmEnvelopeMainData.vb` to use `CommonServices.My.Resources.Model.ResourceManager` for localized string retrieval.
2026-10-08 11:34:05 +02:00
64 changed files with 2433 additions and 913 deletions

View File

@@ -0,0 +1,17 @@
namespace EnvelopeGenerator.Application.Common.Configurations;
/// <summary>
///
/// </summary>
public class SignatureFieldParams
{
/// <summary>
///
/// </summary>
public double Width { get; init; } = 1.77;
/// <summary>
///
/// </summary>
public double Height { get; init; } = 1.96;
}

View File

@@ -23,5 +23,5 @@ public record DocumentDto : DocumentDetailsDto
/// <summary>
/// Gets or sets the binary data of the document, if available.
/// </summary>
public byte[]? ByteData { get; init; }
public byte[]? ByteData { get; set; }
}

View File

@@ -62,7 +62,7 @@ public record EnvelopeDto : IEnvelope
/// <summary>
///
/// </summary>
public int? ContractType { get; set; }
public ContractType? ContractType { get; set; }
/// <summary>
/// Default value is 'de-DE'
@@ -84,7 +84,7 @@ public record EnvelopeDto : IEnvelope
/// <summary>
///
/// </summary>
public int? CertificationType { get; set; }
public CertificationType? CertificationType { get; set; }
/// <summary>
///

View File

@@ -1,4 +1,6 @@
namespace EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Domain.Constants;
namespace EnvelopeGenerator.Application.Common.Dto;
/// <summary>
/// Data Transfer Object representing a type of envelope with its configuration settings.
@@ -28,7 +30,7 @@ public class EnvelopeTypeDto
/// <summary>
/// Gets or sets the certification type identifier.
/// </summary>
public int? CertificationType { get; set; }
public CertificationType? CertificationType { get; set; }
/// <summary>
/// Gets or sets a value indicating whether an access code is required.
@@ -78,5 +80,5 @@ public class EnvelopeTypeDto
/// <summary>
/// Gets or sets the contract type associated with the envelope type.
/// </summary>
public int? ContractType { get; set; }
public ContractType? ContractType { get; set; }
}

View File

@@ -0,0 +1,12 @@
namespace EnvelopeGenerator.Application.Common.Interfaces;
/// <summary>
///
/// </summary>
public interface IRequestContext
{
/// <summary>
///
/// </summary>
string? Origin { get; }
}

View File

@@ -0,0 +1,98 @@
using EnvelopeGenerator.Application.Common.Extensions;
using EnvelopeGenerator.Domain.Constants;
namespace EnvelopeGenerator.Application.Common.Interfaces.Services.Notification;
/// <summary>
///
/// </summary>
public record DocumentReceivedNotification : EnvelopeNotificationBase
{
/// <summary>
///
/// </summary>
public required string DocumentTitle { get; init; }
/// <summary>
///
/// </summary>
public required string NameReceiver { get; init; }
/// <summary>
///
/// </summary>
public required string NameSender { get; init; }
/// <summary>
/// [SIGNATURE_TYPE]
/// </summary>
public required bool IsReadAndConfirm { private get; init; }
/// <summary>
///
/// </summary>
public string SignatureType => IsReadAndConfirm ? "Signieren" : "Lesen und bestätigen";
/// <summary>
///
/// </summary>
public required bool AdvancedSignature { private get; init; }
/// <summary>
///
/// </summary>
public string DocumentProcess => AdvancedSignature ? " und elektronisch unterschreiben" : string.Empty;
/// <summary>
///
/// </summary>
public required Uri Origin { private get; init; }
/// <summary>
///
/// </summary>
public required string EnvelopeUuid { private get; init; }
/// <summary>
///
/// </summary>
public required string ReceiverSignature { private get; init; }
private Uri EnvelopeURL => new(Origin, $"Envelope/{(EnvelopeUuid, ReceiverSignature).ToEnvelopeKey()}");
/// <summary>
///
/// </summary>
public string LinkToDocument => EnvelopeURL.AbsoluteUri;
/// <summary>
///
/// </summary>
public string LinkToDocumentText
{
get
{
var urlText = EnvelopeURL.ToString();
return $"{urlText[..Math.Min(urlText.Length, 40)]}..";
}
}
/// <summary>
///
/// </summary>
public required string Message { get; init; }
/// <summary>
///
/// </summary>
public string NamePortal { get; set; } = "signFLOW Portal";
/// <summary>
///
/// </summary>
/// <returns></returns>
public override EmailTemplateType GetTemplateType()
{
return EmailTemplateType.DocumentReceived;
}
}

View File

@@ -1,9 +1,11 @@
namespace EnvelopeGenerator.Application.Common.Interfaces.Services.Notification;
using EnvelopeGenerator.Domain.Constants;
namespace EnvelopeGenerator.Application.Common.Interfaces.Services.Notification;
/// <summary>
///
/// </summary>
public record DocumentSignedNotification
public record DocumentSignedNotification : EnvelopeNotificationBase
{
/// <summary>
///
@@ -23,7 +25,27 @@ public record DocumentSignedNotification
/// <summary>
///
/// </summary>
public required byte[] Attachment { get; init; }
public required bool IsReadAndConfirm { private get; init; }
/// <summary>
///
/// </summary>
public string ReceiverAction => IsReadAndConfirm ? "bestätigt" : "unterschrieben";
/// <summary>
///
/// </summary>
public string FinalStatus => IsReadAndConfirm ? "Lesebestätigung" : "Signatur";
/// <summary>
///
/// </summary>
public string FinalAction => IsReadAndConfirm ? "Empfänger bestätigt" : "Vertragspartner unterzeichnet";
/// <summary>
///
/// </summary>
public override EmailTemplateType GetTemplateType() => EmailTemplateType.DocumentSigned;
}
/*

View File

@@ -0,0 +1,19 @@
using EnvelopeGenerator.Domain.Constants;
namespace EnvelopeGenerator.Application.Common.Interfaces.Services.Notification;
/// <summary>
///
/// </summary>
public abstract record EnvelopeNotificationBase
{
/// <summary>
///
/// </summary>
public abstract EmailTemplateType GetTemplateType();
/// <summary>
///
/// </summary>
public IReadOnlyList<(string FileName, byte[] Content)>? Attachments { get; init; }
}

View File

@@ -8,9 +8,11 @@ public interface IEnvelopeNotificationService
/// <summary>
///
/// </summary>
/// <param name="email"></param>
/// <param name="documentSigned"></param>
/// <typeparam name="TEnvelopeNotificationBase"></typeparam>
/// <param name="receiverEmail"></param>
/// <param name="notification"></param>
/// <param name="cancel"></param>
/// <returns></returns>
public Task Notify(string email, DocumentSignedNotification documentSigned, CancellationToken cancel = default);
public Task Notify<TEnvelopeNotificationBase>(string receiverEmail, TEnvelopeNotificationBase notification, CancellationToken cancel = default)
where TEnvelopeNotificationBase : EnvelopeNotificationBase;
}

View File

@@ -2,6 +2,7 @@
using DigitalData.EmailProfilerDispatcher.Abstraction.Entities;
using EnvelopeGenerator.Application.Common.Configurations;
using EnvelopeGenerator.Application.Common.Extensions;
using EnvelopeGenerator.Application.Common.Notifications.EnvelopeDeleted;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Domain.Entities;
using MediatR;
@@ -96,6 +97,10 @@ public abstract class SendMailHandler<TNotification> : INotificationHandler<TNot
/// <exception cref="NotImplementedException"></exception>
public virtual async Task Handle(TNotification notification, CancellationToken cancel)
{
// do not send email if the envelope is deleted and the status is less than or equal to EnvelopeSaved
if (notification is EnvelopeDeletedNotification delNot && delNot.Envelope.Status <= EnvelopeStatus.EnvelopeSaved)
return;
var placeHolders = CreatePlaceHolders(notification);
var temp = await TempRepo

View File

@@ -49,6 +49,7 @@ public static class DependencyInjection
services.Configure<DispatcherParams>(config.GetSection(nameof(DispatcherParams)));
services.Configure<MailParams>(config.GetSection(nameof(MailParams)));
services.Configure<SignatureFieldParams>(config.GetSection(nameof(SignatureFieldParams)));
services.Configure<AuthenticatorParams>(config.GetSection(nameof(AuthenticatorParams)));
services.Configure<TotpSmsParams>(config.GetSection(nameof(TotpSmsParams)));
services.Configure<ESignParams>(config.GetSection(nameof(ESignParams)));

View File

@@ -1,5 +1,3 @@
using AutoMapper;
using AutoMapper.QueryableExtensions;
using DigitalData.Core.Abstraction.Application.Repository;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Domain.Entities;
@@ -21,12 +19,10 @@ public record ReadDocumentDetailsQuery(int EnvelopeId, int? UserId = null) : IRe
public class ReadDocumentDetailsQueryHandler : IRequestHandler<ReadDocumentDetailsQuery, DocumentDetailsDto?>
{
private readonly IRepository<Document> _repo;
private readonly IMapper _mapper;
public ReadDocumentDetailsQueryHandler(IRepository<Document> repo, IMapper mapper)
public ReadDocumentDetailsQueryHandler(IRepository<Document> repo)
{
_repo = repo;
_mapper = mapper;
}
public async Task<DocumentDetailsDto?> Handle(ReadDocumentDetailsQuery query, CancellationToken cancel)
@@ -36,9 +32,71 @@ public class ReadDocumentDetailsQueryHandler : IRequestHandler<ReadDocumentDetai
if (query.UserId is int userId)
docQuery = docQuery.Where(doc => doc.Envelope.UserId == userId);
return await docQuery
// shortcut: manual select avoids AutoMapper ProjectTo pulling BYTE_DATA and the full navigation
// graph (Envelope → History → User etc.) which caused an extremely slow query (~87s).
// Annotations navigation is IEnumerable<T> on the entity (not ICollection<T>), so EF Core
// cannot translate it in a Select projection — we skip it entirely here; sender editor only
// needs element coordinates, not annotation payloads.
var raw = await docQuery
.Where(d => d.EnvelopeId == query.EnvelopeId)
.ProjectTo<DocumentDetailsDto>(_mapper.ConfigurationProvider)
.Select(d => new
{
d.Id,
d.EnvelopeId,
d.AddedWhen,
Elements = d.Elements == null ? null : d.Elements.Select(e => new
{
e.Id,
e.DocumentId,
e.ReceiverId,
e.ElementType,
e.X,
e.Y,
e.Width,
e.Height,
e.Page,
e.Required,
e.Tooltip,
e.ReadOnly,
e.AnnotationIndex,
e.AddedWhen,
e.ChangedWhen,
e.FullName,
e.Position,
e.Place,
}).ToList()
})
.FirstOrDefaultAsync(cancel);
if (raw is null)
return null;
return new DocumentDetailsDto
{
Id = raw.Id,
EnvelopeId = raw.EnvelopeId,
AddedWhen = raw.AddedWhen,
Elements = raw.Elements?.Select(e => new DocReceiverElementDto
{
Id = e.Id,
DocumentId = e.DocumentId,
ReceiverId = e.ReceiverId,
ElementType = e.ElementType,
X = e.X,
Y = e.Y,
Width = e.Width,
Height = e.Height,
Page = e.Page,
Required = e.Required,
Tooltip = e.Tooltip,
ReadOnly = e.ReadOnly,
AnnotationIndex = e.AnnotationIndex,
AddedWhen = e.AddedWhen,
ChangedWhen = e.ChangedWhen,
FullName = e.FullName,
Position = e.Position,
Place = e.Place,
})
};
}
}

View File

@@ -0,0 +1,84 @@
using DigitalData.Core.Abstraction.Application.Repository;
using DigitalData.Core.Exceptions;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Domain.Entities;
using EnvelopeGenerator.Domain.Exceptions;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace EnvelopeGenerator.Application.EnvelopeReceivers.Commands;
/// <summary>
///
/// </summary>
public record RemoveEnvelopeReceiverCommand : IRequest
{
/// <summary>
///
/// </summary>
public required int EnvelopeId { get; init; }
/// <summary>
///
/// </summary>
public required int ReceiverId { get; init; }
internal int? UserId { get; private set; }
/// <summary>
///
/// </summary>
/// <param name="userId"></param>
/// <returns></returns>
public RemoveEnvelopeReceiverCommand Authenticated(int userId)
{
UserId = userId;
return this;
}
}
/// <summary>
///
/// </summary>
/// <param name="Repo"></param>
public class RemoveEnvelopeReceiverCommandHandler(
IRepository<EnvelopeReceiver> Repo,
IRepository<DocReceiverElement> ElementRepo,
IRepository<ElementAnnotation> AnnotRepo) : IRequestHandler<RemoveEnvelopeReceiverCommand>
{
/// <summary>
///
/// </summary>
/// <param name="request"></param>
/// <param name="cancel"></param>
/// <returns></returns>
/// <exception cref="NotImplementedException"></exception>
public async Task Handle(RemoveEnvelopeReceiverCommand request, CancellationToken cancel)
{
var er = await Repo.Query
.Where(er => er.EnvelopeId == request.EnvelopeId && er.ReceiverId == request.ReceiverId)
.Include(er => er.Envelope)
.SingleOrDefaultAsync(cancel)
?? throw new NotFoundException();
if (er.Envelope is null)
throw new InvalidOperationException($"In {nameof(RemoveEnvelopeReceiverCommandHandler)} EnvelopeReceiver.Envelope is null." +
$"This should not happen." +
$"EnvelopeId: {request.EnvelopeId}, ReceiverId: {request.ReceiverId}");
if (request.UserId is int userId && er.Envelope.UserId != userId)
throw new AuthorizationException("Cannot remove receiver from envelope that you do not own.");
if (er.Envelope.Status > EnvelopeStatus.EnvelopeSaved)
throw new ForbiddenException("Cannot remove receiver from envelope that is already processed.");
// 1. Delete all ElementAnnotations for this EnvelopeReceiver
await AnnotRepo.DeleteAsync(ann => ann.Element.Document.EnvelopeId == request.EnvelopeId && ann.Element.ReceiverId == request.ReceiverId, cancel);
// 2. Delete all DocReceiverElements for this EnvelopeReceiver
await ElementRepo.DeleteAsync(el => el.Document.EnvelopeId == request.EnvelopeId && el.ReceiverId == request.ReceiverId, cancel);
// 3. Delete the EnvelopeReceiver
await Repo.DeleteAsync(er => er.EnvelopeId == request.EnvelopeId && er.ReceiverId == request.ReceiverId, cancel);
}
}

View File

@@ -45,7 +45,7 @@ public class ReadEnvelopeTypesQueryHandler : IRequestHandler<ReadEnvelopeTypesQu
Title = t.Title ?? string.Empty,
Language = string.IsNullOrWhiteSpace(t.Language) ? "de-DE" : t.Language,
ExpiresDays = t.ExpiresDays,
CertificationType = (int?)t.CertificationType,
CertificationType = t.CertificationType,
UseAccessCode = t.UseAccessCode,
FinalEmailToCreator = (int?)t.FinalEmailToCreator,
FinalEmailToReceivers = (int?)t.FinalEmailToReceivers,

View File

@@ -1,5 +1,6 @@
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor;
using EnvelopeGenerator.Domain.Constants;
using MediatR;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
@@ -35,7 +36,7 @@ public record CreateEnvelopeCommand : IRequest<EnvelopeDto?>
/// <summary>
/// Zertifizierungstyp fuer den Signaturprozess.
/// </summary>
public int? CertificationType { get; set; }
public CertificationType? CertificationType { get; set; }
/// <summary>
/// Finale E-Mail-Einstellung fuer den Ersteller.
@@ -129,10 +130,27 @@ public record CreateEnvelopeCommand : IRequest<EnvelopeDto?>
public bool UseSQLExecutor { get; set; } = true;
}
/// <summary>
///
/// </summary>
/// <param name="DataAsBase64"></param>
public record CreateEnvelopeDocumentDto(string DataAsBase64);
/// <summary>
///
/// </summary>
/// <param name="X"></param>
/// <param name="Y"></param>
/// <param name="Page"></param>
public record CreateEnvelopeFieldDto(double X, double Y, int Page);
/// <summary>
///
/// </summary>
/// <param name="EmailAddress"></param>
/// <param name="Name"></param>
/// <param name="PhoneNumber"></param>
/// <param name="Fields"></param>
public record CreateEnvelopeReceiverDto(
string EmailAddress,
string? Name,

View File

@@ -1,68 +1,66 @@
using AutoMapper;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using DigitalData.Core.Abstraction.Application.Repository;
using DigitalData.EmailProfilerDispatcher.Abstraction.Entities;
using DigitalData.Core.Exceptions;
using EnvelopeGenerator.Domain.Entities;
using EnvelopeGenerator.Application.Common.Dto;
using DigitalData.EmailProfilerDispatcher.Abstraction.Entities;
using DigitalData.UserManager.Domain.Entities;
using EnvelopeGenerator.Application.Common.Configurations;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Extensions;
using EnvelopeGenerator.Application.Common.Interfaces;
using EnvelopeGenerator.Application.Common.Interfaces.Services;
using EnvelopeGenerator.Application.Common.Interfaces.Services.Notification;
using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor;
using EnvelopeGenerator.Application.Histories.Commands;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Domain;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Domain.Entities;
using EnvelopeGenerator.Domain.Interfaces;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace EnvelopeGenerator.Application.Envelopes.Commands;
/// <summary>
///
/// </summary>
public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeCommand, EnvelopeDto?>
/// <param name="Provider"></param>
/// <param name="Mapper"></param>
/// <param name="EnvNtfService"></param>
/// <param name="ReqCntxt"></param>
/// <param name="Logger"></param>
public class CreateEnvelopeCommandHandler(IServiceProvider Provider, IMapper Mapper, IEnvelopeNotificationService EnvNtfService, IRequestContext ReqCntxt, ILogger<CreateEnvelopeCommandHandler> Logger) : IRequestHandler<CreateEnvelopeCommand, EnvelopeDto?>
{
private readonly IServiceProvider _provider;
private IEnvelopeExecutor Executor => Provider.GetRequiredService<IEnvelopeExecutor>();
private IEnvelopeExecutor Executor => _provider.GetRequiredService<IEnvelopeExecutor>();
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<Document> DocumentRepository => _provider.GetRequiredService<IRepository<Document>>();
private IRepository<EnvelopeReceiver> EnvelopeReceiverRepository => Provider.GetRequiredService<IRepository<EnvelopeReceiver>>();
private IRepository<EnvelopeReceiver> EnvelopeReceiverRepository => _provider.GetRequiredService<IRepository<EnvelopeReceiver>>();
private IRepository<EnvelopeType> EnvelopeTypeRepository => Provider.GetRequiredService<IRepository<EnvelopeType>>();
private IRepository<EnvelopeType> EnvelopeTypeRepository => _provider.GetRequiredService<IRepository<EnvelopeType>>();
private IRepository<Receiver> ReceiverRepository => Provider.GetRequiredService<IRepository<Receiver>>();
private IRepository<Receiver> ReceiverRepository => _provider.GetRequiredService<IRepository<Receiver>>();
private IRepository<DocReceiverElement> DocReceiverElementRepository => Provider.GetRequiredService<IRepository<DocReceiverElement>>();
private IRepository<DocReceiverElement> DocReceiverElementRepository => _provider.GetRequiredService<IRepository<DocReceiverElement>>();
private IRepository<EmailTemplate> EmailTemplateRepository => Provider.GetRequiredService<IRepository<EmailTemplate>>();
private IRepository<EmailTemplate> EmailTemplateRepository => _provider.GetRequiredService<IRepository<EmailTemplate>>();
private IRepository<EmailOut> EmailOutRepository => Provider.GetRequiredService<IRepository<EmailOut>>();
private IRepository<EmailOut> EmailOutRepository => _provider.GetRequiredService<IRepository<EmailOut>>();
private MailParams MailParams => Provider.GetRequiredService<IOptions<MailParams>>().Value;
private MailParams MailParams => _provider.GetRequiredService<IOptions<MailParams>>().Value;
private DispatcherParams DispatcherParams => Provider.GetRequiredService<IOptions<DispatcherParams>>().Value;
private DispatcherParams DispatcherParams => _provider.GetRequiredService<IOptions<DispatcherParams>>().Value;
private SignatureFieldParams SignatureFieldParams => Provider.GetRequiredService<IOptionsMonitor<SignatureFieldParams>>().CurrentValue;
private IConfigService ConfigService => _provider.GetRequiredService<IConfigService>();
[Obsolete("Use IConfigService instead.")]
private IConfigService ConfigService => Provider.GetRequiredService<IConfigService>();
private IMediator Mediator => _provider.GetRequiredService<IMediator>();
private readonly IMapper _mapper;
/// <summary>
///
/// </summary>
/// <param name="provider"></param>
/// <param name="mapper"></param>
public CreateEnvelopeCommandHandler(IServiceProvider provider, IMapper mapper)
{
_provider = provider;
_mapper = mapper;
}
private IMediator Mediator => Provider.GetRequiredService<IMediator>();
/// <summary>
///
@@ -230,7 +228,7 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
.ThenInclude(er => er.Receiver)
.FirstAsync(e => e.Id == envelope.Id, cancel);
return _mapper.Map<EnvelopeDto>(envelope);
return Mapper.Map<EnvelopeDto>(envelope);
}
private async Task<EnvelopeSettings> ResolveEnvelopeSettingsAsync(CreateEnvelopeCommand request, Envelope? existing, CancellationToken cancel)
@@ -268,9 +266,9 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
Language: ResolveLanguage(request.Language, selectedType?.Language, existing?.Language),
UseAccessCode: useAccessCode,
CertificationType: request.CertificationType
?? (int?)selectedType?.CertificationType
?? selectedType?.CertificationType
?? existing?.CertificationType
?? (int)CertificationType.AdvancedElectronicSignature,
?? CertificationType.AdvancedElectronicSignature,
FinalEmailToCreator: request.FinalEmailToCreator
?? (int?)selectedType?.FinalEmailToCreator
?? existing?.FinalEmailToCreator
@@ -299,7 +297,7 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
ExpiresWarningWhen: expiresWarningWhenDays > 0 ? DateTime.Now.AddDays(expiresWarningWhenDays) : null,
ContractType: existing?.ContractType
?? selectedType?.ContractType
?? (int)ContractType.Contract);
?? ContractType.Contract);
}
private static string ResolveLanguage(string? preferred, string? fallback1, string? fallback2)
@@ -320,7 +318,7 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
int? EnvelopeTypeId,
string Language,
bool UseAccessCode,
int CertificationType,
CertificationType CertificationType,
int FinalEmailToCreator,
int FinalEmailToReceivers,
bool SendReminderEmails,
@@ -330,7 +328,7 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
int ExpiresWarningWhenDays,
DateTime? ExpiresWhen,
DateTime? ExpiresWarningWhen,
int ContractType);
ContractType ContractType);
private async Task<string> ResolveSenderReferenceAsync(Envelope envelope, CancellationToken cancel)
{
@@ -444,8 +442,8 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
ElementType = 1,
X = field.X,
Y = field.Y,
Width = 1.77,
Height = 1.96,
Width = SignatureFieldParams.Width,
Height = SignatureFieldParams.Height,
Page = field.Page,
Required = true,
ReadOnly = false,
@@ -529,15 +527,15 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
if (!hasDocument)
throw new BadRequestException("Cannot send envelope without a document.");
var links = await EnvelopeReceiverRepository.Query
var envRcvs = await EnvelopeReceiverRepository.Query
.Include(er => er.Receiver)
.Where(er => er.EnvelopeId == envelope.Id)
.ToListAsync(cancel);
if (links.Count == 0)
if (envRcvs.Count == 0)
throw new BadRequestException("Cannot send envelope without receivers.");
var receiverIds = links.Select(l => l.ReceiverId).Distinct().ToList();
var receiverIds = envRcvs.Select(l => l.ReceiverId).Distinct().ToList();
var documentIds = await DocumentRepository.Query
.Where(d => d.EnvelopeId == envelope.Id)
.Select(d => d.Id)
@@ -550,7 +548,7 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
.Select(g => new { ReceiverId = g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.ReceiverId, x => x.Count, cancel);
var receiversWithoutFields = links
var receiversWithoutFields = envRcvs
.Where(link => !receiverFieldCounts.TryGetValue(link.ReceiverId, out var count) || count <= 0)
.Select(link => link.Name)
.ToList();
@@ -574,70 +572,39 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
Status = EnvelopeStatus.EnvelopeQueued
}, cancel);
await QueueInvitationEmailsAsync(envelope, links, userReference, cancel);
await SendInvitationEmailsAsync(envelope, envRcvs, cancel);
envelope.Status = EnvelopeStatus.EnvelopeQueued;
}
private async Task QueueInvitationEmailsAsync(
private async Task SendInvitationEmailsAsync(
Envelope envelope,
IReadOnlyCollection<EnvelopeReceiver> links,
string senderReference,
IReadOnlyCollection<EnvelopeReceiver> envRcvs,
CancellationToken cancel)
{
var template = await EmailTemplateRepository.Query
.SingleOrDefaultAsync(x => x.Name == EmailTemplateType.DocumentReceived.ToString(), cancel)
?? throw new InvalidOperationException($"Email template not found: {EmailTemplateType.DocumentReceived}");
var signatureHost = await ConfigService.ReadDefaultSignatureHost();
var senderName = senderReference;
foreach (var link in links)
foreach (var er in envRcvs)
{
var receiver = link.Receiver;
if (receiver is null || string.IsNullOrWhiteSpace(receiver.EmailAddress) || string.IsNullOrWhiteSpace(receiver.Signature))
continue;
var envelopeKey = (envelope.Uuid, receiver.Signature).ToEnvelopeKey();
var documentLink = $"{signatureHost.TrimEnd('/')}/envelope/login/{Uri.EscapeDataString(envelopeKey)}";
var placeholders = new Dictionary<string, string>(MailParams.Placeholders)
var advancedSignature = envelope.CertificationType == CertificationType.AdvancedElectronicSignature;
var origin = new Uri(ReqCntxt.Origin ?? throw new InvalidOperationException($"The request origin is missing in {nameof(CreateEnvelopeCommandHandler)}."));
try
{
["[NAME_RECEIVER]"] = link.Name ?? string.Empty,
["[NAME_SENDER]"] = senderName ?? string.Empty,
["[DOCUMENT_TITLE]"] = envelope.Title ?? string.Empty,
["[MESSAGE]"] = envelope.Message ?? string.Empty,
["[LINK_TO_DOCUMENT]"] = documentLink,
["[LINK_TO_DOCUMENT_TEXT]"] = documentLink[..Math.Min(40, documentLink.Length)] + ".."
};
var subject = ReplacePlaceHolders(template.Subject, placeholders);
var body = ReplacePlaceHolders(template.Body, placeholders);
var emailOut = new EmailOut
await EnvNtfService.Notify(er.Receiver.EmailAddress, new DocumentReceivedNotification
{
AdvancedSignature = advancedSignature,
DocumentTitle = envelope.Title ?? string.Empty,
NameReceiver = er.Name ?? string.Empty,
EnvelopeUuid = envelope.Uuid,
IsReadAndConfirm = envelope.IsReadAndConfirm(),
Message = envelope.Message ?? string.Empty,
NameSender = $"{envelope.User.Prename} {envelope.User.Name}",
Origin = origin,
ReceiverSignature = er.Receiver.Signature,
}, cancel);
}
catch(Exception ex)
{
EmailAddress = receiver.EmailAddress,
EmailSubj = subject,
EmailBody = body,
AddedWhen = DateTime.Now,
AddedWho = DispatcherParams.AddedWho,
SendingProfile = DispatcherParams.SendingProfile,
ReminderTypeId = DispatcherParams.ReminderTypeId,
EmailAttmt1 = DispatcherParams.EmailAttmt1,
WfId = (int)EnvelopeStatus.MessageInvitationSent,
ReferenceString = receiver.EmailAddress,
ReferenceId = link.ReceiverId
};
await EmailOutRepository.CreateAsync(emailOut, cancel);
Logger.LogError(ex, "Failed to send invitation email for receiver {emailAddress} in envelope {envelopeId}.", er.Receiver.EmailAddress, envelope.Id);
}
}
}
private static string ReplacePlaceHolders(string text, Dictionary<string, string> placeHolders)
{
foreach (var placeHolder in placeHolders)
text = text.Replace(placeHolder.Key, placeHolder.Value);
return text;
}
}

View File

@@ -2,6 +2,9 @@
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Application.Common.Query;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver;
using EnvelopeGenerator.Application.Common.Dto.History;
using EnvelopeGenerator.Application.Common.Dto.Receiver;
using AutoMapper;
using DigitalData.Core.Abstraction.Application.Repository;
using EnvelopeGenerator.Domain.Entities;
@@ -14,6 +17,11 @@ namespace EnvelopeGenerator.Application.Envelopes.Queries;
/// </summary>
public record ReadEnvelopeQuery : EnvelopeQueryBase, IRequest<IEnumerable<EnvelopeDto>>
{
/// <summary>
///
/// </summary>
public bool IncludeDocAndDocRes { get; init; } = true;
/// <summary>
///
/// </summary>
@@ -148,6 +156,86 @@ public class ReadEnvelopeQueryHandler : IRequestHandler<ReadEnvelopeQuery, IEnum
if (request is { OnlyCompleted: true })
query = query.Where(e => Status.Completed.Contains(e.Status));
if (!request.IncludeDocAndDocRes)
{
return await query
.Select(e => new EnvelopeDto
{
Id = e.Id,
UserId = e.UserId,
Status = e.Status,
StatusName = string.Empty,
Uuid = e.Uuid,
Message = e.Message,
AddedWhen = e.AddedWhen,
ChangedWhen = e.ChangedWhen,
Title = e.Title ?? string.Empty,
ContractType = e.ContractType,
Language = e.Language,
EnvelopeTypeId = e.EnvelopeTypeId,
CertificationType = e.CertificationType,
FinalEmailToCreator = e.FinalEmailToCreator,
FinalEmailToReceivers = e.FinalEmailToReceivers,
SendReminderEmails = e.SendReminderEmails,
FirstReminderDays = e.FirstReminderDays,
ReminderIntervalDays = e.ReminderIntervalDays,
ExpiresWhenDays = e.ExpiresWhenDays,
ExpiresWarningWhenDays = e.ExpiresWarningWhenDays,
UseAccessCode = e.UseAccessCode ?? true,
TFAEnabled = e.TfaEnabled ?? false,
EnvelopeTypeTitle = e.Type != null ? e.Type.Title : null,
IsAlreadySent = false,
DocResult = null,
Documents = null,
EnvelopeReceivers = e.EnvelopeReceivers.Select(er => new EnvelopeReceiverDto
{
EnvelopeId = er.EnvelopeId,
ReceiverId = er.ReceiverId,
Sequence = er.Sequence,
Name = er.Name,
JobTitle = er.JobTitle,
CompanyName = er.CompanyName,
PrivateMessage = er.PrivateMessage,
AccessCode = er.AccessCode,
AddedWhen = er.AddedWhen,
ChangedWhen = er.ChangedWhen,
HasPhoneNumber = er.HasPhoneNumber,
Receiver = er.Receiver == null
? null
: new ReceiverDto
{
Id = er.Receiver.Id,
EmailAddress = er.Receiver.EmailAddress,
Signature = er.Receiver.Signature,
AddedWhen = er.Receiver.AddedWhen,
TfaRegDeadline = er.Receiver.TfaRegDeadline
}
}),
Histories = e.Histories.Select(h => new HistoryDto
{
Id = h.Id,
EnvelopeId = h.EnvelopeId,
UserReference = h.UserReference,
Status = h.Status,
StatusName = null,
AddedWhen = h.AddedWhen,
ActionDate = h.ActionDate,
Comment = h.Comment,
Receiver = h.Receiver == null
? null
: new ReceiverDto
{
Id = h.Receiver.Id,
EmailAddress = h.Receiver.EmailAddress,
Signature = h.Receiver.Signature,
AddedWhen = h.Receiver.AddedWhen,
TfaRegDeadline = h.Receiver.TfaRegDeadline
}
})
})
.ToListAsync(cancel);
}
var envelopes = await query
.Include(e => e.Type)
.Include(e => e.EnvelopeReceivers).ThenInclude(er => er.Receiver)

View File

@@ -98,7 +98,8 @@ public class FinalizeEnvelopeCommandHandler(IRepository<Envelope> Repo, ISender
{
DocumentTitle = envelope.Title,
NameReceiver = er.Name,
Attachment = doc
Attachments = [($"unterzeichnetes-dokument.pdf", doc)],
IsReadAndConfirm = envelope.IsReadAndConfirm(),
}, cancel);
}

View File

@@ -2,8 +2,7 @@
{
public enum CertificationType
{
AdvancedElectronicSignature = 1
// ElectronicSignature = 1
// QualifiedSignature = 2
AdvancedElectronicSignature = 1,
ElectronicSignature = 2
}
}
}

View File

@@ -30,7 +30,7 @@ namespace EnvelopeGenerator.Domain.Entities
SendReminderEmails = false;
FirstReminderDays = 0;
ReminderIntervalDays = 0;
CertificationType = (int)Constants.CertificationType.AdvancedElectronicSignature;
CertificationType = Constants.CertificationType.AdvancedElectronicSignature;
UseAccessCode = false;
Documents = Enumerable.Empty<Document>().ToList();
Histories = Enumerable.Empty<History>().ToList();
@@ -83,7 +83,7 @@ namespace EnvelopeGenerator.Domain.Entities
public string Comment { get; set; }
[Column("CONTRACT_TYPE")]
public int? ContractType { get; set; }
public ContractType? ContractType { get; set; }
#if NETFRAMEWORK
[NotMapped]
@@ -111,7 +111,7 @@ namespace EnvelopeGenerator.Domain.Entities
public bool ReadOnly => this.IsReadAndConfirm();
[Column("CERTIFICATION_TYPE")]
public int? CertificationType { get; set; }
public CertificationType? CertificationType { get; set; }
[Column("USE_ACCESS_CODE")]
public bool? UseAccessCode { get; set; }
@@ -129,7 +129,11 @@ namespace EnvelopeGenerator.Domain.Entities
public int? ExpiresWarningWhenDays { get; set; }
[ForeignKey("UserId")]
public User User { get; set; }
public User
#if nullable
?
#endif
User { get; set; }
[Column("TFA_ENABLED")]
public bool? TfaEnabled { get; set; }

View File

@@ -1,5 +1,7 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using EnvelopeGenerator.Domain.Constants;
#if NETFRAMEWORK
using System;
#endif
@@ -56,7 +58,7 @@ namespace EnvelopeGenerator.Domain.Entities
public int? ReminderIntervalDays { get; set; }
[Column("CONTRACT_TYPE")]
public int? ContractType { get; set; }
public ContractType? ContractType { get; set; }
[NotMapped]
public bool TFA_Enabled { get; set; } = false;

View File

@@ -0,0 +1,7 @@
namespace EnvelopeGenerator.Infrastructure.Notification.Configuration;
public enum ConsumerType
{
API,
RabbitMQ
}

View File

@@ -0,0 +1,16 @@
using DigitalData.MessagingService.RabbitMQ;
namespace EnvelopeGenerator.Infrastructure.Notification.Configuration;
public class NotificationOptions
{
public const string SectionName = "Notification";
public ConsumerType ConsumerType { get; set; }
public string? ApiUrl { get; set; }
public string SenderEmail { get; set; } = null!;
public RabbitMqConnectionConfiguration? RabbitMQ { get; set; }
}

View File

@@ -1,41 +1,47 @@
using DigitalData.MessagingService.Client;
using DigitalData.MessagingService.Domain.Entities;
using DigitalData.MessagingService.RabbitMQ;
using EnvelopeGenerator.Application.Common.Interfaces.Services.Notification;
using EnvelopeGenerator.Infrastructure.Notification.Configuration;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace EnvelopeGenerator.Infrastructure.Notification;
public static class DependencyInjection
{
public static IServiceCollection AddNotificationInfrastructure(this IServiceCollection services, IConfiguration configuration)
public static IServiceCollection AddNotificationInfrastructure(this IServiceCollection services, IConfiguration configuration, string sectionName = NotificationOptions.SectionName)
{
var rMqConfig = configuration.GetSection("RabbitMQ").Get<RabbitMqConfiguration>()
?? throw new ArgumentNullException(nameof(RabbitMqConfiguration), "RabbitMQ configuration is missing");
var ntfConfiguration = configuration.GetSection(sectionName);
services.Configure<NotificationOptions>(ntfConfiguration);
EmailSender.ConnectRabbitMq(config =>
var options = ntfConfiguration.Get<NotificationOptions>()
?? throw new InvalidOperationException(
$"Failed to bind configuration section '{sectionName}' to {nameof(NotificationOptions)}. Ensure the section exists and is correctly configured.");
switch (options.ConsumerType)
{
config.HostName = rMqConfig.HostName;
config.Port = rMqConfig.Port;
config.UserName = rMqConfig.UserName;
config.Password = rMqConfig.Password;
config.VirtualHost = "/";
config.QueueName = "emailprofiler.email.outbox";
config.ExchangeName = "emailprofiler.emails";
config.RoutingKey = "email.outbox";
config.DlqQueueName = "emailprofiler.email.outbox.dlq";
config.DlqExchangeName = "emailprofiler.emails.dlq";
config.DlqRoutingKey = "email.outbox.dlq";
});
services.Configure<EmailAccount>(configuration.GetSection("NotificationEmailAccount"));
case ConsumerType.API:
if (options.ApiUrl is string apiUrl)
services.AddMessagingServiceHttpClient(opt =>
{
opt.BaseAddress = new Uri(apiUrl);
// Additional configuration for the MessagingServiceHttpClient can be added here if needed
});
else
throw new InvalidOperationException(
$"MessagingServiceApiUrl must be provided when ClientType is set to Api in the '{sectionName}' configuration section.");
break;
case ConsumerType.RabbitMQ:
if(options.RabbitMQ is not null)
services.AddMessagingServiceClient(ntfConfiguration, nameof(options.RabbitMQ));
else
throw new InvalidOperationException(
$"Missing RabbitMQ connection configuration." +
$"When ClientType is set to RabbitMQ, a valid RabbitMQ configuration must be provided in the '{sectionName}' configuration section.");
break;
default:
throw new InvalidOperationException(
$"Unsupported ConsumerType '{options.ConsumerType}' in the '{sectionName}' configuration section.");
}
services.AddScoped<IEnvelopeNotificationService, EnvelopeNotificationService>();
return services;

View File

@@ -8,7 +8,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DigitalData.MessagingService.Client" Version="1.0.5" />
<PackageReference Include="DigitalData.MessagingService.Client" Version="3.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.10" />
</ItemGroup>

View File

@@ -0,0 +1,15 @@
using DigitalData.MessagingService.Application.Common.Dto;
namespace EnvelopeGenerator.Infrastructure.Notification;
public static class EnvelopeNotificationExtensions
{
public static EmailAttachmentDto ToEmailAttachmentDto(this (string FileName, byte[] Content) attachment) => new()
{
FileName = attachment.FileName,
Content = attachment.Content
};
public static IEnumerable<EmailAttachmentDto> ToEmailAttachmentDto(this IEnumerable<(string FileName, byte[] Content)> attachments)
=> attachments.Select(ToEmailAttachmentDto);
}

View File

@@ -1,44 +1,45 @@
using DigitalData.Core.Abstraction.Application.Repository;
using DigitalData.Core.Exceptions;
using DigitalData.MessagingService.Application.Common.Dto;
using DigitalData.MessagingService.Client;
using DigitalData.MessagingService.Domain.Entities;
using DigitalData.MessagingService.Application.Common.Interfaces;
using DigitalData.MessagingService.Application.EmailSending.Commands;
using EnvelopeGenerator.Application.Common.Interfaces.Services.Notification;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Domain.Entities;
using MediatR;
using EnvelopeGenerator.Domain.Exceptions;
using EnvelopeGenerator.Infrastructure.Notification.Configuration;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace EnvelopeGenerator.Infrastructure.Notification;
public class EnvelopeNotificationService(IOptions<EmailAccount> EmailAccountOptions, IRepository<EmailTemplate> TempRepo) : IEnvelopeNotificationService
public class EnvelopeNotificationService(IRepository<EmailTemplate> TempRepo, IMessagingServiceClient MsgClient, IOptions<NotificationOptions> ntfOpt) : IEnvelopeNotificationService
{
public async Task Notify(string email, DocumentSignedNotification documentSigned, CancellationToken cancel = default)
{
var template = await TempRepo.Where(t => t.Name == "DocumentCompleted").FirstOrDefaultAsync(cancel)
?? throw new NotFoundException("DocumentCompleted email template not found");
var subjectTemplate = Replace(template.Subject, documentSigned);
var bodyTemplate = Replace(template.Body, documentSigned);
private readonly NotificationOptions NtfOpt = ntfOpt.Value;
await EmailSender.Send(new EmailContext()
private async Task<EmailTemplate> GetTemplateAsync(EmailTemplateType templateType, CancellationToken cancel = default)
{
return await TempRepo.Where(t => t.Name == templateType.ToString()).FirstOrDefaultAsync(cancel)
?? throw new DataIntegrityException($"{templateType} email template not found");
}
public async Task Notify<TEnvelopeNotificationBase>(string receiverEmail, TEnvelopeNotificationBase notification, CancellationToken cancel = default)
where TEnvelopeNotificationBase : EnvelopeNotificationBase
{
var template = await GetTemplateAsync(notification.GetTemplateType(), cancel);
// create email command
var mail = new PublishEmailViaSmtpCommand()
{
Body = bodyTemplate,
Subject = subjectTemplate,
Recipients = [email],
Sender = EmailAccountOptions.Value,
Attachments = [new EmailAttachmentDto()
{
FileName = "unterzeichnetes-dokument.pdf",
Content = documentSigned.Attachment
}]
});
}
Body = template.Body.ReplacePlaceholders(notification),
Subject = template.Subject.ReplacePlaceholders(notification),
Recipients = [receiverEmail],
Sender = NtfOpt.SenderEmail.ToSender(),
IsHtml = true
};
private static string Replace(string template, DocumentSignedNotification documentSigned)
{
return template
.Replace("[DOCUMENT_TITLE]", documentSigned.DocumentTitle)
.Replace("[NAME_PORTAL]", documentSigned.NamePortal)
.Replace("[NAME_RECEIVER]", documentSigned.NameReceiver);
// add attachments if any
if(notification.Attachments?.Any() ?? false)
mail = mail.WithAttachments(notification.Attachments.ToEmailAttachmentDto());
await MsgClient.SendAsync(mail, cancel);
}
}
}

View File

@@ -0,0 +1,47 @@
using System.Collections.Concurrent;
using System.Reflection;
using System.Text.RegularExpressions;
namespace EnvelopeGenerator.Infrastructure.Notification;
public static class ReplacePlaceholderExtensions
{
private sealed record PlaceholderProperty(string Placeholder, PropertyInfo Property);
private static readonly ConcurrentDictionary<Type, PlaceholderProperty[]> PropertyCache = new();
public static string ReplacePlaceholders(this string template, object data)
{
var properties = PropertyCache.GetOrAdd(data.GetType(), CreatePlaceholderProperties);
foreach (var item in properties)
{
var value = item.Property.GetValue(data)?.ToString() ?? string.Empty;
template = template.Replace(
item.Placeholder,
value,
StringComparison.Ordinal);
}
return template;
}
private static PlaceholderProperty[] CreatePlaceholderProperties(
Type type)
{
return [.. type
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.CanRead && p.GetIndexParameters().Length == 0)
.Select(p => new PlaceholderProperty(
$"[{ToCapitalSnakeCase(p.Name)}]",
p))];
}
private static string ToCapitalSnakeCase(string name)
{
return Regex.Replace(
Regex.Replace(name, @"([A-Z]+)([A-Z][a-z])", "$1_$2"),
@"([a-z0-9])([A-Z])",
"$1_$2").ToUpperInvariant();
}
}

View File

@@ -0,0 +1,17 @@
-- Original email template: DocumentAccessCodeReceived (GUID 5)
-- Backed up before redesign: 2026-10-29 16:32:15
-- Subject: Zugriffscode für Dokument erhalten: ''[DOCUMENT_TITLE]''
-- Restore by running this script against DD_ECM:
UPDATE dbo.TBSIG_EMAIL_TEMPLATE
SET BODY = N'Guten Tag [NAME_RECEIVER],<br />
<br /><B><I>
[NAME_SENDER]</I></B> hat Ihnen ein Dokument zum [SIGNATURE_TYPE] gesendet. <br />
<br />
Verwenden Sie den folgenden Zugriffscode, um das Dokument einzusehen:<br />
<br />
[DOCUMENT_ACCESS_CODE]<br />
<br />
Mit freundlichen Grüßen<br />
<br />
[NAME_PORTAL]'
WHERE GUID = 5;

View File

@@ -0,0 +1,15 @@
-- Original email template: DocumentCompleted (GUID 4)
-- Backed up before redesign: 2026-10-29 16:29:55
-- Subject: Umschlag abgeschlossen: ''[DOCUMENT_TITLE]''
-- Restore by running this script against DD_ECM:
UPDATE dbo.TBSIG_EMAIL_TEMPLATE
SET BODY = N'Guten Tag [NAME_RECEIVER],<br />
<br />
Der Vorgang <B><I>''[DOCUMENT_TITLE]''</I></B> wurde erfolgreich abgeschlossen.<br />
<br />
Sie erhalten das Dokument mit einem detaillierten Ergebnisbericht als Anhang zu dieser Email.<br />
<br />
Mit freundlichen Grüßen<br />
<br />
[NAME_PORTAL]'
WHERE GUID = 4;

View File

@@ -0,0 +1,13 @@
-- Original email template: DocumentDeleted (GUID 2)
-- Backed up before redesign: 2026-10-29 16:23:45
-- Subject: Umschlag zurückgezogen: ''[DOCUMENT_TITLE]''
-- Restore by running this script against DD_ECM:
UPDATE dbo.TBSIG_EMAIL_TEMPLATE
SET BODY = N'Guten Tag [NAME_RECEIVER],<br />
<br /><B><I>
[NAME_SENDER]</I></B> hat den Umschlag <B><I>''[DOCUMENT_TITLE]''</I></B> gelöscht/zurückgezogen.<br /><p>
Begründung: <br /> <I>[REASON]</I> <p>
<br />
Mit freundlichen Grüßen<br />
<br />
[NAME_PORTAL]'

View File

@@ -0,0 +1,17 @@
-- Original email template: DocumentReceived (GUID 1, de-DE)
-- Backed up before redesign: 2026-10-29 16:06:07
-- Subject: Dokument erhalten: ''[DOCUMENT_TITLE]''
-- Restore by running this script against DD_ECM:
UPDATE dbo.TBSIG_EMAIL_TEMPLATE
SET BODY = N'Guten Tag [NAME_RECEIVER],<br />
<br /><B><I>
[NAME_SENDER]</I></B> hat Ihnen ein Dokument zum [SIGNATURE_TYPE] gesendet.<br />
<br />
Über den folgenden Link können Sie das Dokument einsehen[DOCUMENT_PROCESS]: <a href="[LINK_TO_DOCUMENT]">[LINK_TO_DOCUMENT_TEXT]</a><br />
<br />
[MESSAGE]<br />
<br />
Mit freundlichen Grüßen<br />
<br />
[NAME_PORTAL]'
WHERE GUID = 1;

View File

@@ -0,0 +1,12 @@
-- Original email template: DocumentRejected_ADM (GUID 6)
-- Backed up before redesign: 2026-10-29 16:33:34
-- Subject: ''[DOCUMENT_TITLE]'' - Vorgang zurückgezogen
-- Restore by running this script against DD_ECM:
UPDATE dbo.TBSIG_EMAIL_TEMPLATE
SET BODY = N'Guten Tag [NAME_SENDER],<p><B><I>[NAME_RECEIVER]</I></B> hat den Umschlag <B><I>''[DOCUMENT_TITLE]''</I></B> mit folgendem Grund abgelehnt: <p>
[REASON]
<p>Der Umschlag wurde auf den Status Rejected gesetzt. <p>
Mit freundlichen Grüßen<br />
<br />
[NAME_PORTAL]'
WHERE GUID = 6;

View File

@@ -0,0 +1,11 @@
-- Original email template: DocumentRejected_REC_2 (GUID 10)
-- Backed up before redesign: 2026-10-29 16:37:12
-- Subject: ''[DOCUMENT_TITLE]'' - Vorgang abgelehnt.
-- Restore by running this script against DD_ECM:
UPDATE dbo.TBSIG_EMAIL_TEMPLATE
SET BODY = N'Guten Tag [NAME_RECEIVER],
<p>Der Vorgang <B><I>''[DOCUMENT_TITLE]''</I></B> wurde durch einen anderen Vertragspartner abgelehnt.<p> Der Absender <B><I>[NAME_SENDER]</I></B> wird sich bei Bedarf mit Ihnen in Verbindung setzen. <p>
Mit freundlichen Grüßen<br />
<br />
[NAME_PORTAL]'
WHERE GUID = 10;

View File

@@ -0,0 +1,11 @@
-- Original email template: DocumentRejected_REC (GUID 9)
-- Backed up before redesign: 2026-10-29 16:34:21
-- Subject: ''[DOCUMENT_TITLE]'' - Bestätigung der Ablehnung
-- Restore by running this script against DD_ECM:
UPDATE dbo.TBSIG_EMAIL_TEMPLATE
SET BODY = N'Guten Tag [NAME_RECEIVER],
<p>Hiermit bestätigen wir Ihnen die Ablehnung des Vorgangs <B><I>''[DOCUMENT_TITLE]''</I></B>!<p>Der Absender <B><I>[NAME_SENDER]</I></B> wurde über die Ablehnung informiert. <p>
Mit freundlichen Grüßen<br />
<br />
[NAME_PORTAL]'
WHERE GUID = 9;

View File

@@ -0,0 +1,7 @@
-- Original email template: DocumentShared (GUID 11)
-- Backed up before redesign: 2026-10-29 16:42:51
-- Subject: Dokument geteilt: ''[DOCUMENT_TITLE]''
-- Restore by running this script against DD_ECM:
UPDATE dbo.TBSIG_EMAIL_TEMPLATE
SET BODY = N'Guten Tag,<br /> <br /><B><I> [NAME_RECEIVER]</I></B> hat Ihnen ein Dokument zum Ansehen gesendet.<br /> <br /> Über den folgenden Link können Sie das Dokument einsehen: <a href="[LINK_TO_DOCUMENT]">[LINK_TO_DOCUMENT_TEXT]</a><br /> <br /> <br /> Mit freundlichen Grüßen<br /> <br /> [NAME_PORTAL]'
WHERE GUID = 11;

View File

@@ -0,0 +1,14 @@
-- Original email template: DocumentSigned (GUID 3)
-- Backed up before redesign: 2026-10-29 16:25:45
-- Subject: Dokument [RECEIVER_ACTION]: ''[DOCUMENT_TITLE]''
-- Restore by running this script against DD_ECM:
UPDATE dbo.TBSIG_EMAIL_TEMPLATE
SET BODY = N'Guten Tag [NAME_RECEIVER],<br />
<br />
hiermit bestätigen wir Ihnen die erfolgreiche [FINAL_STATUS] für den Vorgang <B><I>''[DOCUMENT_TITLE]''</I></B>.<br />
Wenn alle [FINAL_ACTION] haben, erhalten Sie ebenfalls per E-Mail ein abgeschlossenes Exemplar mit entsprechendem Zertifikat!
<br />
Mit freundlichen Grüßen<br />
<br />
[NAME_PORTAL]'
WHERE GUID = 3;

View File

@@ -0,0 +1,8 @@
-- Original email template: TotpSecret (GUID 12)
-- Backed up before redesign: 2026-10-29 16:44:44
-- Subject: 2-Faktor-Authentifizierung QR-Code
-- Restore by running this script against DD_ECM:
UPDATE dbo.TBSIG_EMAIL_TEMPLATE
SET BODY = N'Guten Tag,<br /> <br />Sie können auf Ihren Zwei-Faktor-Authentifizierungscode zugreifen, indem Sie den unten stehenden QR-Code mit einer beliebigen Authentifizierungs-App auf Ihrem Telefon scannen (Google Authenticator, Microsoft Authenticator usw.). Dieser Code ist bis zum [TFA_EXPIRATION] gültig.<br /> <br /> <img src="data:image/png;base64,[TFA_QR_CODE]" style="width: 13rem; height: 13rem;"><br /> <br />
<br /> Mit freundlichen Grüßen<br /> <br /> [NAME_PORTAL]'
WHERE GUID = 12;

View File

@@ -5,6 +5,7 @@
@using EnvelopeGenerator.Domain.Resources
@using EnvelopeGenerator.Server.Client.Components.Sender.ReceiverSuggestionFilter
@using EnvelopeGenerator.Server.Client.Services
@using MudBlazor
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.Extensions.Localization
@inject IStringLocalizer<Model> ModelLocalizer
@@ -27,334 +28,341 @@
<div class="sender-create-wizard"
tabindex="0"
@onkeydown="OnWizardKeyDownAsync">
<div class="sender-create-wizard__progress">
<div class="sender-create-wizard__progress-track"></div>
@for (var step = 0; step < TotalSteps; step++)
{
var isActive = step == _currentStep;
var isDone = step < _currentStep;
<div class="sender-create-wizard__step @(isActive ? "sender-create-wizard__step--active" : string.Empty) @(isDone ? "sender-create-wizard__step--done" : string.Empty)">
<span class="sender-create-wizard__step-index">@(step + 1)</span>
</div>
}
</div>
<MudPaper Class="sender-create-wizard__stepper-shell" Elevation="0">
<MudStepper Vertical="true"
NonLinear="false"
ShowResetButton="false"
ActiveIndex="@_currentStep"
Class="sender-create-wizard__stepper">
<ChildContent>
@for (var step = 0; step < TotalSteps; step++)
{
var stepIndex = step;
<div class="sender-create-wizard__meta">
<div class="sender-create-wizard__title">@CurrentStepTitle</div>
<div class="sender-create-wizard__description">@CurrentStepDescription</div>
</div>
<MudStep Title="@GetStepTitle(stepIndex)">
<div class="sender-create-wizard__stepper-description">@GetStepDescription(stepIndex)</div>
@if (stepIndex == _currentStep)
{
<div class="sender-create-wizard__step-content">
@if (_currentStep == 0)
{
<div class="sender-create-wizard__form-grid">
<div class="sender-create-wizard__field">
<label class="sender-create-wizard__label">
@L("Title", "Titel") <span class="sender-create-wizard__required">*</span>
</label>
<DxTextBox Text="@_title"
TextChanged="OnTitleChanged"
BindValueMode="BindValueMode.OnInput"
NullText='@L("SenderEditor_Title_Placeholder", "z.B. Arbeitsvertrag 2025")'
CssClass="w-100" />
</div>
<div class="sender-create-wizard__field">
<label class="sender-create-wizard__label">
@L("EnvelopeType", "Umschlagtyp") <span class="sender-create-wizard__required">*</span>
</label>
<select value="@(_selectedEnvelopeTypeId?.ToString() ?? string.Empty)"
@onchange="OnEnvelopeTypeChanged"
class="sender-create-wizard__select">
@foreach (var type in EnvelopeTypes)
{
<option value="@type.Id">@type.Title</option>
}
</select>
</div>
</div>
}
else if (_currentStep == 3)
{
<div class="sender-create-wizard__form-grid">
<div class="sender-create-wizard__field sender-create-wizard__field--full">
<label class="sender-create-wizard__label">
@L("Message", "Nachricht") <span class="sender-create-wizard__optional">(@L("Optional", "optional"))</span>
</label>
<DxMemo Text="@_message"
TextChanged="OnMessageChanged"
Rows="5"
MaxLength="500"
NullText='@L("SenderEditor_Message_Placeholder", "Bitte unterzeichnen Sie das beigefügte Dokument.")'
CssClass="w-100" />
</div>
<div class="sender-create-wizard__field">
<label class="sender-create-wizard__label">
@L("Language", "Sprache")
</label>
<select @bind="_language" class="sender-create-wizard__select">
<option value="de-DE">Deutsch (de-DE)</option>
<option value="en-US">English (en-US)</option>
<option value="de">Deutsch (de)</option>
<option value="en">English (en)</option>
</select>
</div>
</div>
}
else if (_currentStep == 2)
{
<div class="sender-create-wizard__settings-grid">
<div class="sender-create-wizard__settings-group">
<div class="sender-create-wizard__settings-title">
@L("SenderEditor_EnvelopeSettings", "Envelope-Einstellungen")
</div>
<div class="sender-create-wizard__form-grid">
<div class="sender-create-wizard__field">
<label class="sender-create-wizard__label">@L("CertificationType", "Zertifizierung")</label>
<select @bind="_certificationType" class="sender-create-wizard__select">
<option value="@CertificationType.AdvancedElectronicSignature">@L("AdvancedElectronicSignature", "Advanced Electronic Signature")</option>
<option value="@CertificationType.ElectronicSignature">@L("ElectronicSignature", "Electronic Signature")</option>
</select>
</div>
<div class="sender-create-wizard__field">
<label class="sender-create-wizard__label">@L("FinalEmailToCreator", "Finale E-Mail an Ersteller")</label>
<select @bind="_finalEmailToCreator" class="sender-create-wizard__select">
<option value="0">@L("No", "Nein")</option>
<option value="1">@L("Yes", "Ja")</option>
<option value="2">@L("YesWithAttachment", "Ja, mit Anhang")</option>
</select>
</div>
<div class="sender-create-wizard__field">
<label class="sender-create-wizard__label">@L("FinalEmailToReceivers", "Finale E-Mail an Empfänger")</label>
<select @bind="_finalEmailToReceivers" class="sender-create-wizard__select">
<option value="0">@L("No", "Nein")</option>
<option value="1">@L("Yes", "Ja")</option>
<option value="2">@L("YesWithAttachment", "Ja, mit Anhang")</option>
</select>
</div>
<div class="sender-create-wizard__field sender-create-wizard__field--full">
<div class="sender-create-wizard__settings-toggles">
<label class="sender-create-wizard__settings-toggle">
<input type="checkbox" checked="@_tfaEnabled" @onchange="OnTfaChanged" />
@L("TFAEnabled", "2FA aktiviert")
</label>
<label class="sender-create-wizard__settings-toggle">
<input type="checkbox" checked="@_useAccessCode" @onchange="OnUseAccessCodeChanged" disabled="@_tfaEnabled" />
@L("UseAccessCode", "Zugriffscode verwenden")
</label>
</div>
</div>
</div>
</div>
<div class="sender-create-wizard__settings-group">
<div class="sender-create-wizard__settings-title">
@L("SendReminderEmails", "Erinnerungs-E-Mails senden")
</div>
<div class="sender-create-wizard__form-grid">
<div class="sender-create-wizard__field sender-create-wizard__field--full">
<label class="sender-create-wizard__settings-toggle">
<input type="checkbox" checked="@_sendReminderEmails" @onchange="OnSendReminderEmailsChanged" />
@L("SendReminderEmails", "Erinnerungs-E-Mails senden")
</label>
</div>
<div class="sender-create-wizard__field">
<label class="sender-create-wizard__label">@L("FirstReminderDays", "Erste Erinnerung (Tage)")</label>
<InputNumber @bind-Value="_firstReminderDays" class="form-control form-control-sm" disabled="@(!_sendReminderEmails)" />
</div>
<div class="sender-create-wizard__field">
<label class="sender-create-wizard__label">@L("ReminderIntervalDays", "Erinnerungsintervall (Tage)")</label>
<InputNumber @bind-Value="_reminderIntervalDays" class="form-control form-control-sm" disabled="@(!_sendReminderEmails)" />
</div>
<div class="sender-create-wizard__field">
<label class="sender-create-wizard__label">@L("ExpiresWhenDays", "Läuft ab nach (Tage)")</label>
<InputNumber @bind-Value="_expiresWhenDays" class="form-control form-control-sm" />
</div>
<div class="sender-create-wizard__field">
<label class="sender-create-wizard__label">@L("ExpiresWarningWhenDays", "Ablaufwarnung (Tage)")</label>
<InputNumber @bind-Value="_expiresWarningWhenDays" class="form-control form-control-sm" />
</div>
</div>
</div>
</div>
}
else if (_currentStep == 1)
{
<div class="sender-create-wizard__suggestions">
<div class="sender-create-wizard__suggestions-title">@L("SenderWizard_RecentReceivers", "Zuletzt verwendete Empfänger")</div>
@if (_isLoadingRecentSuggestions)
{
<div class="sender-create-wizard__empty-list">@L("Loading", "Lädt...")</div>
}
else if (_recentReceiverSuggestions.Count > 0)
{
<div class="sender-create-wizard__suggestion-list" role="listbox" aria-label="Recent receivers">
@foreach (var suggestion in FilteredRecentSuggestions)
{
<button type="button"
class="sender-create-wizard__suggestion-item"
@onclick="() => ApplyRecentSuggestion(suggestion)"
disabled="@IsActionLocked">
<span class="sender-create-wizard__suggestion-name">@suggestion.FullName</span>
<span class="sender-create-wizard__suggestion-email">@suggestion.EmailAddress</span>
</button>
}
</div>
}
</div>
<div class="sender-create-wizard__form-grid">
<div class="sender-create-wizard__field">
<label class="sender-create-wizard__label">
@L("FullName", "Vor- und Nachname") <span class="sender-create-wizard__required">*</span>
</label>
<DxTextBox Text="@_receiverName"
TextChanged="OnReceiverNameChanged"
BindValueMode="BindValueMode.OnInput"
NullText="Max Mustermann"
CssClass="w-100" />
</div>
<div class="sender-create-wizard__field">
<label class="sender-create-wizard__label">
@L("EmailAddress", "E-Mail-Adresse") <span class="sender-create-wizard__required">*</span>
</label>
<DxTextBox Text="@_receiverEmail"
TextChanged="OnReceiverEmailChanged"
BindValueMode="BindValueMode.OnInput"
NullText="name@beispiel.de"
CssClass="w-100" />
</div>
</div>
<div class="sender-create-wizard__receiver-actions">
<button type="button"
class="sender-create-wizard__add-receiver-btn"
@onclick="AddReceiver"
disabled="@IsActionLocked">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
<path d="M8 4a.5.5 0 0 1 .5.5V7.5H11.5a.5.5 0 0 1 0 1H8.5v3a.5.5 0 0 1-1 0v-3H4.5a.5.5 0 0 1 0-1H7.5v-3A.5.5 0 0 1 8 4z" />
</svg>
<span>@L("AddReceiver", "Empfänger hinzufügen")</span>
</button>
</div>
@if (_receivers.Count == 0)
{
<div class="sender-create-wizard__empty-list">
@L("SenderEditor_NoReceiversAdded", "Noch keine Empfänger hinzugefügt.")
</div>
}
else
{
<div class="sender-create-wizard__receiver-tagbox-wrap">
<label class="sender-create-wizard__label" for="wizardReceiversTagBox">
@L("Receiver", "Empfänger")
</label>
<DxTagBox Data="@_receivers"
TData="CreateEnvelopeWizardReceiver"
TValue="CreateEnvelopeWizardReceiver"
AllowCustomTags="true"
@bind-Tags="@_receiverTags"
@bind-Tags:after="OnReceiverTagsChanged"
HideSelectedItems="false"
TextFieldName="@nameof(CreateEnvelopeWizardReceiver.EmailAddress)"
ListRenderMode="ListRenderMode.Virtual"
SearchMode="ListSearchMode.AutoSearch"
SearchFilterCondition="ListSearchFilterCondition.Contains"
SearchTextParseMode="ListSearchTextParseMode.GroupWordsByAnd"
CssClass="sender-create-wizard__receiver-tagbox"
InputId="wizardReceiversTagBox"
Enabled="@(!IsActionLocked)">
<TagDisplayTemplate Context="tagContext">
<div class="sender-create-wizard__receiver-tag">
@if (tagContext.DataItem is CreateEnvelopeWizardReceiver receiver)
{
<span class="sender-create-wizard__receiver-tag-name">@receiver.FullName</span>
<span class="sender-create-wizard__receiver-tag-email">@receiver.EmailAddress</span>
}
else
{
<span class="sender-create-wizard__receiver-tag-email">@tagContext.DisplayText</span>
}
<button type="button"
class="sender-create-wizard__receiver-tag-remove"
@onclick="tagContext.RemoveTagAction"
aria-label="Remove receiver"
disabled="@IsActionLocked">x</button>
</div>
</TagDisplayTemplate>
<EmptyDataAreaTemplate Context="emptyContext">
<div class="sender-create-wizard__tagbox-empty"></div>
</EmptyDataAreaTemplate>
</DxTagBox>
</div>
}
}
else
{
<div class="sender-create-wizard__upload-stage">
<label class="sender-create-wizard__upload-btn @(IsActionLocked ? "sender-create-wizard__upload-btn--disabled" : string.Empty)">
@if (_documentBytes is null)
{
<span>@L("UploadPdf", "PDF hochladen")</span>
}
else
{
<span>@L("SenderWizard_ReplacePdf", "PDF ersetzen")</span>
}
<InputFile OnChange="OnDocumentSelectedAsync"
accept=".pdf"
disabled="@IsActionLocked"
class="sender-create-wizard__hidden-input" />
</label>
@if (_documentBytes is not null)
{
<div class="sender-create-wizard__document-preview-card">
<div class="sender-create-wizard__document-meta">
<div class="sender-create-wizard__document-name">@_documentFileName</div>
<div class="sender-create-wizard__document-size">@_documentSizeText</div>
</div>
<div class="sender-create-wizard__document-preview-wrap">
<SenderPdfImageEditor IsPlacementMode="false"
PageCount="@_documentPreviewPageCount"
Pages="@_documentPreviewPages"
PdfDataUrl="@DocumentPreviewDataUrl"
PdfFileName="@_documentFileName"
Fields="@_documentPreviewFieldOverlays"
EditorClick="OnDocumentPreviewEditorClickAsync"
RemoveField="OnDocumentPreviewRemoveFieldAsync"
MoveField="OnDocumentPreviewMoveFieldAsync" />
</div>
</div>
}
else
{
<PdfFileDropZone Disabled="@IsActionLocked"
HasDocument="false"
UploadText='@L("SenderWizard_DocumentRequiredHint", "Bitte laden Sie ein PDF-Dokument hoch, um fortzufahren.")'
DragDropHintText='@L("SenderWizard_DragDropPdfHint", "Oder ziehen Sie die PDF-Datei hierher.")'
FileSelected="OnDocumentSelectedAsync" />
}
</div>
}
</div>
}
</MudStep>
}
</ChildContent>
</MudStepper>
</MudPaper>
@if (!string.IsNullOrWhiteSpace(_validationMessage))
{
<div class="sender-create-wizard__validation">@_validationMessage</div>
}
@if (_currentStep == 0)
{
<div class="sender-create-wizard__form-grid">
<div class="sender-create-wizard__field">
<label class="sender-create-wizard__label">
@L("Title", "Titel") <span class="sender-create-wizard__required">*</span>
</label>
<DxTextBox Text="@_title"
TextChanged="OnTitleChanged"
BindValueMode="BindValueMode.OnInput"
NullText='@L("SenderEditor_Title_Placeholder", "z.B. Arbeitsvertrag 2025")'
CssClass="w-100" />
</div>
<div class="sender-create-wizard__field">
<label class="sender-create-wizard__label">
@L("EnvelopeType", "Umschlagtyp") <span class="sender-create-wizard__required">*</span>
</label>
<select value="@(_selectedEnvelopeTypeId?.ToString() ?? string.Empty)"
@onchange="OnEnvelopeTypeChanged"
class="sender-create-wizard__select">
@foreach (var type in EnvelopeTypes)
{
<option value="@type.Id">@type.Title</option>
}
</select>
</div>
</div>
}
else if (_currentStep == 1)
{
<div class="sender-create-wizard__form-grid">
<div class="sender-create-wizard__field sender-create-wizard__field--full">
<label class="sender-create-wizard__label">
@L("Message", "Nachricht") <span class="sender-create-wizard__optional">(@L("Optional", "optional"))</span>
</label>
<DxMemo Text="@_message"
TextChanged="OnMessageChanged"
Rows="5"
MaxLength="500"
NullText='@L("SenderEditor_Message_Placeholder", "Bitte unterzeichnen Sie das beigefügte Dokument.")'
CssClass="w-100" />
</div>
<div class="sender-create-wizard__field">
<label class="sender-create-wizard__label">
@L("Language", "Sprache")
</label>
<select @bind="_language" class="sender-create-wizard__select">
<option value="de-DE">Deutsch (de-DE)</option>
<option value="en-US">English (en-US)</option>
<option value="de">Deutsch (de)</option>
<option value="en">English (en)</option>
</select>
</div>
</div>
}
else if (_currentStep == 2)
{
<div class="sender-create-wizard__settings-grid">
<div class="sender-create-wizard__settings-group">
<div class="sender-create-wizard__settings-title">
@L("SenderEditor_EnvelopeSettings", "Envelope-Einstellungen")
</div>
<div class="sender-create-wizard__form-grid">
<div class="sender-create-wizard__field">
<label class="sender-create-wizard__label">@L("CertificationType", "Zertifizierung")</label>
<select @bind="_certificationType" class="sender-create-wizard__select">
<option value="1">@L("AdvancedElectronicSignature", "Advanced Electronic Signature")</option>
<option value="2">@L("ElectronicSignature", "Electronic Signature")</option>
<option value="3">@L("QualifiedSignature", "Qualified Signature")</option>
</select>
</div>
<div class="sender-create-wizard__field">
<label class="sender-create-wizard__label">@L("FinalEmailToCreator", "Finale E-Mail an Ersteller")</label>
<select @bind="_finalEmailToCreator" class="sender-create-wizard__select">
<option value="0">@L("No", "Nein")</option>
<option value="1">@L("Yes", "Ja")</option>
<option value="2">@L("YesWithAttachment", "Ja, mit Anhang")</option>
</select>
</div>
<div class="sender-create-wizard__field">
<label class="sender-create-wizard__label">@L("FinalEmailToReceivers", "Finale E-Mail an Empfänger")</label>
<select @bind="_finalEmailToReceivers" class="sender-create-wizard__select">
<option value="0">@L("No", "Nein")</option>
<option value="1">@L("Yes", "Ja")</option>
<option value="2">@L("YesWithAttachment", "Ja, mit Anhang")</option>
</select>
</div>
<div class="sender-create-wizard__field sender-create-wizard__field--full">
<div class="sender-create-wizard__settings-toggles">
<label class="sender-create-wizard__settings-toggle">
<input type="checkbox" checked="@_tfaEnabled" @onchange="OnTfaChanged" />
@L("TFAEnabled", "2FA aktiviert")
</label>
<label class="sender-create-wizard__settings-toggle">
<input type="checkbox" checked="@_useAccessCode" @onchange="OnUseAccessCodeChanged" disabled="@_tfaEnabled" />
@L("UseAccessCode", "Zugriffscode verwenden")
</label>
</div>
</div>
</div>
</div>
<div class="sender-create-wizard__settings-group">
<div class="sender-create-wizard__settings-title">
@L("SendReminderEmails", "Erinnerungs-E-Mails senden")
</div>
<div class="sender-create-wizard__form-grid">
<div class="sender-create-wizard__field sender-create-wizard__field--full">
<label class="sender-create-wizard__settings-toggle">
<input type="checkbox" checked="@_sendReminderEmails" @onchange="OnSendReminderEmailsChanged" />
@L("SendReminderEmails", "Erinnerungs-E-Mails senden")
</label>
</div>
<div class="sender-create-wizard__field">
<label class="sender-create-wizard__label">@L("FirstReminderDays", "Erste Erinnerung (Tage)")</label>
<InputNumber @bind-Value="_firstReminderDays" class="form-control form-control-sm" disabled="@(!_sendReminderEmails)" />
</div>
<div class="sender-create-wizard__field">
<label class="sender-create-wizard__label">@L("ReminderIntervalDays", "Erinnerungsintervall (Tage)")</label>
<InputNumber @bind-Value="_reminderIntervalDays" class="form-control form-control-sm" disabled="@(!_sendReminderEmails)" />
</div>
<div class="sender-create-wizard__field">
<label class="sender-create-wizard__label">@L("ExpiresWhenDays", "Läuft ab nach (Tage)")</label>
<InputNumber @bind-Value="_expiresWhenDays" class="form-control form-control-sm" />
</div>
<div class="sender-create-wizard__field">
<label class="sender-create-wizard__label">@L("ExpiresWarningWhenDays", "Ablaufwarnung (Tage)")</label>
<InputNumber @bind-Value="_expiresWarningWhenDays" class="form-control form-control-sm" />
</div>
</div>
</div>
</div>
}
else if (_currentStep == 3)
{
<div class="sender-create-wizard__suggestions">
<div class="sender-create-wizard__suggestions-title">@L("SenderWizard_RecentReceivers", "Zuletzt verwendete Empfänger")</div>
@if (_isLoadingRecentSuggestions)
{
<div class="sender-create-wizard__empty-list">@L("Loading", "Lädt...")</div>
}
else if (_recentReceiverSuggestions.Count > 0)
{
<div class="sender-create-wizard__suggestion-list" role="listbox" aria-label="Recent receivers">
@foreach (var suggestion in FilteredRecentSuggestions)
{
<button type="button"
class="sender-create-wizard__suggestion-item"
@onclick="() => ApplyRecentSuggestion(suggestion)"
disabled="@IsActionLocked">
<span class="sender-create-wizard__suggestion-name">@suggestion.FullName</span>
<span class="sender-create-wizard__suggestion-email">@suggestion.EmailAddress</span>
</button>
}
</div>
}
</div>
<div class="sender-create-wizard__form-grid">
<div class="sender-create-wizard__field">
<label class="sender-create-wizard__label">
@L("FullName", "Vor- und Nachname") <span class="sender-create-wizard__required">*</span>
</label>
<DxTextBox Text="@_receiverName"
TextChanged="OnReceiverNameChanged"
BindValueMode="BindValueMode.OnInput"
NullText="Max Mustermann"
CssClass="w-100" />
</div>
<div class="sender-create-wizard__field">
<label class="sender-create-wizard__label">
@L("EmailAddress", "E-Mail-Adresse") <span class="sender-create-wizard__required">*</span>
</label>
<DxTextBox Text="@_receiverEmail"
TextChanged="OnReceiverEmailChanged"
BindValueMode="BindValueMode.OnInput"
NullText="name@beispiel.de"
CssClass="w-100" />
</div>
</div>
<div class="sender-create-wizard__receiver-actions">
<button type="button"
class="sender-create-wizard__add-receiver-btn"
@onclick="AddReceiver"
disabled="@IsActionLocked">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
<path d="M8 4a.5.5 0 0 1 .5.5V7.5H11.5a.5.5 0 0 1 0 1H8.5v3a.5.5 0 0 1-1 0v-3H4.5a.5.5 0 0 1 0-1H7.5v-3A.5.5 0 0 1 8 4z" />
</svg>
<span>@L("AddReceiver", "Empfänger hinzufügen")</span>
</button>
</div>
@if (_receivers.Count == 0)
{
<div class="sender-create-wizard__empty-list">
@L("SenderEditor_NoReceiversAdded", "Noch keine Empfänger hinzugefügt.")
</div>
}
else
{
<div class="sender-create-wizard__receiver-tagbox-wrap">
<label class="sender-create-wizard__label" for="wizardReceiversTagBox">
@L("Receiver", "Empfänger")
</label>
<DxTagBox Data="@_receivers"
TData="CreateEnvelopeWizardReceiver"
TValue="CreateEnvelopeWizardReceiver"
AllowCustomTags="true"
@bind-Tags="@_receiverTags"
@bind-Tags:after="OnReceiverTagsChanged"
HideSelectedItems="false"
TextFieldName="@nameof(CreateEnvelopeWizardReceiver.EmailAddress)"
ListRenderMode="ListRenderMode.Virtual"
SearchMode="ListSearchMode.AutoSearch"
SearchFilterCondition="ListSearchFilterCondition.Contains"
SearchTextParseMode="ListSearchTextParseMode.GroupWordsByAnd"
CssClass="sender-create-wizard__receiver-tagbox"
InputId="wizardReceiversTagBox"
Enabled="@(!IsActionLocked)">
<TagDisplayTemplate Context="tagContext">
<div class="sender-create-wizard__receiver-tag">
@if (tagContext.DataItem is CreateEnvelopeWizardReceiver receiver)
{
<span class="sender-create-wizard__receiver-tag-name">@receiver.FullName</span>
<span class="sender-create-wizard__receiver-tag-email">@receiver.EmailAddress</span>
}
else
{
<span class="sender-create-wizard__receiver-tag-email">@tagContext.DisplayText</span>
}
<button type="button"
class="sender-create-wizard__receiver-tag-remove"
@onclick="tagContext.RemoveTagAction"
aria-label="Remove receiver"
disabled="@IsActionLocked">x</button>
</div>
</TagDisplayTemplate>
<EmptyDataAreaTemplate Context="emptyContext">
<div class="sender-create-wizard__tagbox-empty"></div>
</EmptyDataAreaTemplate>
</DxTagBox>
</div>
}
}
else
{
<div class="sender-create-wizard__upload-stage">
<label class="sender-create-wizard__upload-btn @(IsActionLocked ? "sender-create-wizard__upload-btn--disabled" : string.Empty)">
@if (_documentBytes is null)
{
<span>@L("UploadPdf", "PDF hochladen")</span>
}
else
{
<span>@L("SenderWizard_ReplacePdf", "PDF ersetzen")</span>
}
<InputFile OnChange="OnDocumentSelectedAsync"
accept=".pdf"
disabled="@IsActionLocked"
class="sender-create-wizard__hidden-input" />
</label>
@if (_documentBytes is not null)
{
<div class="sender-create-wizard__document-preview-card">
<div class="sender-create-wizard__document-meta">
<div class="sender-create-wizard__document-name">@_documentFileName</div>
<div class="sender-create-wizard__document-size">@_documentSizeText</div>
</div>
<div class="sender-create-wizard__document-preview-wrap">
<SenderPdfImageEditor IsPlacementMode="false"
PageCount="@_documentPreviewPageCount"
Pages="@_documentPreviewPages"
PdfDataUrl="@DocumentPreviewDataUrl"
PdfFileName="@_documentFileName"
Fields="@_documentPreviewFieldOverlays"
EditorClick="OnDocumentPreviewEditorClickAsync"
RemoveField="OnDocumentPreviewRemoveFieldAsync"
MoveField="OnDocumentPreviewMoveFieldAsync" />
</div>
</div>
}
else
{
<PdfFileDropZone Disabled="@IsActionLocked"
HasDocument="false"
UploadText='@L("SenderWizard_DocumentRequiredHint", "Bitte laden Sie ein PDF-Dokument hoch, um fortzufahren.")'
DragDropHintText='@L("SenderWizard_DragDropPdfHint", "Oder ziehen Sie die PDF-Datei hierher.")'
FileSelected="OnDocumentSelectedAsync" />
}
</div>
}
</div>
</BodyContentTemplate>
<FooterContentTemplate>
@@ -421,7 +429,7 @@
private string _language = "de-DE";
private bool _useAccessCode;
private bool _tfaEnabled;
private int _certificationType = (int)CertificationType.AdvancedElectronicSignature;
private CertificationType _certificationType = CertificationType.AdvancedElectronicSignature;
private int _finalEmailToCreator = (int)FinalEmailType.No;
private int _finalEmailToReceivers = (int)FinalEmailType.No;
private bool _sendReminderEmails;
@@ -457,7 +465,7 @@
private bool IsFirstStep => _currentStep <= 0;
private bool IsLastStep => _currentStep >= TotalSteps - 1;
private bool CanSkipCurrentStep => _currentStep == 1;
private bool CanSkipCurrentStep => _currentStep == 3;
private IReadOnlyList<RecentReceiverSuggestionDto> FilteredRecentSuggestions =>
BuildFilteredRecentSuggestions();
@@ -466,22 +474,26 @@
? $"data:application/pdf;base64,{Convert.ToBase64String(_documentBytes)}"
: null;
private string CurrentStepTitle => _currentStep switch
private string CurrentStepTitle => GetStepTitle(_currentStep);
private string CurrentStepDescription => GetStepDescription(_currentStep);
private string GetStepTitle(int step) => step switch
{
0 => L("SenderWizard_Step_Basics", "Grunddaten"),
1 => L("SenderWizard_Step_Message", "Nachricht und Optionen"),
1 => L("SenderWizard_Step_Receivers", "Empfänger"),
2 => L("SenderEditor_EnvelopeSettings", "Envelope-Einstellungen"),
3 => L("SenderWizard_Step_Receivers", "Empfänger"),
3 => L("SenderWizard_Step_Message", "Nachricht und Optionen"),
4 => L("SenderWizard_Step_Document", "Dokument"),
_ => L("SenderWizard_Step_Basics", "Grunddaten")
};
private string CurrentStepDescription => _currentStep switch
private string GetStepDescription(int step) => step switch
{
0 => L("SenderWizard_Step_Basics_Desc", "Starten Sie mit den Pflichtangaben für den Umschlag."),
1 => L("SenderWizard_Step_Message_Desc", "Fügen Sie optionale Nachricht und Einstellungen hinzu."),
1 => L("SenderWizard_Step_Receivers_Desc", "Definieren Sie die Empfänger für den Signaturprozess."),
2 => L("SenderWizard_Step_Settings_Desc", "Konfigurieren Sie Sicherheits-, Erinnerungs- und Ablaufoptionen."),
3 => L("SenderWizard_Step_Receivers_Desc", "Definieren Sie die Empfänger für den Signaturprozess."),
3 => L("SenderWizard_Step_Message_Desc", "Fügen Sie optionale Nachricht und Einstellungen hinzu."),
4 => L("SenderWizard_Step_Document_Desc", "Laden Sie das Dokument hoch und platzieren Sie Signaturfelder."),
_ => L("SenderWizard_Step_Basics_Desc", "Starten Sie mit den Pflichtangaben für den Umschlag.")
};
@@ -704,7 +716,7 @@
{
_currentStep++;
if (_currentStep == 3)
if (_currentStep == 1)
await EnsureRecentSuggestionsLoadedAsync();
}
}
@@ -798,10 +810,10 @@
if (!string.Equals(args.Key, "Enter", StringComparison.OrdinalIgnoreCase))
return;
if (_currentStep == 1)
if (_currentStep == 3)
return;
if (_currentStep == 3 && (!string.IsNullOrWhiteSpace(_receiverName) || !string.IsNullOrWhiteSpace(_receiverEmail)))
if (_currentStep == 1 && (!string.IsNullOrWhiteSpace(_receiverName) || !string.IsNullOrWhiteSpace(_receiverEmail)))
{
await AddReceiver();
return;
@@ -895,7 +907,7 @@
return true;
}
if (_currentStep == 3)
if (_currentStep == 1)
{
if (_receivers.Count == 0)
{
@@ -1116,7 +1128,7 @@
_language = "de-DE";
_useAccessCode = false;
_tfaEnabled = false;
_certificationType = (int)CertificationType.AdvancedElectronicSignature;
_certificationType = CertificationType.AdvancedElectronicSignature;
_finalEmailToCreator = (int)FinalEmailType.No;
_finalEmailToReceivers = (int)FinalEmailType.No;
_sendReminderEmails = false;

View File

@@ -1,3 +1,5 @@
using EnvelopeGenerator.Domain.Constants;
namespace EnvelopeGenerator.Server.Client.Components.Sender;
public sealed record CreateEnvelopeWizardResult(
@@ -7,7 +9,7 @@ public sealed record CreateEnvelopeWizardResult(
string Language,
bool UseAccessCode,
bool TFAEnabled,
int CertificationType,
CertificationType CertificationType,
int FinalEmailToCreator,
int FinalEmailToReceivers,
bool SendReminderEmails,

View File

@@ -12,6 +12,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MudBlazor" Version="8.0.0" />
<PackageReference Include="DevExpress.Blazor.PdfViewer" Version="25.2.3" />
<PackageReference Include="DevExpress.Blazor.Reporting.JSBasedControls" Version="25.2.3" />
<PackageReference Include="DevExpress.Blazor.Reporting.Viewer" Version="25.2.3" />

View File

@@ -7,11 +7,12 @@
@using EnvelopeGenerator.Application.Resources
@using EnvelopeGenerator.Domain.Constants
@using EnvelopeGenerator.Domain.Resources
@using EnvelopeGenerator.Application.Common.Configurations
@using EnvelopeGenerator.Server.Client.Components.PdfEditor
@using EnvelopeGenerator.Server.Client.Services
@using Microsoft.AspNetCore.WebUtilities
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.Extensions.Localization
@using Microsoft.Extensions.Options
@inject IJSRuntime JSRuntime
@inject NavigationManager NavigationManager
@inject AppVersionService AppVersion
@@ -19,23 +20,19 @@
@inject DocumentService DocumentService
@inject PdfRenderService PdfRenderService
@inject ReceiverService ReceiverService
@inject EnvelopeReceiverService EnvelopeReceiverService
@inject EnvelopeService EnvelopeService
@inject EnvelopeTypeService EnvelopeTypeService
@inject CreateEnvelopeBootstrapStorageService CreateEnvelopeBootstrapStorageService
@inject AuthService AuthService
@inject IStringLocalizer<Resource> UiLocalizer
@inject IStringLocalizer<Model> ModelLocalizer
@inject IOptionsMonitor<SignatureFieldParams> SignatureFieldOptions
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
<link href="_content/DevExpress.Blazor.Viewer/css/dx-blazor-viewer-components.bs5.css" rel="stylesheet" />
<link href="@AppVersion.GetVersionedUrl("css/envelope-viewer.css")" rel="stylesheet" />
<script src="@AppVersion.GetVersionedUrl("js/envelope-editor.js")"></script>
<DxLoadingPanel Visible="@IsBootstrapLoading"
IsContentBlocked="true"
ApplyBackgroundShading="true"
IndicatorAreaVisible="true"
Text='@L("SenderEditor_BootstrapLoadingHint", "Der Umschlag wird vorbereitet und die Daten werden uebernommen.")'>
<div class="envelope-viewer-layout">
@* ── Action Bar ── *@
@@ -126,9 +123,8 @@
<div class="sender-settings-field">
<label class="sender-settings-label">@L("CertificationType", "Zertifizierung")</label>
<select @bind="_certificationType" class="sender-settings-select">
<option value="1">@L("AdvancedElectronicSignature", "Advanced Electronic Signature")</option>
<option value="2">@L("ElectronicSignature", "Electronic Signature")</option>
<option value="3">@L("QualifiedSignature", "Qualified Signature")</option>
<option value="@CertificationType.AdvancedElectronicSignature">@L("AdvancedElectronicSignature", "Advanced Electronic Signature")</option>
<option value="@CertificationType.ElectronicSignature">@L("ElectronicSignature", "Electronic Signature")</option>
</select>
</div>
@@ -196,20 +192,43 @@
</div>
<div class="sender-receivers-panel">
<div class="sender-receivers-panel__header">
<div class="sender-receivers-panel__title-wrap">
<span class="sender-receivers-panel__title">@L("Receiver", "Empfänger")</span>
<span class="sender-receivers-panel__count">@_receivers.Count</span>
</div>
<button class="pdf-toolbar__btn pdf-toolbar__btn--signature-change sender-toolbar-action-btn"
<div class="sender-receivers-list">
<button class="pdf-toolbar__btn pdf-toolbar__btn--signature-change sender-toolbar-action-btn sender-receivers-list__add-btn"
@onclick="OpenAddReceiverPopup"
title='@L("AddReceiver", "Empfänger hinzufügen")'>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
<path d="M8 1a.5.5 0 0 1 .5.5v6h6a.5.5 0 0 1 0 1h-6v6a.5.5 0 0 1-1 0v-6h-6a.5.5 0 0 1 0-1h6v-6A.5.5 0 0 1 8 1z" />
</svg>
<span class="pdf-toolbar__btn-text">@L("AddReceiver", "Empfänger hinzufügen")</span>
<span class="sender-receivers-list__add-text">@L("AddReceiver", "Empfänger hinzufügen")</span>
<span class="badge rounded-pill text-bg-primary sender-receivers-list__count">@_receivers.Count</span>
</button>
@foreach (var receiver in _receivers)
{
<div class="sender-receiver-chip" style="--receiver-color:@receiver.Color;">
<div class="sender-receiver-chip__body">
<div class="sender-receiver-chip__email" title="@receiver.FullName">@receiver.Email</div>
</div>
<button class="pdf-toolbar__btn pdf-toolbar__btn--reset sender-toolbar-action-btn sender-toolbar-action-btn--compact sender-receiver-chip__action sender-receiver-chip__remove-btn"
@onclick="() => RemoveReceiverAsync(receiver)"
title='@L("Delete", "Loeschen")'>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
<path d="M4 4L12 12M12 4L4 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<button class="pdf-toolbar__btn pdf-toolbar__btn--signature-change sender-toolbar-action-btn sender-toolbar-action-btn--compact sender-receiver-chip__action sender-receiver-chip__place-btn
@(_pendingReceiverForPlacement?.Id == receiver.Id ? "sender-receiver-chip__place-btn--active" : "sender-receiver-chip__place-btn--idle")"
@onclick="() => ActivatePlacementForReceiver(receiver)"
style="--receiver-color:@receiver.Color;"
title='@L("SenderEditor_PlaceSignatureField", "Signaturfeld platzieren")'>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" 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-10z" />
</svg>
</button>
</div>
}
</div>
@if (_receivers.Count == 0)
@@ -218,35 +237,6 @@
@L("SenderEditor_NoReceiversAdded", "Noch keine Empfänger hinzugefügt.")
</div>
}
else
{
<div class="sender-receivers-list">
@foreach (var receiver in _receivers)
{
<div class="sender-receiver-chip" style="--receiver-color:@receiver.Color;">
<div class="sender-receiver-chip__body">
<div class="sender-receiver-chip__name">@receiver.FullName</div>
<div class="sender-receiver-chip__email">@receiver.Email</div>
@if (!string.IsNullOrWhiteSpace(receiver.PhoneNumber))
{
<div class="sender-receiver-chip__phone">@receiver.PhoneNumber</div>
}
</div>
<button class="pdf-toolbar__btn pdf-toolbar__btn--signature-change sender-toolbar-action-btn sender-toolbar-action-btn--compact sender-receiver-chip__place-btn
@(_pendingReceiverForPlacement?.Id == receiver.Id ? "sender-receiver-chip__place-btn--active" : "sender-receiver-chip__place-btn--idle")"
@onclick="() => ActivatePlacementForReceiver(receiver)"
style="--receiver-color:@receiver.Color;"
title='@L("SenderEditor_PlaceSignatureField", "Signaturfeld platzieren")'>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" 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-10z" />
</svg>
<span class="pdf-toolbar__btn-text">@L("SenderEditor_AddSignature", "Signatur hinzufügen")</span>
</button>
</div>
}
</div>
}
</div>
</div>
@@ -431,7 +421,6 @@
}
</div>
</div>
</DxLoadingPanel>
<DxPopup @bind-Visible="_receiverPopupVisible"
HeaderText='@L("AddReceiver", "Empfänger hinzufügen")'
@@ -596,13 +585,11 @@
[SupplyParameterFromQuery(Name = "envelopeId")]
public int? EnvelopeId { get; set; }
[SupplyParameterFromQuery(Name = "bootstrapToken")]
public string? BootstrapToken { get; set; }
// ── Constants ──
// Signature field size in PDF points (fixed): 1.77" × 1.96"
const double SigWidthPt = 1.77 * 72; // 127.44 pt
const double SigHeightPt = 1.96 * 72; // 141.12 pt
// ── Signature field size (configurable) ──
double SigWidthInches => SignatureFieldOptions.CurrentValue.Width;
double SigHeightInches => SignatureFieldOptions.CurrentValue.Height;
double SigWidthPt => SigWidthInches * 72.0;
double SigHeightPt => SigHeightInches * 72.0;
// CssClass for JS click coordinate helper
const string ViewerCssClass = "sender-editor-pdf-viewer";
@@ -617,10 +604,7 @@
int _pageCount;
List<PdfPageImageDto> _pagePreviews = [];
int? _loadedEnvelopeId;
int? _editingEnvelopeId;
bool _isLoadingEnvelopeForEdit;
bool _bootstrapApplied;
string? _pendingBootstrapToken;
List<SignatureFieldDraft> _signatureFields = [];
ReceiverDraft? _pendingReceiverForPlacement; // Set when user clicks "Signatur hinzufügen"
@@ -641,7 +625,6 @@
string _saveSuccessTitle = "Vorgang erfolgreich abgeschlossen.";
string _saveSuccessBody = string.Empty;
bool _saveNavigateToDashboard = false;
bool _saveDraftAfterBootstrap;
// ── Envelope metadata ──
string _envelopeTitle = string.Empty;
@@ -653,7 +636,7 @@
string _language = "de-DE";
bool _useAccessCode;
bool _tfaEnabled;
int _certificationType = (int)CertificationType.AdvancedElectronicSignature;
CertificationType _certificationType = CertificationType.AdvancedElectronicSignature;
int _finalEmailToCreator = (int)FinalEmailType.No;
int _finalEmailToReceivers = (int)FinalEmailType.No;
bool _sendReminderEmails;
@@ -675,9 +658,6 @@
static readonly System.ComponentModel.DataAnnotations.EmailAddressAttribute ReceiverEmailValidator = new();
bool IsEditMode => EnvelopeId.HasValue && EnvelopeId.Value > 0;
bool IsBootstrapLoading => !IsEditMode && !_bootstrapApplied;
// ── Lifecycle ──
protected override async Task OnInitializedAsync()
@@ -692,12 +672,6 @@
try
{
_envelopeTypes = (await EnvelopeTypeService.GetAsync()).ToList();
if (!IsEditMode && _selectedEnvelopeTypeId is null && _envelopeTypes.Count > 0)
{
_selectedEnvelopeTypeId = _envelopeTypes[0].Id;
ApplyEnvelopeTypeDefaults(_envelopeTypes[0]);
}
}
catch (Exception ex)
{
@@ -708,95 +682,14 @@
protected override async Task OnParametersSetAsync()
{
if (EnvelopeId.HasValue && EnvelopeId.Value > 0)
_editingEnvelopeId = EnvelopeId.Value;
if (IsEditMode && _loadedEnvelopeId != EnvelopeId)
{
await LoadEnvelopeForEditAsync(EnvelopeId!.Value);
return;
}
if (IsEditMode || _bootstrapApplied)
return;
if (string.IsNullOrWhiteSpace(BootstrapToken))
if (!EnvelopeId.HasValue || EnvelopeId.Value <= 0)
{
NavigationManager.NavigateTo("/sender", replace: true);
return;
}
_pendingBootstrapToken = BootstrapToken;
}
async Task TryApplyBootstrapAsync(string token)
{
var payload = await CreateEnvelopeBootstrapStorageService.ConsumeAsync(token);
_bootstrapApplied = true;
if (payload is null)
{
NavigationManager.NavigateTo("/sender", replace: true);
return;
}
_envelopeTitle = payload.Title ?? string.Empty;
_envelopeMessage = payload.Message ?? string.Empty;
_selectedEnvelopeTypeId = payload.EnvelopeTypeId;
_language = string.IsNullOrWhiteSpace(payload.Language) ? "de-DE" : payload.Language;
_useAccessCode = payload.UseAccessCode;
_tfaEnabled = payload.TFAEnabled;
_certificationType = payload.CertificationType;
_finalEmailToCreator = payload.FinalEmailToCreator;
_finalEmailToReceivers = payload.FinalEmailToReceivers;
_sendReminderEmails = payload.SendReminderEmails;
_firstReminderDays = payload.FirstReminderDays;
_reminderIntervalDays = payload.ReminderIntervalDays;
_expiresWhenDays = payload.ExpiresWhenDays;
_expiresWarningWhenDays = payload.ExpiresWarningWhenDays;
NormalizeSettings();
_receivers = payload.Receivers
.Select((receiver, index) => new ReceiverDraft(
Guid.NewGuid(),
receiver.FullName,
receiver.EmailAddress,
string.Empty,
ReceiverPalette[index % ReceiverPalette.Length]))
.ToList();
_originalPdfBytes = payload.DocumentBytes;
_fileName = string.IsNullOrWhiteSpace(payload.DocumentFileName) ? "document.pdf" : payload.DocumentFileName;
_pdfLoaded = _originalPdfBytes is { Length: > 0 };
_signatureFields.Clear();
_pendingReceiverForPlacement = null;
_saveDraftAfterBootstrap = true;
if (_pdfLoaded && _originalPdfBytes is not null)
{
try
{
_pagePreviews = (await PdfRenderService.RenderPagesAsync(_originalPdfBytes))
.OrderBy(p => p.PageNumber)
.ToList();
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to render bootstrap PDF previews.");
_pagePreviews = [];
}
_pageCount = _pagePreviews.Count > 0
? _pagePreviews.Count
: EstimatePdfPageCount(_originalPdfBytes) ?? 1;
}
else
{
_pagePreviews = [];
_pageCount = 0;
}
if (_loadedEnvelopeId != EnvelopeId && !_isLoadingEnvelopeForEdit)
await LoadEnvelopeForEditAsync(EnvelopeId.Value);
}
protected override async Task OnAfterRenderAsync(bool firstRender)
@@ -806,26 +699,6 @@
_isPageReady = true;
await InvokeAsync(StateHasChanged);
}
if (!OperatingSystem.IsBrowser())
return;
if (!IsEditMode && !_bootstrapApplied && !string.IsNullOrWhiteSpace(_pendingBootstrapToken))
{
var token = _pendingBootstrapToken;
_pendingBootstrapToken = null;
await TryApplyBootstrapAsync(token);
await InvokeAsync(StateHasChanged);
return;
}
if (_saveDraftAfterBootstrap && !_isSaving)
{
_saveDraftAfterBootstrap = false;
await SaveDraftAndStayOnEditorAsync();
return;
}
}
async Task LoadEnvelopeForEditAsync(int envelopeId)
@@ -853,7 +726,7 @@
if (_tfaEnabled)
_useAccessCode = true;
_certificationType = envelope.CertificationType ?? (int)CertificationType.AdvancedElectronicSignature;
_certificationType = envelope.CertificationType ?? CertificationType.AdvancedElectronicSignature;
_finalEmailToCreator = envelope.FinalEmailToCreator ?? (int)FinalEmailType.No;
_finalEmailToReceivers = envelope.FinalEmailToReceivers ?? (int)FinalEmailType.No;
@@ -869,12 +742,17 @@
_receivers = envelopeReceivers
.Select((er, index) => new ReceiverDraft(
Guid.NewGuid(),
er.ReceiverId,
er.Name ?? er.Receiver?.EmailAddress ?? $"Receiver {er.ReceiverId}",
er.Receiver?.EmailAddress ?? string.Empty,
string.Empty,
ReceiverPalette[index % ReceiverPalette.Length]))
.ToList();
var receiverDraftByReceiverId = _receivers
.Where(r => r.ReceiverId.HasValue)
.ToDictionary(r => r.ReceiverId!.Value, r => r);
var receiverById = envelopeReceivers.ToDictionary(er => er.ReceiverId, er => er);
var colorByEmail = _receivers
.Where(r => !string.IsNullOrWhiteSpace(r.Email))
@@ -896,10 +774,17 @@
? receiverColor
: ReceiverPalette[Math.Abs(element.ReceiverId) % ReceiverPalette.Length];
var receiverDraft = receiverDraftByReceiverId.TryGetValue(element.ReceiverId, out var draft)
? draft
: _receivers.FirstOrDefault(r =>
(!string.IsNullOrWhiteSpace(receiverEmail) && string.Equals(r.Email, receiverEmail, StringComparison.OrdinalIgnoreCase))
|| string.Equals(r.FullName, receiverName, StringComparison.OrdinalIgnoreCase));
return new SignatureFieldDraft(
XPt: element.X * 72.0,
YPt: element.Y * 72.0,
Page: element.Page,
ReceiverDraftId: receiverDraft?.Id ?? Guid.Empty,
ReceiverName: receiverName,
Color: color);
})
@@ -935,7 +820,6 @@
_fileName = $"Envelope-{envelopeId}.pdf";
_pendingReceiverForPlacement = null;
_loadedEnvelopeId = envelopeId;
_editingEnvelopeId = envelopeId;
}
catch (Exception ex)
{
@@ -1135,6 +1019,7 @@
XPt: xPt,
YPt: yPt,
Page: page1Based,
ReceiverDraftId: _pendingReceiverForPlacement.Id,
ReceiverName: _pendingReceiverForPlacement.FullName,
Color: _pendingReceiverForPlacement.Color);
@@ -1237,9 +1122,8 @@
try
{
var previousEnvelopeId = EnvelopeId;
var command = BuildCreateEnvelopeCommand(send: false);
Logger.LogInformation("[SenderEditor] Saving draft. EnvelopeId={EnvelopeId} IsEditMode={IsEditMode}", command.EnvelopeId, IsEditMode);
Logger.LogInformation("[SenderEditor] Saving draft. EnvelopeId={EnvelopeId}", command.EnvelopeId);
var result = await EnvelopeService.UpsertAsync(command);
if (result is null)
@@ -1247,16 +1131,9 @@
EnvelopeId = result.Id;
_loadedEnvelopeId = result.Id;
_editingEnvelopeId = result.Id;
if (navigateToDashboard)
{
NavigationManager.NavigateTo("/sender", forceLoad: false);
}
else if (previousEnvelopeId != result.Id)
{
NavigationManager.NavigateTo($"/sender/editor?envelopeId={result.Id}", forceLoad: false);
}
return result.Id;
}
@@ -1274,12 +1151,6 @@
}
}
async Task SaveDraftAndStayOnEditorAsync()
{
Logger.LogInformation("[SenderEditor] Trigger bootstrap draft save.");
await SaveDraftCoreAsync(navigateToDashboard: false);
}
// ── Send envelope ──
async Task SendAsync()
{
@@ -1319,7 +1190,7 @@
// Warn if any receiver has no signature field, but don't block
var receiversWithoutField = _receivers
.Where(r => !_signatureFields.Any(f => f.ReceiverName == r.FullName))
.Where(r => !_signatureFields.Any(f => f.ReceiverDraftId == r.Id))
.ToList();
if (receiversWithoutField.Count > 0)
{
@@ -1334,21 +1205,10 @@
try
{
var envelopeId = _editingEnvelopeId
?? EnvelopeId
?? _loadedEnvelopeId
?? GetEnvelopeIdFromCurrentUri();
var envelopeId = EnvelopeId ?? _loadedEnvelopeId;
if (!envelopeId.HasValue || envelopeId.Value <= 0)
{
var saveResult = await EnvelopeService.UpsertAsync(BuildCreateEnvelopeCommand(send: false))
?? throw new InvalidOperationException("Draft save did not return a result.");
envelopeId = saveResult.Id;
EnvelopeId = saveResult.Id;
_loadedEnvelopeId = saveResult.Id;
_editingEnvelopeId = saveResult.Id;
}
throw new InvalidOperationException("Envelope id is missing for send.");
var sendResult = await EnvelopeService.UpsertAsync(BuildCreateEnvelopeCommand(send: true) with { EnvelopeId = envelopeId })
?? throw new InvalidOperationException("Send did not return a result.");
@@ -1379,13 +1239,10 @@
{
NormalizeSettings();
var envelopeId = _editingEnvelopeId
?? EnvelopeId
?? _loadedEnvelopeId
?? GetEnvelopeIdFromCurrentUri();
var envelopeId = EnvelopeId ?? _loadedEnvelopeId;
if (IsEditMode && envelopeId is null)
throw new InvalidOperationException("Edit mode envelope id is missing.");
if (envelopeId is null)
throw new InvalidOperationException("Envelope id is missing.");
var docBase64 = _originalPdfBytes is { Length: > 0 }
? Convert.ToBase64String(_originalPdfBytes)
@@ -1394,7 +1251,7 @@
var receiverCommands = _receivers.Select(receiver =>
{
var fields = _signatureFields
.Where(f => f.ReceiverName == receiver.FullName)
.Where(f => f.ReceiverDraftId == receiver.Id)
.Select(f => new CreateEnvelopeFieldDto(
X: f.XPt / 72.0,
Y: f.YPt / 72.0,
@@ -1473,28 +1330,6 @@
return true;
}
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;
}
}
// ── Receiver popup ──
void OpenAddReceiverPopup()
{
@@ -1656,18 +1491,49 @@
return Task.CompletedTask;
}
_receivers.Add(new ReceiverDraft(Guid.NewGuid(), fullName, email, phoneNumber,
_receivers.Add(new ReceiverDraft(Guid.NewGuid(), null, fullName, email, phoneNumber,
ReceiverPalette[_receivers.Count % ReceiverPalette.Length]));
CloseAddReceiverPopup();
return Task.CompletedTask;
}
async Task RemoveReceiverAsync(ReceiverDraft receiver)
{
if (_isSaving)
return;
var envelopeId = EnvelopeId ?? _loadedEnvelopeId;
if (envelopeId is int persistedEnvelopeId && persistedEnvelopeId > 0 && receiver.ReceiverId is int persistedReceiverId && persistedReceiverId > 0)
{
try
{
await EnvelopeReceiverService.DeleteAsync(persistedEnvelopeId, persistedReceiverId);
}
catch (Exception ex)
{
Logger.LogError(ex, "[SenderEditor] Failed to remove receiver {ReceiverId} from envelope {EnvelopeId}", persistedReceiverId, persistedEnvelopeId);
_saveErrorMessage = string.Format(
L("SenderEditor_RemoveReceiverError", "Empfaenger konnte nicht entfernt werden: {0}"),
ex.Message);
_savePopupVisible = true;
return;
}
}
_receivers.RemoveAll(r => r.Id == receiver.Id);
_signatureFields.RemoveAll(f => f.ReceiverDraftId == receiver.Id);
if (_pendingReceiverForPlacement?.Id == receiver.Id)
_pendingReceiverForPlacement = null;
}
// ── Models ──
record SignatureFieldDraft(double XPt, double YPt, int Page, string ReceiverName, string Color);
record SignatureFieldDraft(double XPt, double YPt, int Page, Guid ReceiverDraftId, string ReceiverName, string Color);
record NormalisedCoords(double NormX, double NormY, int PageIndex);
record ReceiverDraft(Guid Id, string FullName, string Email, string PhoneNumber, string Color);
record ReceiverDraft(Guid Id, int? ReceiverId, string FullName, string Email, string PhoneNumber, string Color);
// ── Receiver colour palette (cycles when > 8 receivers) ──
static readonly string[] ReceiverPalette =

View File

@@ -7,6 +7,7 @@
@using EnvelopeGenerator.Server.Client.Services
@using EnvelopeGenerator.Application.Common.Dto
@using EnvelopeGenerator.Application.Common.Dto.History
@using EnvelopeGenerator.Application.Envelopes.Commands
@using EnvelopeGenerator.Application.Resources
@using EnvelopeGenerator.Domain.Resources
@using EnvelopeGenerator.Server.Client.Components.Sender
@@ -20,7 +21,7 @@
@inject EnvelopeGenerator.Server.Client.Services.UserPreferencesService UserPreferencesService
@inject EnvelopeGenerator.Server.Client.Services.CustomImagesService CustomImagesService
@inject EnvelopeGenerator.Server.Client.Services.PdfSignatureService PdfSignatureService
@inject EnvelopeGenerator.Server.Client.Services.CreateEnvelopeBootstrapStorageService CreateEnvelopeBootstrapStorageService
@inject EnvelopeGenerator.Server.Client.Services.EnvelopeReceiverService EnvelopeReceiverService
@inject ILogger<SenderPage> Logger
@inject NavigationManager Navigation
@inject IJSRuntime JSRuntime
@@ -315,12 +316,13 @@
@foreach (var receiver in envelope.EnvelopeReceivers)
{
<div style="display: flex; align-items: center; gap: 1rem; padding: 0.5rem; background: white; border-radius: 6px; border: 1px solid #e5e7eb;">
@{
var isRejected = IsReceiverRejected(envelope, receiver);
var isSigned = IsReceiverSigned(envelope, receiver);
var accessCode = receiver.AccessCode;
var hasAccessCode = !string.IsNullOrWhiteSpace(accessCode);
}
@{
var isRejected = IsReceiverRejected(envelope, receiver);
var isSigned = IsReceiverSigned(envelope, receiver);
var accessCode = receiver.AccessCode;
var hasAccessCode = !string.IsNullOrWhiteSpace(accessCode);
var hasReceiverEnvelopeUrl = CanOpenReceiverEnvelopeUrl(envelope, receiver);
}
<span class="receiver-badge receiver-badge--@(isRejected ? "unsigned" : (isSigned ? "signed" : "unsigned"))" style="min-width: 100px;">
@if (isRejected)
{
@@ -349,27 +351,40 @@
<strong style="color: #1f2937;">@receiver.Name</strong>
<span style="color: #6b7280; margin-left: 0.5rem;">@receiver.Receiver?.EmailAddress</span>
</div>
<div class="sender-access-code">
<span class="sender-access-code__label">@L("AccessCode", "Access code")</span>
<div class="sender-access-code__row">
<input class="sender-access-code__value"
<div class="sender-access-code">
<span class="sender-access-code__label">@L("AccessCode", "Access code")</span>
<div class="sender-access-code__row">
<input class="sender-access-code__value"
value="@(hasAccessCode ? accessCode : "-")"
readonly
title="@L("AccessCode", "Access code")" />
<button type="button"
class="sender-access-code__copy"
disabled="@(!hasAccessCode)"
@onclick='() => CopyAccessCodeAsync(accessCode)'
title="@L("Copy", "Copy")">
<button type="button"
class="sender-access-code__copy"
disabled="@(!hasAccessCode)"
@onclick='() => CopyAccessCodeAsync(accessCode)'
title="@L("Copy", "Copy")">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
<path d="M10 1.5A1.5 1.5 0 0 1 11.5 3v1H13A1.5 1.5 0 0 1 14.5 5.5v8A1.5 1.5 0 0 1 13 15H5A1.5 1.5 0 0 1 3.5 13.5V12H3A1.5 1.5 0 0 1 1.5 10.5v-8A1.5 1.5 0 0 1 3 1h7zm.5 2A.5.5 0 0 0 10 3H3a.5.5 0 0 0-.5.5v7A.5.5 0 0 0 3 11h.5V5.5A1.5 1.5 0 0 1 5 4h5.5v-.5zM5 5a.5.5 0 0 0-.5.5v8a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5v-8A.5.5 0 0 0 13 5H5z"/>
</svg>
</button>
</div>
</div>
</div>
}
</div>
<path d="M10 1.5A1.5 1.5 0 0 1 11.5 3v1H13A1.5 1.5 0 0 1 14.5 5.5v8A1.5 1.5 0 0 1 13 15H5A1.5 1.5 0 0 1 3.5 13.5V12H3A1.5 1.5 0 0 1 1.5 10.5v-8A1.5 1.5 0 0 1 3 1h7zm.5 2A.5.5 0 0 0 10 3H3a.5.5 0 0 0-.5.5v7A.5.5 0 0 0 3 11h.5V5.5A1.5 1.5 0 0 1 5 4h5.5v-.5zM5 5a.5.5 0 0 0-.5.5v8a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5v-8A.5.5 0 0 0 13 5H5z"/>
</svg>
</button>
@if (_canUseReceiverEnvelopeUrl)
{
<button type="button"
class="sender-access-code__copy"
disabled="@(!hasReceiverEnvelopeUrl)"
@onclick='() => OpenReceiverEnvelopeUrlAsync(envelope, receiver)'
title="@L("Open", "Open")">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
<path fill-rule="evenodd" d="M6.364 13.5a1.5 1.5 0 0 1-1.5-1.5V4a1.5 1.5 0 0 1 1.5-1.5h4a.5.5 0 0 1 0 1h-4a.5.5 0 0 0-.5.5v8a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5v-4a.5.5 0 0 1 1 0v4a1.5 1.5 0 0 1-1.5 1.5h-8z"/>
<path fill-rule="evenodd" d="M8.5 1a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 .5.5v5a.5.5 0 0 1-1 0V2.707L8.854 7.354a.5.5 0 1 1-.708-.708L12.793 2H9a.5.5 0 0 1-.5-.5z"/>
</svg>
</button>
}
</div>
</div>
</div>
}
</div>
}
else
{
@@ -544,6 +559,7 @@
private string _activeTab = "active";
private bool _isLoading = true;
private bool _isLoggingOut = false;
private bool _canUseReceiverEnvelopeUrl = false;
private string? _errorMessage;
private DxGrid? _grid;
@@ -603,6 +619,8 @@
try { await ThemeService.LoadFromServerAsync(); }
catch { /* swallow — default theme remains active */ }
_canUseReceiverEnvelopeUrl = await EnvelopeReceiverService.GetReceiverEnvelopeUrlFeatureEnabledAsync();
// Load logo config — authenticated, so this should succeed
var logos = await CustomImagesService.GetAsync();
_appLogo = logos.App;
@@ -629,7 +647,7 @@
try
{
_allEnvelopes = await EnvelopeService.GetAsync() ?? [];
_allEnvelopes = await EnvelopeService.GetAsync(includeDocAndDocRes: false) ?? [];
// Split into active and completed based on effective status
// (history-aware: rejected envelopes should not stay in active tab).
@@ -726,29 +744,44 @@
async Task StartEditorFromWizardAsync(CreateEnvelopeWizardResult result)
{
var payload = new CreateEnvelopeBootstrapPayload(
Title: result.Title,
Message: result.Message,
EnvelopeTypeId: result.EnvelopeTypeId,
Language: result.Language,
UseAccessCode: result.UseAccessCode,
TFAEnabled: result.TFAEnabled,
CertificationType: result.CertificationType,
FinalEmailToCreator: result.FinalEmailToCreator,
FinalEmailToReceivers: result.FinalEmailToReceivers,
SendReminderEmails: result.SendReminderEmails,
FirstReminderDays: result.FirstReminderDays,
ReminderIntervalDays: result.ReminderIntervalDays,
ExpiresWhenDays: result.ExpiresWhenDays,
ExpiresWarningWhenDays: result.ExpiresWarningWhenDays,
Receivers: result.Receivers
.Select(x => new CreateEnvelopeBootstrapReceiver(x.FullName, x.EmailAddress))
.ToList(),
DocumentBytes: result.DocumentBytes,
DocumentFileName: result.DocumentFileName);
var docBase64 = result.DocumentBytes is { Length: > 0 }
? Convert.ToBase64String(result.DocumentBytes)
: null;
var token = await CreateEnvelopeBootstrapStorageService.StoreAsync(payload);
Navigation.NavigateTo($"/sender/editor?bootstrapToken={token}");
var command = new CreateEnvelopeCommand
{
EnvelopeTypeId = result.EnvelopeTypeId,
Title = result.Title,
Message = result.Message,
Language = string.IsNullOrWhiteSpace(result.Language) ? "de-DE" : result.Language,
UseAccessCode = result.UseAccessCode,
TFAEnabled = result.TFAEnabled,
CertificationType = result.CertificationType,
FinalEmailToCreator = result.FinalEmailToCreator,
FinalEmailToReceivers = result.FinalEmailToReceivers,
SendReminderEmails = result.SendReminderEmails,
FirstReminderDays = result.FirstReminderDays,
ReminderIntervalDays = result.ReminderIntervalDays,
ExpiresWhenDays = result.ExpiresWhenDays,
ExpiresWarningWhenDays = result.ExpiresWarningWhenDays,
Send = false,
Document = string.IsNullOrWhiteSpace(docBase64) ? null : new CreateEnvelopeDocumentDto(docBase64),
Receivers = result.Receivers
.Select(x => new CreateEnvelopeReceiverDto(x.EmailAddress, x.FullName, null, []))
.ToList()
};
try
{
var envelope = await EnvelopeService.UpsertAsync(command)
?? throw new InvalidOperationException("Envelope creation returned no result.");
Navigation.NavigateTo($"/sender/editor?envelopeId={envelope.Id}");
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to create envelope from wizard.");
_errorMessage = ex.Message;
}
}
void EditEnvelope()
@@ -987,6 +1020,35 @@
}
}
async Task OpenReceiverEnvelopeUrlAsync(EnvelopeDto envelope, EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto receiver)
{
if (!_canUseReceiverEnvelopeUrl)
return;
if (!CanOpenReceiverEnvelopeUrl(envelope, receiver))
return;
try
{
var receiverEnvelopeUrl = await EnvelopeReceiverService.GetReceiverEnvelopeUrlAsync(
envelope.Uuid,
receiver.Receiver!.Signature!);
if (string.IsNullOrWhiteSpace(receiverEnvelopeUrl))
return;
await JSRuntime.InvokeVoidAsync("open", receiverEnvelopeUrl, "_blank", "noopener,noreferrer");
}
catch
{
await JSRuntime.InvokeVoidAsync("console.warn", "[Sender] Failed to open receiver envelope URL.");
}
}
static bool CanOpenReceiverEnvelopeUrl(EnvelopeDto envelope, EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto receiver)
=> !string.IsNullOrWhiteSpace(envelope.Uuid)
&& !string.IsNullOrWhiteSpace(receiver.Receiver?.Signature);
void OnCustomizeElement(GridCustomizeElementEventArgs e)
{
// Future: Add custom row coloring based on status if needed

View File

@@ -1,11 +1,13 @@
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using EnvelopeGenerator.Server.Client.Services;
using EnvelopeGenerator.Server.Client.Options;
using MudBlazor.Services;
using DevExpress.Blazor.Reporting;
using DevExpress.XtraReports.Web.Extensions;
using DevExpress.DataAccess.Web;
using DevExpress.XtraReports.Services;
using System.Globalization;
using EnvelopeGenerator.Application.Common.Configurations;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
@@ -15,6 +17,7 @@ CultureInfo.DefaultThreadCurrentCulture = cultureInfo;
CultureInfo.DefaultThreadCurrentUICulture = cultureInfo;
builder.Services.AddLocalization();
builder.Services.AddMudServices();
// Named HttpClient for API calls (both for services and DevExpress components)
builder.Services.AddHttpClient("EnvelopeGenerator.Server", client =>
@@ -31,6 +34,8 @@ builder.Services.AddScoped(sp => new HttpClient
// Configuration Options
builder.Services.Configure<PdfViewerOptions>(opts =>
builder.Configuration.GetSection(PdfViewerOptions.SectionName).Bind(opts));
builder.Services.Configure<SignatureFieldParams>(opts =>
builder.Configuration.GetSection(nameof(SignatureFieldParams)).Bind(opts));
// Business Services
builder.Services.AddScoped<DocumentService>();

View File

@@ -1,4 +1,5 @@
using System.Text.Json;
using EnvelopeGenerator.Domain.Constants;
using Microsoft.JSInterop;
namespace EnvelopeGenerator.Server.Client.Services;
@@ -49,7 +50,7 @@ public sealed record CreateEnvelopeBootstrapPayload(
string Language,
bool UseAccessCode,
bool TFAEnabled,
int CertificationType,
CertificationType CertificationType,
int FinalEmailToCreator,
int FinalEmailToReceivers,
bool SendReminderEmails,

View File

@@ -4,6 +4,7 @@ using System.Net.Http.Json;
using System.Text.Json;
using EnvelopeGenerator.Application.EnvelopeReceivers.Commands.CreateEnvelopeReceiver;
using EnvelopeGenerator.Server.Client.Models;
using Microsoft.AspNetCore.WebUtilities;
namespace EnvelopeGenerator.Server.Client.Services;
@@ -15,6 +16,8 @@ namespace EnvelopeGenerator.Server.Client.Services;
public class EnvelopeReceiverService(IHttpClientFactory httpClientFactory)
{
private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
private sealed record ReceiverEnvelopeUrlResponse(string Url);
private sealed record ReceiverEnvelopeUrlEnabledResponse(bool Enabled);
/// <summary>
/// Fetches the envelope receiver data for the given envelope key from the API.
@@ -63,4 +66,59 @@ public class EnvelopeReceiverService(IHttpClientFactory httpClientFactory)
return await response.Content.ReadFromJsonAsync<CreateEnvelopeReceiverResponse>(_jsonOptions, cancel);
}
public async Task<string?> GetReceiverEnvelopeUrlAsync(string envelopeUuid, string receiverSignature, CancellationToken cancel = default)
{
if (string.IsNullOrWhiteSpace(envelopeUuid) || string.IsNullOrWhiteSpace(receiverSignature))
return null;
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
var url = $"/api/EnvelopeReceiver/receiver-envelope-url?envelopeUuid={Uri.EscapeDataString(envelopeUuid)}&receiverSignature={Uri.EscapeDataString(receiverSignature)}";
var response = await http.GetAsync(url, cancel);
if (!response.IsSuccessStatusCode)
return null;
var payload = await response.Content.ReadFromJsonAsync<ReceiverEnvelopeUrlResponse>(_jsonOptions, cancel);
return payload?.Url;
}
public async Task<bool> GetReceiverEnvelopeUrlFeatureEnabledAsync(CancellationToken cancel = default)
{
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
var response = await http.GetAsync("/api/EnvelopeReceiver/receiver-envelope-url/enabled", cancel);
if (!response.IsSuccessStatusCode)
return false;
var payload = await response.Content.ReadFromJsonAsync<ReceiverEnvelopeUrlEnabledResponse>(_jsonOptions, cancel);
return payload?.Enabled == true;
}
public async Task DeleteAsync(int envelopeId, int receiverId, CancellationToken cancel = default)
{
if (envelopeId <= 0)
throw new ArgumentOutOfRangeException(nameof(envelopeId));
if (receiverId <= 0)
throw new ArgumentOutOfRangeException(nameof(receiverId));
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
var url = QueryHelpers.AddQueryString("/api/EnvelopeReceiver", new Dictionary<string, string?>
{
["EnvelopeId"] = envelopeId.ToString(),
["ReceiverId"] = receiverId.ToString()
});
var response = await http.DeleteAsync(url, cancel);
if (response.IsSuccessStatusCode)
return;
var body = await response.Content.ReadAsStringAsync(cancel);
var statusCode = (int)response.StatusCode;
var reasonPhrase = response.ReasonPhrase ?? "Unknown error";
throw new HttpRequestException(
$"Failed to remove receiver. Status: {statusCode} ({reasonPhrase}){(string.IsNullOrWhiteSpace(body) ? string.Empty : $" - {body}")}",
null,
response.StatusCode);
}
}

View File

@@ -25,6 +25,7 @@ public class EnvelopeService(IHttpClientFactory clientFactory)
string? uuid = null,
bool? onlyActive = null,
bool? onlyCompleted = null,
bool? includeDocAndDocRes = null,
CancellationToken cancel = default)
{
var baseUrl = $"/api/Envelope";
@@ -46,6 +47,10 @@ public class EnvelopeService(IHttpClientFactory clientFactory)
{
queryParams["OnlyCompleted"] = onlyCompleted.Value.ToString();
}
if (includeDocAndDocRes.HasValue)
{
queryParams["IncludeDocAndDocRes"] = includeDocAndDocRes.Value.ToString();
}
var url = QueryHelpers.AddQueryString(baseUrl, queryParams);

View File

@@ -9,6 +9,7 @@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flag-icons@7.2.3/css/flag-icons.min.css" />
<link rel="stylesheet" href="css/app.css" />
<link rel="stylesheet" href="css/envelope-viewer.css" />
<link rel="stylesheet" href="_content/MudBlazor/MudBlazor.min.css" />
<link rel="stylesheet" href="EnvelopeGenerator.Server.styles.css" />
<HeadOutlet />
<!-- Apply saved theme immediately on page load — before WASM boots.
@@ -37,6 +38,7 @@
<script src="js/receiver-signature.js?v=9"></script>
<script src="js/signflow-theme.js"></script>
<script src="js/envelope-editor.js"></script>
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
<script src="_framework/blazor.web.js"></script>
</body>

View File

@@ -23,27 +23,16 @@ namespace EnvelopeGenerator.Server.Controllers;
/// - 404 Not Found: Die angeforderte Ressource wurde nicht gefunden.
/// - 500 Internal Server Error: Ein unerwarteter Fehler ist aufgetreten.
/// </remarks>
/// <remarks>
/// Erstellt eine neue Instanz des EnvelopeControllers.
/// </remarks>
/// <param name="Logger">Der Logger, der für das Protokollieren von Informationen verwendet wird.</param>
/// <param name="Mediator"></param>
[Route("api/[controller]")]
[ApiController]
[Authorize]
public class EnvelopeController : ControllerBase
public class EnvelopeController(ILogger<EnvelopeController> Logger, IMediator Mediator) : ControllerBase
{
public record DeleteEnvelopeRequest(string Reason);
private readonly ILogger<EnvelopeController> _logger;
private readonly IMediator _mediator;
/// <summary>
/// Erstellt eine neue Instanz des EnvelopeControllers.
/// </summary>
/// <param name="logger">Der Logger, der für das Protokollieren von Informationen verwendet wird.</param>
/// <param name="mediator"></param>
public EnvelopeController(ILogger<EnvelopeController> logger, IMediator mediator)
{
_logger = logger;
_mediator = mediator;
}
/// <summary>
/// Ruft eine Liste von Umschlägen basierend auf dem Benutzer und den angegebenen Statusfiltern ab.
/// </summary>
@@ -58,8 +47,8 @@ public class EnvelopeController : ControllerBase
[HttpGet]
public async Task<IActionResult> GetAsync([FromQuery] ReadEnvelopeQuery envelope)
{
var result = await _mediator.Send(envelope.Authorize(User.GetId()));
return result.Any() ? Ok(result) : NotFound();
var result = await Mediator.Send(envelope.Authorize(User.GetId()));
return Ok(result ?? []);
}
/// <summary>
@@ -74,7 +63,7 @@ public class EnvelopeController : ControllerBase
[HttpGet("doc-result")]
public async Task<IActionResult> GetDocResultAsync([FromQuery] ReadEnvelopeQuery query, [FromQuery] bool view = false)
{
var envelopes = await _mediator.Send(query.Authorize(User.GetId()));
var envelopes = await Mediator.Send(query.Authorize(User.GetId()));
var envelope = envelopes.FirstOrDefault();
if (envelope is null)
@@ -100,22 +89,34 @@ public class EnvelopeController : ControllerBase
[HttpPost]
public async Task<IActionResult> CreateAsync([FromBody] CreateEnvelopeCommand command)
{
var res = await _mediator.Send(command.WithAuth(User.GetId()));
var res = await Mediator.Send(command.WithAuth(User.GetId()));
if (res is null)
{
_logger.LogError("Failed to create envelope. Envelope details: {EnvelopeDetails}", JsonConvert.SerializeObject(command));
Logger.LogError("Failed to create envelope. Envelope details: {EnvelopeDetails}", JsonConvert.SerializeObject(command));
return StatusCode(StatusCodes.Status500InternalServerError);
}
else
return Ok(res);
}
/// <summary>
///
/// </summary>
/// <param name="Reason"></param>
public record DeleteEnvelopeRequest(string Reason);
/// <summary>
///
/// </summary>
/// <param name="id"></param>
/// <param name="request"></param>
/// <returns></returns>
[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
var result = await Mediator.Send(new DeleteEnvelopeCommand
{
EnvelopeId = id,
Reason = request?.Reason ?? string.Empty
@@ -133,7 +134,7 @@ public class EnvelopeController : ControllerBase
[HttpPost("finalize")]
public async Task<IActionResult> FinalizeEnvelopeAsync([FromQuery] FinalizeEnvelopeCommand command)
{
await _mediator.Send(command.Authorized(User.GetId()));
await Mediator.Send(command.Authorized(User.GetId()));
return Ok();
}

View File

@@ -15,6 +15,9 @@ using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor;
using EnvelopeGenerator.Server.Extensions;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Application.EnvelopeReceivers.Commands.CreateEnvelopeReceiver;
using EnvelopeGenerator.Application.Common.Extensions;
using EnvelopeGenerator.Server.Options;
using EnvelopeGenerator.Application.EnvelopeReceivers.Commands;
namespace EnvelopeGenerator.Server.Controllers;
@@ -24,32 +27,16 @@ namespace EnvelopeGenerator.Server.Controllers;
/// <remarks>
/// Dieser Controller bietet Endpunkte für das Abrufen und Verwalten von Umschlagempfängerdaten.
/// </remarks>
/// <remarks>
/// Konstruktor für den EnvelopeReceiverController.
/// </remarks>
[Route("api/[controller]")]
[Authorize]
[ApiController]
public class EnvelopeReceiverController : ControllerBase
public class EnvelopeReceiverController(ILogger<EnvelopeReceiverController> Logger, IMediator Mediator, IMapper Mapper, IEnvelopeExecutor EnvelopeExecutor, IEnvelopeReceiverExecutor ER_Executor, IDocumentExecutor DocExecutor, IOptions<ConnectionString> csOpt, IOptionsMonitor<SenderFeatureAccessOptions> senderFeatAccessOpt) : ControllerBase
{
private readonly ILogger<EnvelopeReceiverController> _logger;
private readonly IMediator _mediator;
private readonly IMapper _mapper;
private readonly IEnvelopeExecutor _envelopeExecutor;
private readonly IEnvelopeReceiverExecutor _erExecutor;
private readonly IDocumentExecutor _documentExecutor;
private readonly string _cnnStr;
/// <summary>
/// Konstruktor für den EnvelopeReceiverController.
/// </summary>
public EnvelopeReceiverController(ILogger<EnvelopeReceiverController> logger, IMediator mediator, IMapper mapper, IEnvelopeExecutor envelopeExecutor, IEnvelopeReceiverExecutor erExecutor, IDocumentExecutor documentExecutor, IOptions<ConnectionString> csOpt)
{
_logger = logger;
_mediator = mediator;
_mapper = mapper;
_envelopeExecutor = envelopeExecutor;
_erExecutor = erExecutor;
_documentExecutor = documentExecutor;
_cnnStr = csOpt.Value.Value;
}
private readonly string _cnnStr = csOpt.Value.Value;
private readonly IOptionsMonitor<SenderFeatureAccessOptions> SenderFeatAccessOpt = senderFeatAccessOpt;
/// <summary>
/// Ruft eine Liste von Umschlagempfängern basierend auf den angegebenen Abfrageparametern ab.
@@ -69,7 +56,7 @@ public class EnvelopeReceiverController : ControllerBase
{
envelopeReceiver = envelopeReceiver with { Username = User.GetUsername() };
var result = await _mediator.Send(envelopeReceiver);
var result = await Mediator.Send(envelopeReceiver);
return Ok(result);
}
@@ -84,7 +71,7 @@ public class EnvelopeReceiverController : ControllerBase
[HttpGet("{envelopeKey}")]
public async Task<IActionResult> GetEnvelopeReceiverOfReceiver([FromRoute] string envelopeKey, CancellationToken cancel)
{
var er = await _mediator.Send(new ReadEnvelopeReceiverQuery()
var er = await Mediator.Send(new ReadEnvelopeReceiverQuery()
{
Key = envelopeKey
}, cancel);
@@ -108,10 +95,53 @@ public class EnvelopeReceiverController : ControllerBase
[HttpGet("salute")]
public async Task<IActionResult> GetReceiverName([FromQuery] ReadReceiverNameQuery receiver)
{
var name = await _mediator.Send(receiver);
var name = await Mediator.Send(receiver);
return name is null ? NotFound() : Ok(name);
}
/// <summary>
///
/// </summary>
/// <param name="envelopeUuid"></param>
/// <param name="receiverSignature"></param>
/// <returns></returns>
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
[HttpGet("receiver-envelope-url")]
public IActionResult GetReceiverEnvelopeUrl([FromQuery] string envelopeUuid, [FromQuery] string receiverSignature)
{
if (!CanUseReceiverEnvelopeUrlFeature())
return Forbid();
if (string.IsNullOrWhiteSpace(envelopeUuid) || string.IsNullOrWhiteSpace(receiverSignature))
return BadRequest("Envelope uuid and receiver signature are required.");
var envelopeKey = (envelopeUuid, receiverSignature).ToEnvelopeKey();
var absoluteUrl = $"{Request.Scheme}://{Request.Host}/envelope/{Uri.EscapeDataString(envelopeKey)}";
return Ok(new { url = absoluteUrl });
}
/// <summary>
///
/// </summary>
/// <returns></returns>
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
[HttpGet("receiver-envelope-url/enabled")]
public IActionResult GetReceiverEnvelopeUrlFeatureEnabled()
{
return Ok(new { enabled = CanUseReceiverEnvelopeUrlFeature() });
}
private bool CanUseReceiverEnvelopeUrlFeature()
{
var senderEmail = User.GetEmail();
if (string.IsNullOrWhiteSpace(senderEmail))
return false;
var allowedEmails = SenderFeatAccessOpt.CurrentValue.AllowedReceiverEnvelopeUrlEmails;
return allowedEmails.Any(email => string.Equals(email?.Trim(), senderEmail, StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// Returns recent receiver suggestions for the authenticated sender.
/// </summary>
@@ -122,7 +152,7 @@ public class EnvelopeReceiverController : ControllerBase
[HttpGet("recent-suggestions")]
public async Task<IActionResult> GetRecentSuggestions([FromQuery] ReadRecentReceiverSuggestionsQuery query, CancellationToken cancel)
{
var result = await _mediator.Send(query.ForSender(User.GetUsername()), cancel);
var result = await Mediator.Send(query.ForSender(User.GetUsername()), cancel);
return Ok(result);
}
@@ -169,7 +199,7 @@ public class EnvelopeReceiverController : ControllerBase
public async Task<IActionResult> CreateAsync([FromBody] CreateEnvelopeReceiverCommand request, CancellationToken cancel)
{
#region Create Envelope
var envelope = await _envelopeExecutor.CreateEnvelopeAsync(User.GetId(), request.Title, request.Message, request.TFAEnabled, cancel);
var envelope = await EnvelopeExecutor.CreateEnvelopeAsync(User.GetId(), request.Title, request.Message, request.TFAEnabled, cancel);
#endregion
#region Add receivers
@@ -178,7 +208,7 @@ public class EnvelopeReceiverController : ControllerBase
foreach (var receiver in request.Receivers)
{
var envelopeReceiver = await _erExecutor.AddEnvelopeReceiverAsync(envelope.Uuid, receiver.EmailAddress, receiver.Salution, receiver.PhoneNumber, cancel);
var envelopeReceiver = await ER_Executor.AddEnvelopeReceiverAsync(envelope.Uuid, receiver.EmailAddress, receiver.Salution, receiver.PhoneNumber, cancel);
if (envelopeReceiver is null)
unsentReceivers.Add(receiver);
@@ -186,13 +216,13 @@ public class EnvelopeReceiverController : ControllerBase
sentReceivers.Add(envelopeReceiver);
}
var res = _mapper.Map<CreateEnvelopeReceiverResponse>(envelope);
var res = Mapper.Map<CreateEnvelopeReceiverResponse>(envelope);
res.UnsentReceivers = unsentReceivers;
res.SentReceiver = _mapper.Map<List<ReceiverDto>>(sentReceivers.Select(er => er.Receiver));
res.SentReceiver = Mapper.Map<List<ReceiverDto>>(sentReceivers.Select(er => er.Receiver));
#endregion
#region Add document
var document = await _documentExecutor.CreateDocumentAsync(request.Document.DataAsBase64, envelope.Uuid, cancel);
var document = await DocExecutor.CreateDocumentAsync(request.Document.DataAsBase64, envelope.Uuid, cancel);
if (document is null)
return StatusCode(StatusCodes.Status500InternalServerError, "Document creation is failed.");
@@ -229,7 +259,7 @@ public class EnvelopeReceiverController : ControllerBase
{
bool outSuccess = reader.GetBoolean(0);
if (!outSuccess)
_logger.LogWarning(
Logger.LogWarning(
"PRSIG_API_ADD_DOC_RECEIVER_ELEM returned OUT_SUCCESS=false. DOC_ID={DocId}, RECEIVER_ID={ReceiverId}, Page={Page}",
document.Id, rcv.Id, sign.Page);
}
@@ -261,7 +291,7 @@ public class EnvelopeReceiverController : ControllerBase
{
bool outSuccess = reader.GetBoolean(0);
if (!outSuccess)
_logger.LogWarning(
Logger.LogWarning(
"PRSIG_API_ADD_HISTORY_STATE returned OUT_SUCCESS=false. EnvelopeUuid={EnvelopeUuid}",
envelope.Uuid);
}
@@ -292,4 +322,18 @@ public class EnvelopeReceiverController : ControllerBase
}
}
/// <summary>
///
/// </summary>
/// <param name="request"></param>
/// <param name="cancel"></param>
/// <returns></returns>
[HttpDelete]
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
public async Task<IActionResult> RemoveAsync([FromQuery] RemoveEnvelopeReceiverCommand request, CancellationToken cancel)
{
request = request.Authenticated(User.GetId());
await Mediator.Send(request, cancel);
return Ok();
}
}

View File

@@ -0,0 +1,88 @@
using EnvelopeGenerator.Application.Common.Interfaces.Services.Notification;
using EnvelopeGenerator.Domain.Constants;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace EnvelopeGenerator.Server.Controllers;
/// <summary>
/// Sends envelope notifications, optionally with randomly generated payloads.
/// </summary>
/// <remarks>
/// Initializes a new instance of the <see cref="NotificationController"/> class.
/// </remarks>
[ApiController]
[Route("api/[controller]")]
public class NotificationController(IEnvelopeNotificationService notificationService) : ControllerBase
{
private const string FallbackEmail = "htek0100@gmail.com";
/// <summary>
/// Sends the supplied document signed notification.
/// </summary>
[HttpPost("signed")]
public async Task<IActionResult> SendSigned(
[FromBody] DocumentSignedNotification notification,
[FromQuery] string? email,
CancellationToken cancel)
{
await notificationService.Notify(NormalizeEmail(email), notification, cancel);
return Ok();
}
/// <summary>
/// Sends a randomly generated document signed notification.
/// </summary>
[HttpPost("signed/random")]
public async Task<IActionResult> SendSignedRandom(
[FromQuery] string? email,
CancellationToken cancel)
{
await notificationService.Notify(NormalizeEmail(email), new DocumentSignedNotification
{
DocumentTitle = $"Dokument {Random.Shared.Next(1000, 9999)}",
NameReceiver = $"Empfänger {Random.Shared.Next(1, 100)}",
IsReadAndConfirm = Random.Shared.Next(2) == 0,
}, cancel);
return Ok();
}
/// <summary>
/// Sends the supplied document received notification.
/// </summary>
[HttpPost("received")]
public async Task<IActionResult> SendReceived(
[FromBody] DocumentReceivedNotification notification,
[FromQuery] string? email,
CancellationToken cancel)
{
await notificationService.Notify(NormalizeEmail(email), notification, cancel);
return Ok();
}
/// <summary>
/// Sends a randomly generated document received notification.
/// </summary>
[HttpPost("received/random")]
public async Task<IActionResult> SendReceivedRandom(
[FromQuery] string? email,
CancellationToken cancel)
{
await notificationService.Notify(NormalizeEmail(email), new DocumentReceivedNotification
{
DocumentTitle = $"Dokument {Random.Shared.Next(1000, 9999)}",
NameReceiver = $"Empfänger {Random.Shared.Next(1, 100)}",
NameSender = $"Absender {Random.Shared.Next(1, 100)}",
IsReadAndConfirm = Random.Shared.Next(2) == 0,
AdvancedSignature = Random.Shared.Next(2) == 0,
Origin = new Uri($"{Request.Scheme}://{Request.Host}"),
EnvelopeUuid = Guid.NewGuid().ToString(),
ReceiverSignature = Guid.NewGuid().ToString(),
Message = $"Testnachricht {Random.Shared.Next(1000, 9999)}"
}, cancel);
return Ok();
}
private static string NormalizeEmail(string? email)
=> string.IsNullOrWhiteSpace(email) ? FallbackEmail : email;
}

View File

@@ -4,13 +4,17 @@ using EnvelopeGenerator.Application.Common.Interfaces.Doc;
using EnvelopeGenerator.Domain.Constants;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using System.Security.Cryptography;
namespace EnvelopeGenerator.Server.Controllers;
[ApiController]
[Route("api/[controller]")]
public class PdfRenderController(IPdfPageImageRenderer renderer) : ControllerBase
public class PdfRenderController(IPdfPageImageRenderer renderer, IMemoryCache memoryCache) : ControllerBase
{
private static readonly TimeSpan RenderCacheDuration = TimeSpan.FromMinutes(5);
// TODO(perf): Replace JSON/base64 transport for PdfBytes with application/octet-stream (or multipart) and stream-based request parsing to reduce payload size and allocation pressure for large PDFs.
[HttpPost("pages")]
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
@@ -19,7 +23,19 @@ public class PdfRenderController(IPdfPageImageRenderer renderer) : ControllerBas
if (request?.PdfBytes is not { Length: > 0 } bytes)
return BadRequest("Missing PDF bytes.");
var cacheKey = BuildCacheKey(bytes);
if (memoryCache.TryGetValue(cacheKey, out IReadOnlyList<PdfPageImageDto>? cachedPages) && cachedPages is not null)
return Ok(cachedPages);
IReadOnlyList<PdfPageImageDto> pages = await renderer.RenderPagesAsImagesAsync(bytes, cancel);
memoryCache.Set(cacheKey, pages, RenderCacheDuration);
return Ok(pages);
}
private static string BuildCacheKey(byte[] pdfBytes)
{
var hash = Convert.ToHexString(SHA256.HashData(pdfBytes));
return $"pdf-render:pages:v1:dpi110:{hash}";
}
}

View File

@@ -10,9 +10,9 @@
<Authors>Digital Data GmbH</Authors>
<Company>Digital Data GmbH</Company>
<Product>EnvelopeGenerator.Server</Product>
<Version>1.7.0-beta</Version>
<FileVersion>1.7.0.0</FileVersion>
<AssemblyVersion>1.7.0.0</AssemblyVersion>
<Version>1.8.0-beta</Version>
<FileVersion>1.8.0.0</FileVersion>
<AssemblyVersion>1.8.0.0</AssemblyVersion>
<Copyright>Copyright © 2026 Digital Data GmbH. All rights reserved.</Copyright>
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
</PropertyGroup>
@@ -30,6 +30,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="8.0.22" />
<PackageReference Include="MudBlazor" Version="8.0.0" />
<PackageReference Include="DevExpress.Blazor" Version="25.2.3" />
<PackageReference Include="DevExpress.Blazor.PdfViewer" Version="25.2.3" />
<PackageReference Include="DevExpress.Blazor.Reporting.Viewer" Version="25.2.3" />

View File

@@ -0,0 +1,8 @@
namespace EnvelopeGenerator.Server.Options;
public sealed class SenderFeatureAccessOptions
{
public const string SectionName = "SenderFeatureAccess";
public List<string> AllowedReceiverEnvelopeUrlEmails { get; set; } = [];
}

View File

@@ -8,6 +8,7 @@ using DigitalData.Core.Application;
using DigitalData.EmailProfilerDispatcher;
using DigitalData.UserManager.DependencyInjection;
using EnvelopeGenerator.Application;
using EnvelopeGenerator.Application.Common.Interfaces;
using EnvelopeGenerator.Application.Common.Interfaces.Doc;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Infrastructure;
@@ -19,6 +20,7 @@ using EnvelopeGenerator.Server.Client.Services;
using EnvelopeGenerator.Server.Components;
using EnvelopeGenerator.Server.Models;
using EnvelopeGenerator.Server.Options;
using EnvelopeGenerator.Server.Services;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Localization;
@@ -30,6 +32,7 @@ using NLog;
using NLog.Web;
using Scalar.AspNetCore;
using System.Globalization;
using MudBlazor.Services;
var logger = LogManager.Setup().LoadConfigurationFromAppSettings().GetCurrentClassLogger();
logger.Info("EnvelopeGenerator.Server logging initialized!");
@@ -58,6 +61,7 @@ try
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents()
.AddInteractiveWebAssemblyComponents();
builder.Services.AddMudServices();
// Add API Controllers
builder.Services.AddControllers()
@@ -298,6 +302,7 @@ try
// Cache options
builder.Services.Configure<CacheOptions>(config.GetSection(CacheOptions.SectionName));
builder.Services.Configure<SenderFeatureAccessOptions>(config.GetSection(SenderFeatureAccessOptions.SectionName));
// Distributed Cache - SQL Server
builder.Services.AddDistributedSqlServerCache(options =>
@@ -391,6 +396,8 @@ try
builder.Services.Configure<EnvelopeGenerator.Server.Client.Options.PdfViewerOptions>(
builder.Configuration.GetSection("PdfViewerOptions"));
builder.Services.AddScoped<IRequestContext, RequestContext>();
var app = builder.Build();
deferredProvider.Factory = () => app.Services;

View File

@@ -0,0 +1,18 @@
using EnvelopeGenerator.Application.Common.Interfaces;
namespace EnvelopeGenerator.Server.Services;
/// <summary>
///
/// </summary>
/// <param name="HttpContextAccessor"></param>
public sealed class RequestContext(
IHttpContextAccessor HttpContextAccessor) : IRequestContext
{
/// <summary>
///
/// </summary>
public string? Origin =>
HttpContextAccessor.HttpContext?
.Request.Headers.Origin.ToString();
}

View File

@@ -58,8 +58,12 @@
"MainCanvasEnableHiDPI": true,
"ZoomStepPercentage": 5
},
"SignatureFieldParams": {
"Width": 1.77,
"Height": 1.96
},
"DocumentService": {
"BaseUrl": "https://localhost:7186"
"BaseUrl": "http://172.24.12.39:9393"
},
"PSPDFKitLicenseKey": "SXCtGGY9XA-31OGUXQK-r7c6AkdLGPm2ljuyDr1qu0kkhLvydg-Do-fxpNUF4Rq3fS_xAnZRNFRHbXpE6sQ2BMcCSVTcXVJO6tPviexjpiT-HnrDEySlUERJnnvh-tmeOWprxS6BySPnSILkmaVQtUfOIUS-cUbvvEYHTvQBKbSF8di4XHQFyfv49ihr51axm3NVV3AXwh2EiKL5C5XdqBZ4sQ4O7vXBjM2zvxdPxlxdcNYmiU83uAzw7B83O_jubPzya4CdUHh_YH7Nlp2gP56MeG1Sw2JhMtfG3Rj14Sg4ctaeL9p6AEWca5dDjJ2li5tFIV2fQSsw6A_cowLu0gtMm5i8IfJXeIcQbMC2-0wGv1oe9hZYJvFMdzhTM_FiejM0agemxt3lJyzuyP8zbBSOgp7Si6A85krLWPZptyZBTG7pp7IHboUHfPMxCXqi-zMsqewOJtQBE2mjntU-lPryKnssOpMPfswwQX7QSkJYV5EMqNmEhQX6mEkp2wcqFzMC7bJQew1aO4pOpvChUaMvb1vgRek0HxLag0nwQYX2YrYGh7F_xXJs-8HNwJe8H0-eW4x4faayCgM5rB5772CCCsD9ThZcvXFrjNHHLGJ8WuBUFm6LArvSfFQdii_7j-_sqHMpeKZt26NFgivj1A==",
"Content-Security-Policy": [ // The first format parameter {0} will be replaced by the nonce value.
@@ -208,6 +212,9 @@
"TableName": "TBDD_CACHE"
}
},
"SenderFeatureAccess": {
"AllowedReceiverEnvelopeUrlEmails": ["h.tek@digitaldata.works"]
},
"MainPageTitle": null,
"AnnotationParams": {
"Background": {

View File

@@ -14,5 +14,9 @@
"RenderingOpacity": 0.85,
"ThumbnailRenderDelay": 50,
"ZoomStepPercentage": 5
},
"SignatureFieldParams": {
"Width": 1.77,
"Height": 1.96
}
}

View File

@@ -989,7 +989,7 @@ body.resizing {
}
.sender-toolbar-action-btn--compact {
padding: 0.45rem 0.7rem;
padding: 0.28rem 0.42rem;
}
.sender-receivers-panel {
@@ -1208,23 +1208,68 @@ body.resizing {
.sender-receivers-list {
display: flex;
flex-wrap: wrap;
gap: 0.625rem;
gap: 0.4rem;
align-items: center;
}
.sender-receivers-list__add-btn {
position: relative;
display: inline-flex;
align-items: center;
gap: 0.38rem;
min-height: 30px;
padding: 0.35rem 0.6rem;
margin-right: 0.28rem;
}
.sender-receivers-list__add-text {
font-size: 0.75rem;
font-weight: 600;
line-height: 1;
}
.sender-receivers-list__count {
position: absolute;
top: -0.45rem;
right: -0.45rem;
min-width: 1.15rem;
height: 1.15rem;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.65rem;
font-weight: 700;
padding: 0 0.3rem;
}
.sender-receiver-chip {
display: inline-flex;
align-items: center;
gap: 0.75rem;
min-width: 220px;
gap: 0.35rem;
min-width: 190px;
max-width: 100%;
padding: 0.625rem 0.75rem;
border-radius: 12px;
padding: 0.28rem 0.4rem;
border-radius: 9px;
background: rgba(255, 255, 255, 0.88);
border: 1px solid rgba(126, 34, 206, 0.12);
border-left: 3px solid var(--receiver-color, #7c3aed);
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.06);
}
.sender-receiver-chip__action {
min-width: auto;
min-height: 26px;
width: 26px;
height: 26px;
padding: 0;
border-radius: 6px;
}
.sender-receiver-chip__action svg {
width: 11px;
height: 11px;
}
.sender-receiver-chip__place-btn--idle {
color: var(--receiver-color, #7c3aed);
border-color: var(--receiver-color, #7c3aed);
@@ -1236,6 +1281,17 @@ body.resizing {
border-color: var(--receiver-color, #7c3aed);
}
.sender-receiver-chip__remove-btn {
color: #b91c1c;
border-color: rgba(185, 28, 28, 0.35);
}
.sender-receiver-chip__remove-btn:hover {
background: #b91c1c;
color: #ffffff;
border-color: #b91c1c;
}
.sender-editor-action-inner {
flex-direction: row;
align-items: center;
@@ -1505,8 +1561,9 @@ body.resizing {
}
.sender-receiver-chip__email {
margin-top: 0.15rem;
font-size: 0.72rem;
margin-top: 0;
font-size: 0.7rem;
line-height: 1.1;
color: #64748b;
white-space: nowrap;
overflow: hidden;

View File

@@ -1116,57 +1116,71 @@
z-index: 2;
}
.sender-create-wizard__progress {
position: relative;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
align-items: center;
gap: 0.5rem;
.sender-create-wizard__stepper-shell {
border: 1px solid rgba(126, 34, 206, 0.12);
border-radius: 10px;
background: rgba(126, 34, 206, 0.04);
padding: 0.75rem;
}
.sender-create-wizard__progress-track {
position: absolute;
left: 12px;
right: 12px;
top: 50%;
height: 2px;
transform: translateY(-50%);
background: rgba(126, 34, 206, 0.18);
z-index: 0;
.sender-create-wizard__stepper {
margin: 0;
}
.sender-create-wizard__step {
position: relative;
z-index: 1;
display: flex;
justify-content: center;
.sender-create-wizard__stepper .mud-step {
pointer-events: none;
}
.sender-create-wizard__step-index {
width: 24px;
height: 24px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.78rem;
.sender-create-wizard__stepper .mud-stepper-actions,
.sender-create-wizard__stepper .mud-stepper-action {
display: none !important;
}
.sender-create-wizard__stepper .mud-step-label {
color: #1f2937;
font-size: 0.86rem;
font-weight: 700;
}
.sender-create-wizard__stepper .mud-step-content {
color: #64748b;
}
.sender-create-wizard__stepper .mud-stepper-vertical .mud-stepper-step {
margin-bottom: 0.25rem;
}
.sender-create-wizard__stepper .mud-step-icon {
color: #64748b;
background: #f8fafc;
border: 1px solid rgba(126, 34, 206, 0.25);
}
.sender-create-wizard__step--active .sender-create-wizard__step-index {
.sender-create-wizard__stepper .mud-step-completed .mud-step-icon,
.sender-create-wizard__stepper .mud-step-current .mud-step-icon {
color: #ffffff;
background: linear-gradient(135deg, #7e22ce 0%, #2a5298 100%);
border-color: transparent;
}
.sender-create-wizard__stepper .mud-step-current .mud-step-icon {
background: linear-gradient(135deg, #7e22ce 0%, #2a5298 100%);
box-shadow: 0 0 0 3px rgba(126, 34, 206, 0.15);
}
.sender-create-wizard__step--done .sender-create-wizard__step-index {
color: #ffffff;
.sender-create-wizard__stepper .mud-step-completed .mud-step-icon {
background: #2a5298;
border-color: transparent;
}
.sender-create-wizard__stepper-description {
font-size: 0.8rem;
line-height: 1.35;
color: #64748b;
margin-top: 0.2rem;
padding-right: 0.35rem;
}
.sender-create-wizard__step-content {
margin-top: 0.7rem;
}
.sender-create-wizard__meta {

View File

@@ -215,7 +215,7 @@ public static class Extensions
Message = faker.Lorem.Paragraph(faker.Random.Number(2, 5)),
TfaEnabled = tfaEnabled,
AddedWhen = DateTime.Now,
CertificationType = (int)CertificationType.AdvancedElectronicSignature,
CertificationType = CertificationType.AdvancedElectronicSignature,
UseAccessCode = false,
ContractType = (int)ContractType.Contract,
Language = "de",

View File

@@ -71,10 +71,12 @@
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
<ProjectReference Include="..\EnvelopeGenerator.Infrastructure.Crypt\EnvelopeGenerator.Infrastructure.Crypt.csproj" />
<ProjectReference Include="..\EnvelopeGenerator.Infrastructure.Notification\EnvelopeGenerator.Infrastructure.Notification.csproj" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net7.0'">
<Compile Remove="Application\Crypt\**\*.cs" />
<Compile Remove="Infrastructure\**\*.cs" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">

View File

@@ -0,0 +1,145 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using EnvelopeGenerator.Infrastructure.Notification;
using Microsoft.Data.SqlClient;
namespace EnvelopeGenerator.Tests.Infrastructure;
[TestFixture]
public class MailTemplatePlaceholderDbTests
{
private static readonly Regex PlaceholderPattern = new(@"\[[A-Z0-9_]+\]", RegexOptions.Compiled);
private static IEnumerable<TestCaseData> DbTemplateCases()
{
foreach (var template in ReadTemplates())
{
yield return new TestCaseData(template)
{
TestName = $"Template_{template.Name}_{template.LangCode}_{template.Guid}"
};
}
}
[Test]
public void Database_contains_templates()
{
Assert.That(ReadTemplates(), Is.Not.Empty);
}
[TestCaseSource(nameof(DbTemplateCases))]
public void Every_placeholder_in_every_db_template_is_replaced(TemplateRow template)
{
var data = new TemplatePlaceholders();
var subject = template.Subject.ReplacePlaceholders(data);
var body = template.Body.ReplacePlaceholders(data);
var originalTokens = PlaceholderPattern
.Matches(template.Subject + template.Body)
.Select(m => m.Value)
.Distinct()
.ToArray();
Assert.Multiple(() =>
{
Assert.That(Unresolved(subject), Is.Empty,
$"'{template.Name}' subject has unresolved placeholders.");
Assert.That(Unresolved(body), Is.Empty,
$"'{template.Name}' body has unresolved placeholders.");
foreach (var token in originalTokens)
{
Assert.That(subject + body, Does.Contain(Expected(token)),
$"'{template.Name}' placeholder {token} was not replaced with its value.");
}
});
}
private static string[] Unresolved(string text)
=> PlaceholderPattern.Matches(text)
.Select(m => m.Value)
.Distinct()
.ToArray();
private static string Expected(string token) => $"__{token.Trim('[', ']')}__";
private static List<TemplateRow> ReadTemplates()
{
const string sql = "SELECT GUID, NAME, SUBJECT, BODY, LANG_CODE FROM TBSIG_EMAIL_TEMPLATE";
using var connection = new SqlConnection(ReadConnectionString());
connection.Open();
using var command = new SqlCommand(sql, connection);
using var reader = command.ExecuteReader();
var templates = new List<TemplateRow>();
while (reader.Read())
{
templates.Add(new TemplateRow(
reader.GetInt32(0),
reader.GetString(1),
reader.IsDBNull(2) ? string.Empty : reader.GetString(2),
reader.IsDBNull(3) ? string.Empty : reader.GetString(3),
reader.IsDBNull(4) ? string.Empty : reader.GetString(4)));
}
return templates;
}
private static string ReadConnectionString()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "EnvelopeGenerator.sln")))
directory = directory.Parent;
if (directory is null)
throw new InvalidOperationException("Solution root (EnvelopeGenerator.sln) was not found.");
var settingsPath = Path.Combine(
directory.FullName,
"EnvelopeGenerator.Server",
"EnvelopeGenerator.Server",
"appsettings.json");
using var stream = File.OpenRead(settingsPath);
using var document = JsonDocument.Parse(stream, new JsonDocumentOptions
{
CommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
});
return document.RootElement
.GetProperty("ConnectionStrings")
.GetProperty("Default")
.GetString()
?? throw new InvalidOperationException($"ConnectionStrings:Default is missing in '{settingsPath}'.");
}
public sealed record TemplateRow(int Guid, string Name, string Subject, string Body, string LangCode);
/// <summary>
/// One property per placeholder used by any row in TBSIG_EMAIL_TEMPLATE.
/// The sentinel value equals the placeholder name, so a wrong mapping is detected.
/// </summary>
private sealed class TemplatePlaceholders
{
public string DocumentProcess { get; set; } = "__DOCUMENT_PROCESS__";
public string DocumentTitle { get; set; } = "__DOCUMENT_TITLE__";
public string LinkToDocumentText { get; set; } = "__LINK_TO_DOCUMENT_TEXT__";
public string LinkToDocument { get; set; } = "__LINK_TO_DOCUMENT__";
public string Message { get; set; } = "__MESSAGE__";
public string NamePortal { get; set; } = "__NAME_PORTAL__";
public string NameReceiver { get; set; } = "__NAME_RECEIVER__";
public string NameSender { get; set; } = "__NAME_SENDER__";
public string SignatureType { get; set; } = "__SIGNATURE_TYPE__";
public string Reason { get; set; } = "__REASON__";
public string ReceiverAction { get; set; } = "__RECEIVER_ACTION__";
public string FinalAction { get; set; } = "__FINAL_ACTION__";
public string FinalStatus { get; set; } = "__FINAL_STATUS__";
public string DocumentAccessCode { get; set; } = "__DOCUMENT_ACCESS_CODE__";
public string TfaExpiration { get; set; } = "__TFA_EXPIRATION__";
public string TfaQrCode { get; set; } = "__TFA_QR_CODE__";
}
}

View File

@@ -0,0 +1,478 @@
using EnvelopeGenerator.Application.Common.Interfaces.Services.Notification;
using EnvelopeGenerator.Infrastructure.Notification;
namespace EnvelopeGenerator.Tests.Infrastructure;
[TestFixture]
public class ReplacePlaceholderExtensionsTests
{
#region Naming
private static IEnumerable<TestCaseData> NamingCases()
{
yield return Naming<PersonModel>("[FIRST_NAME]");
yield return Naming<PersonModel>("[LAST_NAME]");
yield return Naming<IdModel>("[ID]");
yield return Naming<UpperIdModel>("[ID]");
yield return Naming<AcronymModel>("[URL_VALUE]");
yield return Naming<AcronymModel>("[HTTP_STATUS_CODE]");
yield return Naming<AcronymModel>("[XML_HTTP_REQUEST]");
yield return Naming<AcronymModel>("[OTP]");
yield return Naming<DigitModel>("[FIELD1]");
yield return Naming<DigitModel>("[VALUE2_TEXT]");
yield return Naming<DigitModel>("[A1_B2]");
yield return Naming<DigitModel>("[NAME2]");
yield return Naming<MyUrl2ValueModel>("[MY_URL2_VALUE]");
yield return Naming<SingleLetterModel>("[X]");
yield return Naming<LowerCaseModel>("[FIRST_NAME]");
yield return Naming<SnakeCaseModel>("[FIRST_NAME]");
yield return Naming<TfaModel>("[TFA_EXPIRATION]");
}
private static TestCaseData Naming<T>(string placeholder) where T : new()
=> new(new T(), placeholder)
{
TestName = $"Placeholder_{typeof(T).Name}_{placeholder.Trim('[', ']')}"
};
[TestCaseSource(nameof(NamingCases))]
public void Property_names_are_converted_to_upper_snake_case(object data, string placeholder)
{
var result = placeholder.ReplacePlaceholders(data);
Assert.That(result, Is.EqualTo("v"));
}
[Test]
[SetCulture("tr-TR")]
public void Placeholder_names_are_culture_invariant()
{
var result = "[INFO]".ReplacePlaceholders(new LowerCaseInfoModel());
Assert.That(result, Is.EqualTo("v"), "Turkish casing would produce [\u0130NFO]");
}
#endregion
#region Basic replacement
[Test]
public void Replaces_single_placeholder()
{
var result = "[FIRST_NAME]".ReplacePlaceholders(new PersonModel { FirstName = "Ada" });
Assert.That(result, Is.EqualTo("Ada"));
}
[Test]
public void Replaces_multiple_placeholders()
{
var data = new PersonModel { FirstName = "Ada", LastName = "Lovelace" };
var result = "[FIRST_NAME] [LAST_NAME]".ReplacePlaceholders(data);
Assert.That(result, Is.EqualTo("Ada Lovelace"));
}
[Test]
public void Replaces_all_repeated_occurrences()
{
var data = new PersonModel { FirstName = "Ada" };
var result = "[FIRST_NAME]-[FIRST_NAME]-[FIRST_NAME]".ReplacePlaceholders(data);
Assert.That(result, Is.EqualTo("Ada-Ada-Ada"));
}
[Test]
public void Leaves_unknown_placeholder_untouched()
{
var result = "[UNKNOWN_TOKEN]".ReplacePlaceholders(new PersonModel());
Assert.That(result, Is.EqualTo("[UNKNOWN_TOKEN]"));
}
[Test]
public void Returns_template_unchanged_when_it_has_no_placeholders()
{
var result = "Hello world".ReplacePlaceholders(new PersonModel());
Assert.That(result, Is.EqualTo("Hello world"));
}
[Test]
public void Returns_empty_string_for_empty_template()
{
var result = string.Empty.ReplacePlaceholders(new PersonModel());
Assert.That(result, Is.Empty);
}
[Test]
public void Returns_template_unchanged_when_data_has_no_readable_properties()
{
var result = "[ANYTHING]".ReplacePlaceholders(new object());
Assert.That(result, Is.EqualTo("[ANYTHING]"));
}
[Test]
public void Matching_is_case_sensitive()
{
var result = "[name]|[Name]".ReplacePlaceholders(new PersonNameModel { Name = "v" });
Assert.That(result, Is.EqualTo("[name]|[Name]"));
}
[Test]
public void Placeholder_without_brackets_is_untouched()
{
var result = "NAME Receiver".ReplacePlaceholders(new PersonNameModel { Name = "v" });
Assert.That(result, Is.EqualTo("NAME Receiver"));
}
[Test]
public void Double_brackets_leave_the_outer_brackets()
{
var result = "[[NAME]]".ReplacePlaceholders(new PersonNameModel { Name = "v" });
Assert.That(result, Is.EqualTo("[v]"));
}
#endregion
#region Values
[Test]
public void Null_value_is_replaced_with_empty_string()
{
var result = "[NAME]".ReplacePlaceholders(new { Name = (string?)null });
Assert.That(result, Is.Empty);
}
[Test]
public void Custom_ToString_is_used()
{
var result = "[VALUE]".ReplacePlaceholders(new { Value = new CustomValue() });
Assert.That(result, Is.EqualTo("custom"));
}
[Test]
public void Primitive_values_use_ToString()
{
var result = "[COUNT]|[ENABLED]".ReplacePlaceholders(new { Count = 3, Enabled = true });
Assert.That(result, Is.EqualTo("3|True"));
}
[Test]
public void Regex_like_characters_are_inserted_literally()
{
var result = "[PATTERN]".ReplacePlaceholders(new { Pattern = @"$1 {0} \ $&" });
Assert.That(result, Is.EqualTo(@"$1 {0} \ $&"));
}
[Test]
public void Value_containing_its_own_placeholder_is_not_expanded_recursively()
{
var result = "[NAME]".ReplacePlaceholders(new EchoNameModel());
Assert.That(result, Is.EqualTo("[NAME]"));
}
[Test]
public void Unicode_value_is_preserved()
{
var result = "[NAME]".ReplacePlaceholders(new PersonNameModel { Name = "Z\u00fcrich \ud83d\ude00" });
Assert.That(result, Is.EqualTo("Z\u00fcrich \ud83d\ude00"));
}
#endregion
#region Property discovery
[Test]
public void Public_readable_property_is_replaced()
{
var result = "[SHOWN]".ReplacePlaceholders(new DiscoveryModel());
Assert.That(result, Is.EqualTo("shown"));
}
[Test]
public void Get_only_property_is_replaced()
{
var result = "[READ_ONLY]".ReplacePlaceholders(new DiscoveryModel());
Assert.That(result, Is.EqualTo("computed"));
}
[Test]
public void Static_private_write_only_and_indexer_properties_are_ignored()
{
const string template = "[HIDDEN]|[STATIC]|[WRITE_ONLY]|[ITEM]";
var result = template.ReplacePlaceholders(new DiscoveryModel());
Assert.That(result, Is.EqualTo(template));
}
[Test]
public void Inherited_public_property_is_replaced()
{
var result = "[ID]|[NAME]".ReplacePlaceholders(new DerivedModel());
Assert.That(result, Is.EqualTo("base|derived"));
}
[Test]
public void Record_properties_are_replaced()
{
var result = "[FIRST_NAME]/[AGE]".ReplacePlaceholders(new PersonRecord("Ada", 36));
Assert.That(result, Is.EqualTo("Ada/36"));
}
[Test]
public void Struct_data_is_supported()
{
var result = "[NAME]".ReplacePlaceholders(new StructModel { Name = "struct" });
Assert.That(result, Is.EqualTo("struct"));
}
[Test]
public void Anonymous_type_is_supported()
{
var data = new { Name = "anon", Age = 42 };
var result = "[NAME]|[AGE]".ReplacePlaceholders(data);
Assert.That(result, Is.EqualTo("anon|42"));
}
[Test]
public void Generic_type_is_supported_for_reference_type()
{
var result = "[VALUE]".ReplacePlaceholders(new Box<string> { Value = "boxed" });
Assert.That(result, Is.EqualTo("boxed"));
}
[Test]
public void Generic_type_is_supported_for_value_type()
{
var result = "[VALUE]".ReplacePlaceholders(new Box<int> { Value = 5 });
Assert.That(result, Is.EqualTo("5"));
}
#endregion
#region Cache and thread safety
[Test]
public void Cached_metadata_uses_values_of_current_instance()
{
var first = "[FIRST_NAME]".ReplacePlaceholders(new PersonModel { FirstName = "first" });
var second = "[FIRST_NAME]".ReplacePlaceholders(new PersonModel { FirstName = "second" });
Assert.That(new[] { first, second }, Is.EqualTo(new[] { "first", "second" }));
}
[Test]
public void Two_types_with_same_property_name_are_resolved_independently()
{
var a = "[NAME]".ReplacePlaceholders(new TypeA());
var b = "[NAME]".ReplacePlaceholders(new TypeB());
Assert.That(new[] { a, b }, Is.EqualTo(new[] { "a", "b" }));
}
[Test]
public void Concurrent_first_calls_are_thread_safe()
{
var failures = 0;
Parallel.For(0, 500, i =>
{
var expected = $"n{i}";
var actual = "[NAME]".ReplacePlaceholders(new ConcurrentModel { Name = expected });
if (actual != expected)
Interlocked.Increment(ref failures);
});
Assert.That(failures, Is.Zero);
}
#endregion
#region Real DTO
[Test]
public void Document_received_notification_is_fully_replaced()
{
var data = new DocumentReceivedNotification
{
DocumentTitle = "Vertrag",
NameReceiver = "Max",
NameSender = "Erika",
IsReadAndConfirm = true,
AdvancedSignature = false,
LinkToDocument = "https://x.io/a",
Message = "Bitte",
NamePortal = "Portal"
};
const string template =
"[NAME_RECEIVER]|[NAME_SENDER]|[SIGNATURE_TYPE]|[DOCUMENT_TITLE]|[MESSAGE]|[NAME_PORTAL]|[LINK_TO_DOCUMENT]|[LINK_TO_DOCUMENT_TEXT]";
var result = template.ReplacePlaceholders(data);
Assert.That(result, Is.EqualTo("Max|Erika|Signieren|Vertrag|Bitte|Portal|https://x.io/a|https://x.io/a"));
}
#endregion
#region Models
private sealed class PersonModel
{
public string FirstName { get; set; } = "v";
public string LastName { get; set; } = "v";
}
private sealed class PersonNameModel
{
public string Name { get; set; } = "v";
}
private sealed class IdModel
{
public string Id { get; set; } = "v";
}
private sealed class UpperIdModel
{
public string ID { get; set; } = "v";
}
private sealed class AcronymModel
{
public string URLValue { get; set; } = "v";
public string HTTPStatusCode { get; set; } = "v";
public string XMLHttpRequest { get; set; } = "v";
public string OTP { get; set; } = "v";
}
private sealed class DigitModel
{
public string Field1 { get; set; } = "v";
public string Value2Text { get; set; } = "v";
public string A1B2 { get; set; } = "v";
public string Name2 { get; set; } = "v";
}
private sealed class MyUrl2ValueModel
{
public string MyURL2Value { get; set; } = "v";
}
private sealed class SingleLetterModel
{
public string X { get; set; } = "v";
}
private sealed class LowerCaseModel
{
public string firstName { get; set; } = "v";
}
private sealed class LowerCaseInfoModel
{
public string info { get; set; } = "v";
}
private sealed class SnakeCaseModel
{
public string First_Name { get; set; } = "v";
}
private sealed class TfaModel
{
public string TFAExpiration { get; set; } = "v";
}
private sealed class CustomValue
{
public override string ToString() => "custom";
}
private sealed class EchoNameModel
{
public string Name { get; set; } = "[NAME]";
}
private sealed class DiscoveryModel
{
public static string Static { get; set; } = "static";
public string ReadOnly => "computed";
public string WriteOnly
{
set { }
}
public string this[int index] => "indexer";
public string Shown { get; set; } = "shown";
private string Hidden { get; set; } = "hidden";
}
private class BaseModel
{
public string Id { get; set; } = "base";
}
private sealed class DerivedModel : BaseModel
{
public string Name { get; set; } = "derived";
}
private sealed record PersonRecord(string FirstName, int Age);
private struct StructModel
{
public string Name { get; set; }
}
private sealed class Box<T>
{
public T Value { get; set; } = default!;
}
private sealed class TypeA
{
public string Name { get; set; } = "a";
}
private sealed class TypeB
{
public string Name { get; set; } = "b";
}
private sealed class ConcurrentModel
{
public string Name { get; set; } = string.Empty;
}
#endregion
}