Compare commits

...

109 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
06551d502b Update branding to "signFLOW" for consistency
The footer text in `MainLayout.razor` and the header title in `IndexPage.razor` have been updated to change the branding from "SignFlow" to "signFLOW". This change ensures consistent presentation of the brand across the application by adjusting the capitalization.
2026-10-26 09:47:36 +01:00
0f28b80e16 Update notification logic and version numbers
Revised FinalizeEnvelopeCommand to notify each receiver
individually instead of converting documents to Base64.
Adjusted IsActive method in EnvelopeStatusExtensions to
include EnvelopePartlySigned as active. Updated project
version to 1.7.0-beta to reflect these changes.
2026-10-25 18:52:21 +01:00
f7af3776c2 Switch to DocumentService API, remove PKCS#11 support
Replaced PKCS#11-based cryptographic operations with a new DocumentService API integration. Removed all PKCS#11-related code, configurations, and dependencies, including options, self-test status, and signing logic. Updated `FinalizeEnvelopeCommand` to include a notification service and added base64 conversion for documents. Updated `ServiceCollectionExtensions` to register the new DocumentService client. Removed outdated documentation and diagrams related to PKCS#11. Updated project files and configurations to reflect these changes and incremented the version number. Added a comment in `EnvelopeFinalizationWorker` for future RabbitMQ integration.
2026-10-25 18:52:12 +01:00
8ba9e8ec20 Update package versions and DI service registration
Updated `EnvelopeGenerator.Infrastructure.Crypt.csproj` to add `DocumentService.Client` version `1.3.0` and upgraded several Microsoft.Extensions packages to version `10.0.10`.

In `Program.cs`, changed `DocumentService` registration to use the fully qualified name `EnvelopeGenerator.Server.Client.Services.DocumentService`.

Updated `EnvelopeGenerator.Tests.csproj` to upgrade `Microsoft.Extensions.DependencyInjection` and related packages to version `10.0.10`, including changes for the `net8.0` target framework.
2026-10-22 03:03:37 +02:00
226d852c6f Add envelope finalize endpoint 2026-10-22 01:35:26 +02:00
fb155fe706 Wire notification service and finalization worker into server host 2026-10-22 01:35:22 +02:00
658cb28e1c Add notification infrastructure project 2026-10-22 01:35:18 +02:00
f38098aed8 Add envelope finalization command and signed-envelope query 2026-10-22 01:35:14 +02:00
00c3fd18a9 Add envelope notification contracts 2026-10-22 01:35:10 +02:00
8929c517fe Map envelope DocResult and expose burned-state indicator 2026-10-22 01:35:06 +02:00
07ce6ccfe1 Use AuthorizationException for domain-specific errors
Replaced UnauthorizedAccessException with a custom
AuthorizationException in SignPdfByEnvelopeCommandHandler
to provide more meaningful error handling. Added the
EnvelopeGenerator.Domain.Exceptions namespace to support
this change.
2026-10-21 19:07:35 +02:00
26beae0d14 Add support for signed PDF previews
Integrated `PdfSignatureService` to enable fetching and previewing signed PDFs for completed envelopes. Updated `SenderPage.razor` to dynamically adjust popup headers and filenames based on the envelope's status. Enhanced `OpenPreviewAsync` to conditionally fetch signed or original PDFs.

Registered `PdfSignatureService` and DevExpress PDF Viewer in `Program.cs`. Implemented `PdfSignatureService` to handle API calls for retrieving signed PDFs with error handling.
2026-10-21 17:46:38 +02:00
e423c6d751 Allow receiver signing command to reuse an existing document stream 2026-10-21 01:28:16 +02:00
56d1dc050f Add sender endpoint for envelope-level PDF signing 2026-10-21 01:28:07 +02:00
016dec74f9 Add domain exception handling for authorization and data conflicts 2026-10-21 01:27:52 +02:00
b9cd629766 Add e-signature support and improve PDF signing process
Introduced `ESignParams` for configurable e-signature settings,
including `ApplicationName` and `DefaultReason`. Updated
`DocReceiverElementDto` and `PdfSignOptionsDto` to support
new properties. Registered `ESignParams` in DI container.

Refactored `SignPdfByElementCommand` to include new fields
and improved error handling. Added `SignPdfByEnvelopeReceiverCommand`
to enable signing documents for envelope receivers.

Replaced DevExpress with PdfSharp for PDF rendering in
`ReceiverSignedPage.razor`, improving layout and flexibility.
Enhanced `PdfSignatureController` with a new endpoint for
signing documents by envelope key.

Added `DataIntegrityException` for better error handling.
Updated `appsettings.json` with `ESignParams` configuration.
2026-10-20 17:04:15 +02:00
e729adb441 Support flexible coordinate systems for PDF signatures
Added enums `PdfSignatureCoordinateUnit` and `PdfSignatureCoordinateOrigin` to support defining signature coordinates in points or inches and relative to either the bottom-left or top-left corner of the page.

Updated `PdfSignatureAppearanceDto` to include `CoordinateUnit` and `CoordinateOrigin` parameters with default values. Refactored `PdfDigitalSignatureService` to use a new `BuildSignatureBounds` method for calculating signature bounds, which handles unit conversion, origin adjustments, and validation.

Introduced `GetPageHeightInPoints` to retrieve page height for top-left origin calculations. Improved signature field naming precision and added error handling for invalid dimensions and page numbers.

Included a TODO for optimizing the signature check algorithm.
2026-10-20 14:18:20 +02:00
67d0b07926 Add support for signing PDFs with DocReceiverElement
Extended `DocReceiverElementDto` to include an optional `Document` reference. Enhanced `ReadDocReceiverElementQuery` with `Id` and `IncludeDocument` properties for filtering and document inclusion.

Updated `ReadDocReceiverElementQueryHandler` to handle new query properties, enabling filtering by `Id` and optionally including associated documents.

Introduced `SignPdfByElementCommand` and its handler to support signing PDFs using either a provided `DocReceiverElementDto` or an `ElementId`. Added validation via `SignPdfByElementCommandValidator` to ensure proper usage.

Refactored and added necessary `using` directives for new dependencies.
2026-10-20 10:55:41 +02:00
780277d656 Add DATABASE_STRUCTURE_CONTEXT.md for DB documentation
Added `DATABASE_STRUCTURE_CONTEXT.md` to provide detailed
documentation of the database structure for the envelope
domain. This includes entity-to-table mappings, schema
snapshots, EF relationships, constraints, stored procedure
behavior, and trigger wiring paths.

Highlighted model-to-database drift risks, trigger mismatch
alerts, and provided migration-safe operating rules. Added a
refresh checklist to validate metadata consistency before and
after database migrations.
2026-10-20 10:07:25 +02:00
635d5d8278 Refactor namespaces for CreateEnvelopeReceiver
Updated namespaces for `CreateEnvelopeReceiverCommand` and related classes to `EnvelopeGenerator.Application.EnvelopeReceivers.Commands.CreateEnvelopeReceiver` for better organization and clarity.

- Updated namespaces in `CreateEnvelopeReceiverCommand`, `CreateEnvelopeReceiverCommandHandler`, and `CreateEnvelopeReceiverResponse`.
- Adjusted imports in `MappingProfile.cs`, `EnvelopeReceiverService.cs`, and `Fake.cs` to reflect the new namespace.
- Modified `EnvelopeReceiverController.cs` to use the updated namespace and simplified type reference in a `foreach` loop.
2026-10-19 15:50:32 +02:00
f1c8e223dd Refactor ComposeSignaturePreviewPng to return Stream
Updated the `ComposeSignaturePreviewPng` method in the `IPdfPageImageRenderer` interface to return a `Stream` instead of a `byte[]` for improved memory efficiency when handling large image data.

- Added `ComposeSignaturePreviewPngBytes` extension method to convert the `Stream` to a `byte[]` for backward compatibility.
- Updated `PdfPageImageRenderer` to return a `Stream` and adjusted its internal logic to reset the `MemoryStream` position before returning.
- Modified `DevSignatureInkController` to use the `Stream`-based implementation for HTTP responses.
- Made minor formatting adjustments in `PdfPageImageRenderer`.

These changes optimize memory usage while maintaining compatibility with existing workflows that require `byte[]`.
2026-10-19 14:49:48 +02:00
31bf64a6f2 Refactor TODOs for licensing and maintainability
Added a critical pre-commercial release TODO to remove
`SixLabors.ImageSharp` and `QRCoder-ImageSharp` dependencies
and replace them with permissive-license alternatives. Updated
TODOs to focus on modularizing `EnvelopeSenderPage.razor`
and other server pages by splitting large components,
centralizing shared logic, and improving maintainability.

Included a TODO for a senior-level performance review of
sender/receiver flows to address route transitions, loading
logic, and perceived latency issues. Removed outdated TODO
on reducing `EnvelopeSenderPage.razor` complexity.
2026-10-19 14:43:11 +02:00
93142a56f7 Add signature ink handling and DevExpress integration
Introduced `ISignatureInkPayload` interface to standardize signature ink data and metadata. Updated `DocReceiverElementDto` and `DocReceiverElement` to implement this interface. Enhanced `IPdfPageImageRenderer` with methods for signature preview generation and image format validation.

Replaced `PdfSharp` with `DevExpress` for PDF rendering in `ReceiverSignedPage.razor`. Added `DevSignatureInkController` with endpoints for downloading raw and composed signature images. Updated project files to remove `SixLabors.ImageSharp` and integrate DevExpress.

Added configuration for development endpoints in `appsettings.json`. Improved exception handling, validation, and code maintainability.
2026-10-19 14:27:21 +02:00
64baef907c Replace UUID with DocumentElementId across the codebase
Updated `PdfSignOptionsDto` and `PdfSignatureCheckItemDto` to use `DocumentElementId` (long) instead of `UUID` (string). Modified `PdfDigitalSignatureService` to reflect this change, including updates to `SignatureReasonMetaRegex`, `BuildReasonWithMeta`, and `ParseReasonMeta`. Adjusted method signatures, return values, and property assignments to ensure consistent use of `DocumentElementId`. These changes align with a shift in the application's data model to use numeric identifiers for document elements.
2026-10-19 11:22:12 +02:00
232b341894 Refactor PDF signing logic and appearance handling
Refactored `Pkcs11Options` by removing `DefaultCertificationLevel` and improving property documentation. Replaced `VisualSignature` in `PdfSignOptionsDto` with `Appearance` using the new `PdfSignatureAppearanceDto` class, which encapsulates signature appearance details, including font and color settings.

Enhanced `PdfSignatureCertificationLevel` enum with more descriptive values and added XML documentation. Updated `IPdfDigitalSignatureService` to support optional signature images and removed redundant methods.

Refactored `PdfDigitalSignatureService` to streamline appearance handling, validate inputs, and improve maintainability. Removed unused methods and added support for `PdfSignatureAppearanceDto`.

Simplified `PdfSignatureController` by consolidating form properties into `PdfSignOptionsDto`. Improved code readability and flexibility while removing redundant logic.
2026-10-19 10:48:06 +02:00
57a08e8a89 Refactor DTOs and simplify PdfDigitalSignatureService
Refactored `PdfSignOptionsDto` and `PdfVisualSignatureOptionsDto`:
- Converted both to immutable `record` types with non-nullable properties.
- Added XML documentation comments for properties and parameters.

Simplified `PdfDigitalSignatureService`:
- Updated `SignPdfAsync` to enforce non-nullable `PdfSignOptionsDto`.
- Removed redundant null checks and inlined `CreateSignatureBounds`.
- Simplified signature appearance and image data logic.
- Refactored `TryGetKeySize` to use `bool` with `out` parameter.

General improvements:
- Enhanced code readability and maintainability.
- Reduced nullable references and enforced stricter type safety.
2026-10-16 10:43:11 +02:00
198eabcbae Add support for visual PDF signatures
Enhanced `PdfSignOptionsDto` with a `VisualSignature` property and introduced the `PdfVisualSignatureOptionsDto` class for configuring visual signature options. Updated `PdfDigitalSignatureService` to handle visual signatures, including page number resolution, signature bounds calculation, and appearance customization.

Refactored `PdfSignatureController` to support visual signatures by extending `SignPdfForm` with `SignatureImageFile` and `VSignature` properties. Improved validation for page numbers and signature dimensions. Streamlined `Sign` and `IsSigned` endpoints for better clarity and reduced redundancy.

Added XML documentation for improved API clarity and performed general code cleanup to enhance maintainability.
2026-10-16 10:10:04 +02:00
04cea1ca4b Refactor to use streams for PDF handling
Refactored `IPdfDigitalSignatureService` to replace `byte[]` with `Stream` for input and output, improving memory efficiency and scalability. Updated `PdfDigitalSignatureService` implementation to support stream-based operations, including signing, checking, and extracting PDFs.

Modified `SignPdfAsync` to handle multiple signatures sequentially using streams and ensured proper disposal of intermediate streams. Updated `CheckPdfSignatureAsync` and `ExtractOriginalPdfAsync` to use streams, reducing memory overhead.

Refactored `PdfSignatureController` to use streams for file handling, enabling efficient processing of large files. Introduced helper methods for stream handling and enhanced cancellation support. Improved code maintainability by consolidating common logic and reducing duplication.
2026-10-15 11:04:05 +02:00
29108594d9 Update project version to 1.5.0-beta
Updated the `EnvelopeGenerator.Server` project version from `1.4.0-beta` to `1.5.0-beta`.
This includes updates to the file version (from `1.4.0.0` to `1.5.0.0`)
and the assembly version (from `1.4.0.0` to `1.5.0.0`).
2026-10-14 15:11:19 +02:00
51c0f51738 Refactor contact info handling in PDF signing
Replaced the `ContactInfo` property with separate `Email` and `Phone` fields in `PdfSignOptionsDto` and `PdfSignatureCheckItemDto` for better granularity. Updated the `PdfDigitalSignatureService` to handle the new fields, introducing `BuildContactInfo` and `ParseContactInfo` methods for constructing and parsing contact information.

Modified the `PdfSignatureController` to reflect these changes, updating request models and the `SignPdfAsync` method. Updated the `README.md` to document the new structure. These changes improve the clarity and structure of contact information handling.
2026-10-14 15:10:55 +02:00
816bbce9c6 Remove client-side SigningTime handling
The `SigningTime` property has been removed from the `PdfSignOptionsDto` class and related client-side input handling. It is now set automatically on the server side based on the `UseUtcSigningTime` configuration (`UtcNow` or local time).

Updated the `PdfDigitalSignatureService` to always set `SigningTime` server-side, ensuring consistent and deterministic behavior. Removed `SigningTime` from the `PdfSignatureController` and adjusted the logic to exclude it from the `signOptions` object.

Updated the `README.md` to reflect these changes, clarifying that `SigningTime` is now server-side only. These changes improve security and simplify client integration.
2026-10-14 14:49:15 +02:00
5194d638ac Refactor README.md with localized and detailed guidance
The README.md has been extensively restructured to improve clarity, technical depth, and usability. Key changes include:

- Shifted most sections from English to German for localization.
- Consolidated distributed runbooks into a unified reference for PKCS#11/SoftHSM integration.
- Expanded architecture overview with deployment patterns and runtime paths.
- Replaced PKCS#11 basics with detailed setup instructions and troubleshooting tips.
- Updated runtime configuration examples with security best practices.
- Highlighted implemented services, models, and DTOs in the DI setup.
- Added sections on operational challenges, troubleshooting, and monitoring.
- Introduced an API health endpoint and smoke test recommendations.
- Clarified API behavior for signature metadata to ensure deterministic integration.
- Expanded security guidelines, including key rotation and SSH rollback procedures.
- Added a forward-looking section on potential improvements.
- Retained and updated references to official documentation and tools.

These changes aim to provide a comprehensive, structured, and production-aligned guide for developers and engineers.
2026-10-14 14:47:40 +02:00
6b3622b366 Remove test endpoint from PdfSignatureController
Removed the `[HttpGet("sign/test")]` endpoint and its `Test`
method from the `PdfSignatureController`. This method
contained hardcoded Base64 PDF data and predefined signing
options for testing purposes. Eliminated the associated
logic for decoding the Base64 string, creating signing
options, signing the PDF, and returning the signed file.

These changes streamline the controller by removing
unnecessary and potentially insecure functionality,
ensuring cleaner and more production-ready code.
2026-10-14 14:35:58 +02:00
4f8fa9afd8 Rename DefaultApplicationName to ApplicationName
Updated the `Pkcs11Options` class to rename the `DefaultApplicationName` property to `ApplicationName`, clarifying its role as a globally controlled value embedded in PDF signature metadata.

Modified `PdfDigitalSignatureService` to source `ApplicationName` directly from `Pkcs11Options`, removing client-level overrides. Updated `PdfSignatureController` to reflect this change by removing the `ApplicationName` property from `SignOptions`.

Updated `appsettings.json` and `README.md` to reflect the property name change. These changes enforce stricter configuration control, improving metadata consistency and security.
2026-10-14 14:34:59 +02:00
7a36316b7e Standardize CertificationLevel in PDF signing
The `CertificationLevel` property was removed from the `PdfSignatureOptions` class. It is now explicitly set to `PdfSignatureCertificationLevel.FormFillingAndAnnotations` in all instances where `PdfSignatureOptions` is instantiated.

Previously, `CertificationLevel` was nullable and dynamically set from the `request` object or defaulted to `PdfSignatureCertificationLevel.NoCertification`. These changes ensure consistent behavior by standardizing the certification level to `FormFillingAndAnnotations`.
2026-10-14 14:31:54 +02:00
68d9dbdcdf Add UUID and metadata support for PDF signing
Enhanced PDF signing with UUID and metadata handling:
- Added `UUID` and `OriginalDocumentSha256` to `PdfSignOptionsDto` and `PdfSignatureCheckItemDto`.
- Introduced `BusinessReason` to separate metadata from signature reasons.
- Updated `PdfDigitalSignatureService` to embed and parse metadata in signature reasons.
- Improved error handling to include metadata in validation results.
- Updated `PdfSignatureController` to handle `UUID` in signing requests.

Refactored PKCS#11 configuration:
- Replaced `LibraryPath` with `AssetsRootPath` for asset resolution.
- Added `ProxySocket` and `DefaultApplicationName` options.
- Clarified runtime behavior for asset resolution in `README.md`.

Updated documentation and examples to reflect these changes.
2026-10-14 14:24:51 +02:00
7f3d1afcb9 Add SoftHSM SSH and SFTP helper scripts 2026-10-14 13:49:56 +02:00
955ac57a1a Add original revision extraction for signed PDFs 2026-10-14 13:49:46 +02:00
b8a96330c0 Add PKCS#11 startup self-test health reporting 2026-10-14 13:49:38 +02:00
8f2744afc7 add soft hsm lib 2026-10-14 13:11:23 +02:00
c8ddfac8d0 Improve PDF signing and validation logic
Updated `.gitignore` to exclude new files and directories, including `FodyWeavers.xsd` and `appsettings.Secrets.json`.

Refactored `PdfDigitalSignatureService`:
- Improved signing time handling by calculating fallback inline.
- Simplified `SignPdfAsync` by inlining signature building.
- Added `ParseByteRanges` to extract `/ByteRange` values from PDFs.
- Introduced `CoversWholeDocument` to validate document coverage.

Enhanced signature validation:
- Integrated `ParseByteRanges` and `CoversWholeDocument`.
- Streamlined `PdfSignatureCheckItemDto` construction.
- Updated properties to calculate values inline.
- Improved `Unchanged` property to check cryptographic validity and document coverage.

These changes enhance maintainability, readability, and functionality.
2026-10-13 18:52:28 +02:00
1942d78155 Add cryptographic infrastructure and configuration
Integrated cryptographic functionality by adding a reference to `EnvelopeGenerator.Infrastructure.Crypt.csproj`. Updated `Program.cs` to include dependency injection for cryptographic services and load `appsettings.Secrets.json` for sensitive configurations. Added a new `Crypt` section in `appsettings.json` to configure PKCS#11 cryptographic operations, enhancing security and signing capabilities.
2026-10-13 14:53:49 +02:00
f361898ba4 Add PKCS#11-based PDF signing infrastructure
Introduced a cryptographic infrastructure for PDF signing and validation using PKCS#11-based hardware security modules (HSMs) or software tokens. Key changes include:

- Added `PdfDigitalSignatureService` for signing PDFs, multi-signature support, and signature validation.
- Implemented `Pkcs11ExternalSigner` and `Pkcs11PdfSigningRsa` for PKCS#11-based signing with DevExpress PDF infrastructure.
- Added utilities like `Pkcs11SessionRunner`, `Pkcs11InteropBootstrap`, and `Pkcs11ToolBridge` for session management, library initialization, and fallback mechanisms.
- Introduced `Pkcs11Options` for managing PKCS#11 configurations.
- Updated project dependencies to include `DevExpress.Document.Processor`, `System.Security.Cryptography.Pkcs`, and `Pkcs11Interop`.
- Enhanced error handling and fallback support for PKCS#11 operations.

These changes enable robust and extensible PDF signing capabilities with detailed signature metadata extraction and validation.
2026-10-13 14:45:47 +02:00
7766e2ea25 Add PKCS#11 support for PDF signing and validation
Introduced `Pkcs11Options` for configuring PKCS#11 signing workflows, including properties for library path, token selection, and signing metadata. Added `PdfSignOptionsDto` to represent signing options and `PdfSignatureCheckResultDto` with related DTOs for signature validation.

Defined `PdfSignatureCertificationLevel` enum for certification levels. Added `IPdfDigitalSignatureService` interface to support signing and validating PDF signatures. These changes enable robust and extensible handling of digital signatures.
2026-10-13 14:44:06 +02: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
63c60a91b2 Add multi-targeting support for net7.0 and net8.0
Updated the project to support both net7.0 and net8.0 frameworks.
Modified `<TargetFramework>` to `<TargetFrameworks>` and added
conditional `<ItemGroup>` entries for dependencies and project
references specific to each framework. Updated package versions
(e.g., Microsoft.Extensions.DependencyInjection to 9.0.6) and
introduced new dependencies for net8.0, including
Microsoft.EntityFrameworkCore 8.0.17. Added a `<Compile Remove>`
directive for net7.0 to exclude Crypt-related files. Ensured
compatibility and maintained backward support for net7.0.
2026-10-08 10:33:11 +02:00
a5d2c477f6 Add EnvelopeGenerator.Infrastructure.Crypt project
A new project, `EnvelopeGenerator.Infrastructure.Crypt`, has been added to the solution. This project targets `.NET 8.0` and includes several `Microsoft.Extensions` dependencies (e.g., `Configuration.Binder`, `DependencyInjection.Abstractions`, etc.) and the `Pkcs11Interop` package.

The solution file (`EnvelopeGenerator.sln`) has been updated to include the new project, its build configurations, and its nesting under the same parent as `EnvelopeGenerator.Infrastructure.Doc`.
2026-10-08 10:32:48 +02:00
3a71d57619 Enhance FES docs, plans, and SoftHSM integration
Updated README.md for clarity and added official references. Introduced detailed infrastructure and integration plans for FES, including phased implementation, deployment options, and risk mitigations. Added FES workflow diagrams in draw.io and PDF formats. Documented SoftHSM testing process and integration details in ticket.md. Improved alignment with clean architecture principles and production readiness.
2026-10-08 10:31:00 +02:00
73b6c2712c Update ResourceManager references in translation functions
Updated the `TranslateCertificationType` and `TranslateFinalEmailType` functions to use the `ResourceManager` from the `CommonServices.My.Resources.Model` namespace instead of the `Resources.Model` namespace for consistency and improved resource management.
2026-10-08 10:16:22 +02:00
200972c370 Update README with PKCS#11 integration details
Added a detailed README for the `EnvelopeGenerator.Infrastructure.Crypt` project, explaining its purpose, functionality, and integration with PKCS#11-based cryptographic workflows.

Highlights include:
- Overview of the project's role in FES workflows.
- Explanation of SoftHSM and PKCS#11 deployment patterns.
- Mapping of functionality to FES diagram steps 6-8.
- Documentation of service interfaces and DI entry point.
- Configuration guidance with JSON examples and security best practices.
- Instructions for obtaining PKCS#11 values and setting up SoftHSM.
- Checklist for IT handover and environment setup.
- Steps for running cryptographic tests with PowerShell and Linux examples.
- Common failure diagnostics and their meanings.
- Clarification of signing semantics and validation requirements.
- FAQ addressing common questions about SoftHSM and PKCS#11 usage.

This update ensures clear guidance for developers and IT teams to configure and integrate the cryptographic layer effectively.
2026-10-07 13:35:50 +02:00
9f7519941a Improve bootstrap handling in SenderEditorPage
Added a `DxLoadingPanel` to display a loading indicator during the bootstrap process. Introduced the `IsBootstrapLoading` property to track bootstrap progress. Refactored `OnParametersSetAsync` to handle bootstrap tokens more effectively, including redirecting to `/sender` if no token is provided. Updated `TryApplyBootstrapAsync` to adjust navigation behavior when the bootstrap payload is `null`.

Enhanced `OnAfterRenderAsync` to process `_pendingBootstrapToken` more cleanly and removed redundant logic. Simplified navigation and improved code readability and maintainability.
2026-10-06 13:59:39 +02:00
66e58b4a1a Add envelope settings step to wizard
Introduced a new step (step 2) in the `CreateEnvelopeWizardPopup` for configuring envelope settings, including security, reminders, and expiration options.

- Added fields for certification type, final email options, 2FA, access codes, and reminder settings.
- Updated step titles, descriptions, and total steps from 4 to 5.
- Implemented validation logic for the new settings step.
- Added methods to handle changes in settings fields and apply defaults.
- Updated `CreateEnvelopeWizardResult` and `CreateEnvelopeBootstrapPayload` to include new fields.
- Modified `SenderEditorPage` and `SenderPage` to handle new settings.
- Styled the new step with updated CSS for grids, groups, and toggles.
- Refactored state reset logic to initialize new fields.
2026-10-06 12:59:55 +02:00
de9d8c6f37 Add document preview feature to wizard popup
Introduced a document preview feature in the `CreateEnvelopeWizardPopup` component. This includes:

- Injected `PdfRenderService` for rendering PDF pages.
- Replaced document metadata display with a preview card.
- Added `SenderPdfImageEditor` for interactive PDF previews.
- Implemented state management for preview pages and overlays.
- Added fallback logic to estimate PDF page count.
- Enhanced `ResetWizardState` to reset preview-related fields.
- Styled the preview card and wrap with new CSS classes.
- Added placeholder event handlers for editor interactions.
2026-10-06 11:48:03 +02:00
47f48ba12a Add PdfFileDropZone for interactive PDF uploads
Replaced static upload message in `CreateEnvelopeWizardPopup.razor` with a new `PdfFileDropZone` component to enhance user experience. The component supports drag-and-drop functionality, file selection, and dynamic states (e.g., disabled, drag-active).

Introduced `PdfFileDropZone.razor` with customizable parameters such as `Disabled`, `HasDocument`, `UploadText`, and `DragDropHintText`. Added event handling for drag-and-drop interactions and file selection.

Updated `sender-page.css` to include styles for the new component, ensuring seamless integration with the existing layout. Adjusted styles for hover effects, drag-active state, and disabled state.

These changes improve usability and make PDF uploads more intuitive.
2026-10-06 10:31:43 +02:00
60241c9c6d Refactor receiver suggestions filtering logic
Introduced `ReceiverSuggestionFilterHandler` to refine the
filtering of recent receiver suggestions based on name and
email. Added `SuggestionLimit` constant to replace hardcoded
limits and ensure consistency. Updated UI to use the new
`FilteredRecentSuggestions` property, which leverages the
`BuildFilteredRecentSuggestions` method for grouping,
ordering, and limiting suggestions. Adjusted
`OnDocumentSelectedAsync` to use the new constant. Added
necessary `@using` directive for the filter handler.
2026-10-06 09:26:43 +02:00
2a939b4cad Add filtering logic for receiver suggestions
Introduce `ReceiverFilterCriteria` to encapsulate filtering criteria for receivers, including normalization of `FullName` and `EmailAddress`. Add `ReceiverSuggestionFilterHandler` to filter `RecentReceiverSuggestionDto` objects based on the criteria. Includes methods for matching criteria and filtering suggestions.
2026-10-06 09:26:22 +02:00
e89d471c6f Refactor receiver input and improve UI/UX
Replaced receiver list with a tag-based `DxTagBox` for better flexibility and dynamic updates. Updated receiver management logic to synchronize `_receivers` and `_receiverTags`. Enhanced accessibility with ARIA attributes for the suggestion list.

Redesigned the "Add Receiver" button with custom styles, hover effects, and an inline SVG icon. Improved the suggestion list with horizontal scrolling and nowrap styling. Styled tags and added a remove button for each tag.

Increased recent receiver suggestions fetch limit to 20. Added validation to ensure default envelope type selection. Refactored empty state handling and cleaned up redundant code for better maintainability.
2026-10-05 21:58:51 +02:00
ece676fbcf Make Take optional and refactor query handling logic
Updated the `Take` property in `ReadRecentReceiverSuggestionsQuery` to be nullable, allowing for unlimited results when not specified. Refactored the `Handle` method in `ReadRecentReceiverSuggestionsQueryHandler` to conditionally apply the `Take` limit only when provided. Renamed the cancellation token parameter for consistency. Improved code clarity and flexibility by removing redundant operations and dynamically applying the `Take` logic.
2026-10-05 17:06:47 +02:00
fac4ec28a4 Add localization and UI updates for receiver actions
Added new localized strings for "Recently used recipients" and
"Skip" in German, English, and French resource files. Updated
the `CreateEnvelopeWizardPopup.razor` to enhance the "Add
Receiver" button with a primary render style and a new CSS
class. Introduced styles for the button in `sender-page.css`
to improve its appearance with a minimum width, bold font,
and box shadow.
2026-10-05 17:02:12 +02:00
a3b11ed63f Add recent receiver suggestions to wizard
Introduced a feature to display and apply recent receiver
suggestions in the `CreateEnvelopeWizardPopup` component.
Added a new section in step 2 to show recently used receivers,
with loading and state management logic. Updated the
`ReceiverService` to fetch suggestions from the backend API.

Enhanced the UI with new styles for the suggestions section
and improved localization support. Refactored state
management to handle loading, resetting, and applying
suggestions. Minor cleanup and adjustments to ensure proper
state transitions.
2026-10-05 16:53:57 +02:00
5627483589 Make RecentReceiverSuggestionDto immutable; refactor query
Updated RecentReceiverSuggestionDto to use `init` properties,
adding immutability and a new `LastUsedAt` field. Refactored
ReadRecentReceiverSuggestionsQueryHandler to simplify query
logic, remove `IMapper` dependency, and improve determinism
in results. Introduced tie-breaking for duplicate timestamps
and optimized ordering by `LastUsedAt` and `EmailAddress`.
Removed redundant `Include` and mapping steps for clarity.
2026-10-05 16:43:34 +02:00
bf23e88cf8 Add plan for recent receiver suggestions feature
Added a detailed plan document outlining the development of a "recent receiver suggestions" feature for step 3 of the sender wizard. The plan defines team working rules, objectives, functional requirements, and architecture direction across application, server, and client layers.

The implementation is divided into six phases, covering MediatR query creation, API endpoint addition, client integration, UI updates, regression testing, and technical debt tracking. Each phase includes black-box test suggestions to ensure quality and robustness. The plan emphasizes incremental development, user approval, and quality-focused processes.
2026-10-05 15:58:58 +02:00
9435561e78 Add recent receiver suggestions query and DTO
Introduce `RecentReceiverSuggestionDto` to represent recent receiver suggestions with `FullName` and `EmailAddress`.

Add `ReadRecentReceiverSuggestionsQuery` to retrieve recent receiver suggestions scoped by sender username and limited to 3-5 results.

Implement `ReadRecentReceiverSuggestionsQueryHandler` to process the query using Entity Framework Core and AutoMapper.

Update `MappingProfile` to map `EnvelopeReceiver` to `RecentReceiverSuggestionDto`.
2026-10-05 15:55:19 +02:00
db1e91a3f6 Set C# language version to latest in project file
Added the `<LangVersion>` property to `EnvelopeGenerator.Application.csproj` with the value `latest`. This ensures the project uses the most up-to-date C# language features supported by the compiler, enabling access to new enhancements and features as they become available.
2026-10-05 15:32:41 +02:00
0c664c0995 Improve UX, security, and maintainability
- Introduced a DevExpress toast-based notification system using `DxToastProvider` and `IToastNotificationService` for consistent feedback.
- Standardized request-to-domain mapping with AutoMapper/profile conventions or reflection-based mappers, enforced at the Core library level.
- Removed dynamic SQL string composition and migrated to parameterized queries to address SQL injection vulnerabilities. Added CI guardrails to prevent reintroduction.
- Refined UX on `EnvelopeSenderPage.razor` to enforce deterministic row double-click behavior and align row action buttons/tooltips.
- Enabled SPA-style language switching for immediate UI culture updates without page reloads.
- Added sender-scoped caching for "recent receiver suggestions" with short TTL, invalidation hooks, and cross-sender isolation.
2026-10-05 15:20:58 +02:00
5db2f6f1e3 Refactor envelope type selection logic
Removed the placeholder option from the envelope type dropdown to simplify the UI. Introduced a new `GetDefaultEnvelopeTypeId()` method to centralize and enhance the logic for determining the default envelope type. This method prioritizes envelope types with titles containing "Handwritten Signature" if available, improving alignment with user preferences. Updated `_selectedEnvelopeTypeId` initialization to use the new method for better maintainability.
2026-10-05 15:04:09 +02:00
a535e20b0e Add "Next" button translations to localization files
Added a new resource entry for the "Next" button in the
localization files for German (de-DE), English (en-US), and
French (fr-FR). Translations include "Weiter" in German,
"Next" in English, and "Suivant" in French.
2026-10-05 15:01:15 +02:00
bb35a68b1f feat(sender-editor): consume bootstrap payload and auto-save draft
Accept bootstrapToken query parameter and consume one-time payload from sessionStorage-backed service.

Hydrate editor state with title, message, envelope type, receivers, and PDF document from bootstrap data.

Refactor draft save into reusable core flow and trigger non-navigating auto-save after bootstrap is applied.

Prevent stale token replay by clearing query token from URL and handling invalid tokens gracefully.
2026-10-05 14:54:06 +02:00
715add7f51 feat(sender): add create-envelope wizard with bootstrap handoff
Introduce a 4-step modal wizard on SenderPage for creating envelopes.

Validate required fields, support optional skip, receiver management, PDF upload, and keyboard shortcuts.

Persist wizard payload in sessionStorage via token-based bootstrap service and navigate to editor with bootstrapToken.

Register bootstrap storage service in both client and server DI containers and add dedicated sender wizard styling.
2026-10-05 14:53:48 +02:00
621e22701a Add markdown audit and update architecture docs
Added a "Markdown Audit Snapshot" section to document the status, gaps, and recommendations for markdown files in the repository. Included a "Documentation Audit TODO" checklist for tracking updates. Updated the "Active Architecture (Post-Migration)" section to reflect the current Blazor Auto (Server+WASM hybrid) setup. Identified and marked outdated or redundant markdown files for removal or archival. Highlighted the need for periodic synchronization of documentation with implementation progress.
2026-10-05 09:57:56 +02:00
5ef32a2b0c Adjust SenderEditor UI defaults and accordion styling
Changed the `DxAccordionItem` in `SenderEditorPage.razor` to be collapsed by default. Removed unused elements (`sender-settings-chip` and `sender-settings-grid`) from the accordion's content template.

Updated `envelope-viewer.css` to refine the accordion header's appearance: reduced font size, font weight, and padding, and added a `line-height` property for better spacing.
2026-10-02 15:23:57 +02:00
54d16eb3b8 Refactor SenderEditorPage for modernized UI design
Replaced inline styles with reusable CSS classes to improve maintainability, readability, and scalability. Updated the layout and styling of key components, including the action bar, title and message inputs, PDF metadata display, and buttons. Introduced a `DxAccordion` for envelope settings and redesigned the empty state and save result dialog. Added dynamic styling using CSS variables and centralized styles in `envelope-viewer.css`. Removed redundant inline styles and improved overall UI consistency.
2026-10-02 14:59:43 +02:00
ae3264253d Update ILogger type in SenderPage.razor
Changed the `ILogger` dependency injection type parameter from `EnvelopeSenderPage` to `SenderPage` to ensure alignment with the correct class name or intended logging context.
2026-10-02 14:49:38 +02:00
4cbfe52d5b Add localized strings and update localization logic
Added new localized strings to `Model.en.resx`, `Model.fr-FR.resx`,
and `Model.resx` for additional features/settings in English,
French, and German. Updated `SenderEditorPage.razor` to inject
`ModelLocalizer` and modified the `L` function to prioritize
localized strings from `ModelLocalizer` over `UiLocalizer`.
Included `@using EnvelopeGenerator.Domain.Resources` to ensure
access to the `Model` resource file. These changes improve
localization flexibility and maintainability.
2026-10-02 14:46:40 +02:00
27c6b017cd refactor (EnvelopeSenderPage): Rename to SenderPage.razor. 2026-10-02 14:30:11 +02:00
8378c33465 Refactor PDF rendering, UX, and security improvements
Refactored `PdfRenderController.cs` to replace JSON/base64 `byte[]` transport with binary streaming for PDF rendering, improving memory and latency performance for large files.

Introduced a DevExpress toast-based notification system using `DxToastProvider` and `IToastNotificationService` for consistent, actionable feedback.

Standardized request-to-domain mapping with AutoMapper/profile conventions or reflection-based mappers, enforced at the Core library level to reduce drift.

Addressed SQL injection risks by replacing dynamic SQL string composition with parameterized queries, conducted a repo-wide audit, and added CI guardrails.

Improved UX in `EnvelopeSenderPage.razor` by enforcing deterministic row double-click behavior and aligning row action buttons/tooltips.

Implemented SPA-style language switching for immediate UI culture updates without page reloads.

Updated the SQL Server connection string in `appsettings.json`.
2026-10-02 14:04:45 +02:00
c7c4e1194d Force full page reload on language change
Updated the `Navigation.NavigateTo` method in `LanguageControls.razor` to set the `forceLoad` parameter to `true`. This ensures that the application performs a full page reload when the language is changed, improving consistency in language updates.
2026-10-02 13:59:40 +02:00
3b367eff7f Remove EnvelopeSenderEditorPage.razor entirely
The `EnvelopeSenderEditorPage.razor` file was completely removed, including all Razor markup, Blazor components, JavaScript references, CSS styles, and C# code-behind logic.

Key changes:
- Removed the `/sender/editor_legacy` route and all associated imports (`@using`) and dependency injections (`@inject`).
- Deleted the entire HTML structure, including UI elements for PDF editing, signature placement, and receiver management.
- Removed all references to external CSS and JavaScript files.
- Deleted Blazor components like `DxPdfViewer`, `DxPopup`, and `DxTextBox`.
- Removed C# code for state management, lifecycle methods, event handlers, and helper methods.
- Eliminated localization, validation logic, session persistence, and cache management.
- Removed PDF manipulation logic (e.g., placeholder rendering with `PdfSharp`).
- Deleted receiver management logic, including email suggestions and validation.

This change removes the entire functionality of the `EnvelopeSenderEditorPage`, including its UI, backend logic, and external integrations.
2026-10-02 13:39:00 +02:00
127 changed files with 7627 additions and 1882 deletions

1
.gitignore vendored
View File

@@ -370,3 +370,4 @@ FodyWeavers.xsd
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/publish-output
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/tekh_softHSM_test.md
/ignore/EnvelopeGenerator.Form
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/appsettings.Secrets.json

View File

@@ -48,6 +48,16 @@ This section explains every markdown file in the repo: what it contains, why it
- Implementing sender-side PDF.js overlay (signature field placement) — the receiver side is the reference implementation
**Maintenance:** Update when `pdf-viewer.js` API changes, new JS interop functions are added, or the signing flow is modified.
#### `DATABASE_STRUCTURE_CONTEXT.md`
**What:** Deep DB documentation for the envelope domain. Includes table/column snapshot, entity-to-table mapping, EF relationship/constraint map, stored procedure behavior + call sites, and trigger behavior + wiring path.
**Why it exists:** DB is centralized and can evolve independently; this file preserves migration-critical data semantics when historical DB context is hard to recover.
**When you need it:**
- Planning or reviewing DB/schema changes
- Debugging status transitions/history side effects
- Tracing where `PRSIG_*` procedures or DB triggers affect app behavior
- Reconciling Domain entities with physical SQL schema
**Maintenance:** Refresh on each DB migration, trigger/procedure change, or significant Domain/EGDbContext model change.
---
### Deployment Docs
@@ -63,6 +73,36 @@ This section explains every markdown file in the repo: what it contains, why it
---
## Markdown Audit Snapshot (2026-10-05)
The following table summarizes repository markdown files by practical status: completed, partially complete, or removable candidates.
| File | Type | Status | Missing / Gap | Removal Recommendation |
|---|---|---|---|---|
| `AGENTS.md` | Agent onboarding | Partially complete | Route/migration statements can drift from real code over time. | Keep |
| `COPILOT_CONTEXT.md` | Architecture reference | Partially complete | Some route/render details may be out of sync with other docs; needs regular reconciliation. | Keep |
| `FORM_APPLICATION_CONTEXT.md` | Legacy-to-modern feature spec | Partially complete | Large sections still marked not implemented / needs implementation. | Keep |
| `RECEIVER_PDF_VIEWER_CONTEXT.md` | Receiver PDF viewer deep-dive | Partially complete | Long migration plan needs periodic sync with active implementation. | Keep |
| `SENDER_SAVE_EDIT_MIGRATION_PLAN.md` | Sender workflow implementation plan | Partially complete | No explicit implementation progress markers per phase. | Keep (until migration closes) |
| `SENDER_PDF_TOOLBAR_PLAN_EN.md` | Sender PDF toolbar plan | Incomplete | Explicit pending item remains (`Toolbar visual polish`). | Keep (until done) |
| `SENDER_EDITOR_WASM_PDF_EDITOR_IMPLEMENTATION_PLAN_en.md` | Sender editor WASM migration plan | Incomplete | Plan exists but completion/progress tracking is minimal. | Keep (until done) |
| `EnvelopeGenerator.Server/EnvelopeGenerator.Server/README.md` | Deployment guide | Complete (operational) | No critical gap identified. | Keep |
| `envelope-generator-ui/README.md` | Angular scaffold README | Complete but generic | Template text only; low project-specific value. | Removable or replace with project-specific summary |
| `EnvelopeGenerator.Web/wwwroot/lib/typed.js@2.1.0/README.md` | Third-party vendor README | Complete (vendor) | Not project workflow documentation. | Keep with vendor assets |
| `EnvelopeGenerator.Web/wwwroot/lib/jquery-validation/LICENSE.md` | Third-party license | Complete (legal) | No gap. | Keep |
| `EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/css/open-iconic/README.md` | Third-party vendor README | Complete (vendor) | Not project-specific guidance. | Keep with vendor assets |
| `ignore/EnvelopeGenerator/EnvelopeGenerator.Web/wwwroot/README.md` | Legacy PSPDFKit note | Outdated | Mentions deprecated PSPDFKit-based flow; may mislead if reused. | Removable/archive |
| `ignore/EnvelopeGenerator/EnvelopeGenerator.Web/wwwroot/lib/typed.js@2.1.0/README.md` | Duplicate vendor README | Redundant | Duplicate copy under `ignore/`. | Removable/archive |
| `ignore/EnvelopeGenerator/EnvelopeGenerator.Web/wwwroot/lib/bootstrap-icons/README.md` | Vendor README in archive area | Secondary | Archive-only utility, not active docs. | Removable/archive |
| `ignore/EnvelopeGenerator/EnvelopeGenerator.Web/wwwroot/lib/jquery-validation/LICENSE.md` | Duplicate license in archive area | Redundant | Duplicate copy under `ignore/`. | Removable/archive (if legal policy allows) |
| `ignore/EnvelopeGenerator/EnvelopeGenerator.GeneratorAPI/ClientApp/envelope-generator-ui/README.md` | Duplicate Angular scaffold README | Redundant | Generic template + duplicate in archive path. | Removable/archive |
### Documentation Audit TODO
- [ ] Validate this markdown audit table against the current codebase and update statuses (`complete`, `partial`, `incomplete`, `removable`) when implementation changes land.
---
## Active Architecture (Post-Migration)
**Frontend:** Blazor Auto (Server+WASM hybrid)
@@ -238,6 +278,7 @@ Manual testing workflow:
## Technical Debt TODOs
- **CRITICAL (Licensing / pre-commercial release):** Remove all `SixLabors.ImageSharp` and `QRCoder-ImageSharp` package dependencies from production/runtime projects before commercial release. Replace remaining QR/image paths with permissive-license alternatives (no copyleft/share-source obligations), run repo-wide package audit (`*.csproj`), and add CI guardrails to block reintroduction.
- Refactor `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeSenderPage.razor` to reduce complexity.
- Move sender dashboard business/status logic into dedicated services and extension methods (for example effective status resolution, tab classification, receiver signed/rejected detection, and grid-layout filter sanitation).
- Keep `EnvelopeSenderPage.razor` focused on UI composition/state orchestration; avoid embedding heavy domain logic directly in the Razor component.
@@ -250,6 +291,8 @@ Manual testing workflow:
- Eliminate manual property-by-property request→domain mapping drift (example: `CreateEnvelopeCommandHandler`) by introducing a standardized mapping policy (AutoMapper/profile conventions or reflection/source-generator based mapper) and enforcing it across envelope workflows; this needs to be solved in the shared company Core library level so all services get the same guardrails by default.
- P1 security workstream: remove dynamic SQL string composition/interpolation from active save/create/update paths (`string.Format`, `$"...{input}..."`, `ToSqlParam`) and migrate to parameterized queries only; treat this as a potential SQL injection vulnerability (stability + security risk), execute repo-wide audit, and add CI guardrails to block reintroduction.
- Alternative UX design (Server component): in `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeSenderPage.razor`, enforce deterministic row double-click behavior as exactly one action (preview OR edit), and keep row action buttons/tooltips aligned with the same rule to eliminate ambiguous navigation.
- Implement SPA-style language switching so the UI culture updates immediately without a full page reload.
- Implement sender-scoped caching for "recent receiver suggestions" used during envelope creation (wizard/editor flows): cache key should include sender identity, support short TTL + invalidation hooks after successful envelope receiver writes, and prevent cross-sender suggestion leakage.
## Database
@@ -261,11 +304,51 @@ Manual testing workflow:
**Key Tables:**
- `TBSIG_ENVELOPE` - Envelope metadata
- `TBSIG_ENVELOPE_RECEIVER` - Receiver assignments
- `TBSIG_DOC_RECEIVER_ELEMENT` - Signature fields (X, Y in INCHES)
- `TBSIG_DOCUMENT_RECEIVER_ELEMENT` - Signature fields (X, Y in INCHES)
- `TBSIG_RECEIVER` - Receiver registry
- `TBSIG_DOCUMENT` - PDF binary data
- `TBSIG_ENVELOPE_DOCUMENT` - PDF binary data
- `TBSIG_ENVELOPE_HISTORY` - Audit trail
### Data Structure (Domain + EGDbContext)
Use this as the canonical quick map before touching sender/receiver workflow code.
#### Core Aggregate Shape
- `Envelope` (`TBSIG_ENVELOPE`) is the root aggregate for sending workflow.
- `Envelope -> Documents` is `1:N` via `Document.EnvelopeId` (`TBSIG_ENVELOPE_DOCUMENT`).
- `Envelope -> Histories` is `1:N` via `History.EnvelopeId` (`TBSIG_ENVELOPE_HISTORY`).
- `Envelope <-> Receiver` is `N:N` through `EnvelopeReceiver` (`TBSIG_ENVELOPE_RECEIVER`, composite PK `{EnvelopeId, ReceiverId}`).
- `Document -> DocReceiverElement` is `1:N` via `DocReceiverElement.DocumentId` (`TBSIG_DOCUMENT_RECEIVER_ELEMENT`).
- `DocReceiverElement -> ElementAnnotation` is `1:N` via `ElementAnnotation.ElementId` (`TBSIG_DOCUMENT_RECEIVER_ELEMENT_ANNOTATION`).
#### Status and Tracking Entities
- `DocumentStatus` (`TBSIG_DOCUMENT_STATUS`) links `EnvelopeId + ReceiverId` and stores receiver-level signing state.
- `History` stores timeline events (`EnvelopeStatus`) with polymorphic `UserReference` lookup semantics.
- `EnvelopeReceiverReadOnly` (`TBSIG_ENVELOPE_RECEIVER_READ_ONLY`) stores temporary read-only delegation with validity window (`DateValid`).
#### Important Constraints From `EGDbContext`
- `Envelope.Uuid` has a unique index.
- `Receiver.Signature` has a unique index.
- `Receiver.EmailAddress` has a unique index.
- `EnvelopeReceiver` uses composite primary key: `{ EnvelopeId, ReceiverId }`.
- `History.UserReference` is mapped conditionally (`IsMigration == false`) to:
- `Receiver.EmailAddress` (`History.Receiver`)
- `User.Email` from UserManager (`History.Sender`)
- `EnvelopeReceiverReadOnly.AddedWho` maps to `Receiver.EmailAddress` (principal key mapping, not receiver GUID).
#### Coordinate-Critical Fields
- Signature placement is persisted in `DocReceiverElement` as `X`, `Y`, `Width`, `Height`, `Page`.
- These coordinates are in INCHES (not PDF points, not pixels).
#### Cross-Module Tables in Same Context
- `EGDbContext` also includes UserManager entities: `User`, `Group`, `Module`, `GroupOfUser`, `ModuleOfUser`, `UserRep`, and `ClientUser`.
- Mail dispatcher entity `EmailOut` is also registered under `#if NET` and trigger-configurable.
#### Trigger-Aware Model Notes
- Trigger names are injected from `DbTriggerParams` in appsettings and applied via `ToTable(tb => tb.HasTrigger(...))`.
- Current trigger registration is explicit (`AddTrigger<T>()` calls), not attribute/reflection driven.
- Do not remove trigger mappings casually; this project depends on DB-side automation/audit behavior.
## DevExpress
**License:** Commercial (v25.2.3)

View File

@@ -0,0 +1,384 @@
# Database Structure Context
## Why this file exists
This is the deep-dive DB reference for migration work when schema can change and old DB knowledge may be lost.
- Scope: `EnvelopeGenerator.Domain` entities + `EnvelopeGenerator.Infrastructure/EGDbContext.cs` + live DB metadata snapshot from `DD_ECM`.
- Goal: make schema transitions predictable (what exists, how it is linked, where logic executes).
- Safety: all DB checks used read-only `SELECT` metadata queries; no data mutation.
## Snapshot metadata
- Source DB: `DD_ECM` on `SDD-VHP04-SQL19\DD_TESTING01`
- Snapshot date: `2026-10-20`
- App context: `EnvelopeGenerator.Infrastructure/EGDbContext.cs`
## Fast navigation
- High-level aggregate map: `AGENTS.md` -> `Database` section
- EF model config source: `EnvelopeGenerator.Infrastructure/EGDbContext.cs`
- Stored procedure wrappers:
- `EnvelopeGenerator.Application/Common/SQL/EnvelopeCreateReadSQL.cs`
- `EnvelopeGenerator.Application/Common/SQL/EnvelopeReceiverAddReadSQL.cs`
- `EnvelopeGenerator.Application/Common/SQL/DocumentCreateReadSQL.cs`
- Direct SQL execution in controller:
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/EnvelopeReceiverController.cs`
## Core data model (ER-level)
- `Envelope` (`TBSIG_ENVELOPE`) is root aggregate.
- `Envelope (1) -> (N) Document` via `TBSIG_ENVELOPE_DOCUMENT.ENVELOPE_ID`.
- `Envelope (1) -> (N) History` via `TBSIG_ENVELOPE_HISTORY.ENVELOPE_ID`.
- `Envelope (N) <-> (N) Receiver` through `EnvelopeReceiver` (`TBSIG_ENVELOPE_RECEIVER`, composite PK `{ENVELOPE_ID, RECEIVER_ID}`).
- `Document (1) -> (N) DocReceiverElement` via `TBSIG_DOCUMENT_RECEIVER_ELEMENT.DOCUMENT_ID`.
- `DocReceiverElement (1) -> (N) ElementAnnotation` via `TBSIG_DOCUMENT_RECEIVER_ELEMENT_ANNOTATION.ELEMENT_ID`.
- `DocumentStatus` links `Envelope + Receiver` state in `TBSIG_DOCUMENT_STATUS`.
## Entity <-> table mapping (code-level)
| Entity | Table | PK in code | PK in DB | Important relation notes |
|---|---|---|---|---|
| `Envelope` | `TBSIG_ENVELOPE` | `Id` | `GUID` | Has `Documents`, `Histories`, `EnvelopeReceivers`; unique index on `Uuid` configured in EF |
| `EnvelopeReceiver` | `TBSIG_ENVELOPE_RECEIVER` | `{EnvelopeId, ReceiverId}` | `{ENVELOPE_ID, RECEIVER_ID}` | DB also has `GUID` identity (not modeled as entity PK) |
| `Document` | `TBSIG_ENVELOPE_DOCUMENT` | `Id` | `GUID` | FK `ENVELOPE_ID` |
| `DocReceiverElement` | `TBSIG_DOCUMENT_RECEIVER_ELEMENT` | `Id` | `GUID` | Coordinates persisted here (`POSITION_X/Y`, `WIDTH`, `HEIGHT`, `PAGE`) |
| `ElementAnnotation` | `TBSIG_DOCUMENT_RECEIVER_ELEMENT_ANNOTATION` | `Id` | `GUID` | FK `ELEMENT_ID` |
| `DocumentStatus` | `TBSIG_DOCUMENT_STATUS` | `Id` | `GUID` | FK `ENVELOPE_ID`, `RECEIVER_ID` |
| `History` | `TBSIG_ENVELOPE_HISTORY` | `Id` | `GUID` | `USER_REFERENCE` maps to `Receiver.EmailAddress` and `User.Email` when `IsMigration == false` |
| `Receiver` | `TBSIG_RECEIVER` | `Id` | `GUID` | Unique indexes in code: `Signature`, `EmailAddress` |
| `EnvelopeReceiverReadOnly` | `TBSIG_ENVELOPE_RECEIVER_READ_ONLY` | `Id` | `GUID` | `AddedWho` is FK-like mapping to `Receiver.EmailAddress` |
| `EnvelopeType` | `TBSIG_ENVELOPE_TYPE` | `Id` | `GUID` | Used as optional envelope defaults/template |
| `EmailTemplate` | `TBSIG_EMAIL_TEMPLATE` | `Id` | `GUID` | Trigger-managed `CHANGED_WHEN` |
| `EmailOut` (legacy/bridge) | `TBEMLP_EMAIL_OUT` | `Id` | `GUID` | Internal/obsolete domain class, still registered for DB access |
## Physical schema snapshot (table/column level)
Notes:
- Format per column: `COLUMN_NAME TYPE NULLABLE`.
- This section reflects live DB metadata at snapshot time.
### `TBSIG_ENVELOPE`
- `GUID int NOT NULL`
- `USER_ID int NOT NULL`
- `STATUS int NOT NULL`
- `ENVELOPE_UUID nvarchar(36) NOT NULL`
- `MESSAGE nvarchar(max) NOT NULL`
- `EXPIRES_WHEN datetime NULL`
- `EXPIRES_WARNING_WHEN datetime NULL`
- `VERIFICATION_TYPE tinyint NOT NULL`
- `TITLE nvarchar(128) NULL`
- `CONTRACT_TYPE int NULL`
- `LANGUAGE nvarchar(5) NULL`
- `SEND_REMINDER_EMAILS bit NULL`
- `FIRST_REMINDER_DAYS int NULL`
- `REMINDER_INTERVAL_DAYS int NULL`
- `ENVELOPE_TYPE int NULL`
- `CERTIFICATION_TYPE int NULL`
- `USE_ACCESS_CODE bit NULL`
- `FINAL_EMAIL_TO_CREATOR int NULL`
- `FINAL_EMAIL_TO_RECEIVERS int NULL`
- `EXPIRES_WHEN_DAYS int NULL`
- `EXPIRES_WARNING_WHEN_DAYS int NULL`
- `TFA_ENABLED bit NOT NULL`
- `DOC_RESULT varbinary(max) NULL`
- `LANG_CODE varchar(5) NOT NULL`
- `SOURCE varchar(10) NOT NULL`
- `COMMENT varchar(128) NULL`
- `ADDED_WHEN datetime NOT NULL`
- `CHANGED_WHEN datetime NULL`
### `TBSIG_ENVELOPE_RECEIVER`
- `ENVELOPE_ID int NOT NULL`
- `RECEIVER_ID int NOT NULL`
- `SEQUENCE int NOT NULL`
- `NAME nvarchar(128) NULL`
- `JOB_TITLE nvarchar(128) NULL`
- `COMPANY_NAME nvarchar(128) NULL`
- `PRIVATE_MESSAGE nvarchar(max) NULL`
- `ACCESS_CODE nvarchar(64) NULL`
- `ADDED_WHEN datetime NOT NULL`
- `CHANGED_WHEN datetime NULL`
- `GUID bigint NOT NULL`
- `PHONE_NUMBER varchar(20) NULL`
- `PARENT_ENVELOPE_RECEIVER_ID int NOT NULL`
### `TBSIG_ENVELOPE_DOCUMENT`
- `GUID int NOT NULL`
- `ENVELOPE_ID int NOT NULL`
- `FILENAME nvarchar(256) NOT NULL`
- `FILEPATH nvarchar(256) NOT NULL`
- `ADDED_WHEN datetime NOT NULL`
- `FILENAME_ORIGINAL nvarchar(256) NULL`
- `BYTE_DATA varbinary(max) NULL`
### `TBSIG_DOCUMENT_RECEIVER_ELEMENT`
- `GUID int NOT NULL`
- `DOCUMENT_ID int NOT NULL`
- `RECEIVER_ID int NOT NULL`
- `ELEMENT_TYPE int NOT NULL`
- `POSITION_X float NOT NULL`
- `POSITION_Y float NOT NULL`
- `WIDTH float NOT NULL`
- `HEIGHT float NOT NULL`
- `PAGE int NOT NULL`
- `REQUIRED bit NOT NULL`
- `TOOLTIP nvarchar(512) NULL`
- `READ_ONLY bit NOT NULL`
- `ANNOTATION_INDEX int NOT NULL`
- `ADDED_WHEN datetime NOT NULL`
- `CHANGED_WHEN datetime NULL`
- `CHANGED_WHO nvarchar(100) NULL`
- `FULL_NAME nvarchar(100) NULL`
- `POSITION nvarchar(100) NULL`
- `PLACE nvarchar(100) NULL`
- `INK varbinary(max) NULL`
### `TBSIG_DOCUMENT_RECEIVER_ELEMENT_ANNOTATION`
- `GUID bigint NOT NULL`
- `ELEMENT_ID int NOT NULL`
- `NAME nvarchar(100) NOT NULL`
- `VALUE nvarchar(max) NOT NULL`
- `ADDED_WHEN datetime NOT NULL`
- `CHANGED_WHEN datetime NULL`
- `CHANGED_WHO nvarchar(100) NULL`
- `TYPE nvarchar(50) NOT NULL`
- `POSITION_X float NULL`
- `POSITION_Y float NULL`
- `WIDTH float NULL`
- `HEIGHT float NULL`
### `TBSIG_DOCUMENT_STATUS`
- `GUID int NOT NULL`
- `ENVELOPE_ID int NOT NULL`
- `RECEIVER_ID int NOT NULL`
- `STATUS int NOT NULL`
- `STATUS_CHANGED_WHEN datetime NULL`
- `VALUE nvarchar(max) NULL`
- `ADDED_WHEN datetime NOT NULL`
- `CHANGED_WHEN datetime NULL`
### `TBSIG_ENVELOPE_HISTORY`
- `GUID bigint NOT NULL`
- `ENVELOPE_ID int NOT NULL`
- `USER_REFERENCE nvarchar(128) NOT NULL`
- `STATUS int NOT NULL`
- `ADDED_WHEN datetime NOT NULL`
- `ACTION_DATE datetime NULL`
- `COMMENT nvarchar(max) NULL`
### `TBSIG_RECEIVER`
- `GUID int NOT NULL`
- `EMAIL_ADDRESS nvarchar(128) NOT NULL`
- `SIGNATURE nvarchar(64) NOT NULL`
- `ADDED_WHEN datetime NOT NULL`
- `TOTP_SECRET_KEY nvarchar(max) NULL`
- `TFA_REG_DEADLINE datetime NULL`
- `CULTURE nvarchar(10) NULL`
### `TBSIG_ENVELOPE_RECEIVER_READ_ONLY`
- `GUID bigint NOT NULL`
- `ENVELOPE_ID bigint NOT NULL`
- `RECEIVER_MAIL nvarchar(250) NOT NULL`
- `DATE_VALID date NOT NULL`
- `ADDED_WHO varchar(100) NOT NULL`
- `ADDED_WHEN datetime NULL`
- `CHANGED_WHO varchar(100) NULL`
- `CHANGED_WHEN datetime NULL`
### `TBSIG_ENVELOPE_TYPE`
- `GUID int NOT NULL`
- `TITLE nvarchar(128) NOT NULL`
- `LANGUAGE nvarchar(5) NOT NULL`
- `EXPIRES_DAYS int NULL`
- `CERTIFICATION_TYPE int NULL`
- `USE_ACCESS_CODE bit NULL`
- `FINAL_EMAIL_TO_CREATOR int NULL`
- `FINAL_EMAIL_TO_RECEIVERS int NULL`
- `ADDED_WHEN datetime NOT NULL`
- `CHANGED_WHEN datetime NULL`
- `EXPIRES_WARNING_DAYS int NULL`
- `SEND_REMINDER_EMAILS bit NULL`
- `FIRST_REMINDER_DAYS int NULL`
- `REMINDER_INTERVAL_DAYS int NULL`
- `CONTRACT_TYPE int NULL`
### `TBSIG_EMAIL_TEMPLATE`
- `GUID int NOT NULL`
- `NAME nvarchar(64) NULL`
- `BODY nvarchar(max) NULL`
- `SUBJECT nvarchar(512) NULL`
- `ADDED_WHEN datetime NOT NULL`
- `CHANGED_WHEN datetime NULL`
- `LANG_CODE varchar(5) NOT NULL`
### `TBEMLP_EMAIL_OUT`
- `GUID int NOT NULL`
- `REMINDER_TYPE_ID int NOT NULL`
- `SENDING_PROFILE int NOT NULL`
- `REFERENCE_ID int NOT NULL`
- `REFERENCE_STRING varchar(200) NULL`
- `ENTITY_ID int NULL`
- `WF_ID int NOT NULL`
- `WF_REFERENCE varchar(200) NULL`
- `EMAIL_ADRESS varchar(1000) NOT NULL`
- `EMAIL_SUBJ varchar(500) NOT NULL`
- `EMAIL_BODY varchar(max) NOT NULL`
- `EMAIL_ATTMT1 varchar(512) NULL`
- `EMAIL_SENT datetime NULL`
- `COMMENT varchar(500) NULL`
- `ADDED_WHO varchar(50) NOT NULL`
- `ADDED_WHEN datetime NULL`
- `CHANGED_WHO varchar(50) NULL`
- `CHANGED_WHEN datetime NULL`
- `ERROR_TIMESTAMP datetime NULL`
- `ERROR_MSG varchar(900) NULL`
- `ATTACHMENT1_BYTES varbinary(max) NULL`
- `ATT1_RELATED_ID bigint NULL`
- `ATT1_REL_TYPE varchar(20) NULL`
## Constraints and indexes (relevant)
From EF + DB metadata snapshot:
- `TBSIG_ENVELOPE`: PK `PK_TBSIG_ENVELOPE (GUID)`.
- `TBSIG_ENVELOPE_RECEIVER`: composite PK `PK_TBSIG_ENVELOPE_RECEIVER (ENVELOPE_ID, RECEIVER_ID)`.
- `TBSIG_DOCUMENT_RECEIVER_ELEMENT`: PK `GUID`, plus unique key `UQTBSIG_DOCUMENT_RECEIVER_ELEMENT_DocID_PosX_PosY (DOCUMENT_ID, POSITION_X, POSITION_Y)`.
- `TBSIG_RECEIVER`: PK `GUID`, unique key `UQTBSIG_RECEIVER (EMAIL_ADDRESS)`.
- `TBEMLP_EMAIL_OUT`: PK `GUID`, FK `FK_TBEMLP_EMAIL_OUT_SENDING_PROFILE`.
## Stored procedures: behavior and code call sites
### Active in .NET code path
1) `PRSIG_API_CREATE_ENVELOPE`
- Purpose: creates envelope row (`STATUS=1002`, `SOURCE='API'`, defaults) and returns `ENVELOPE_UUID` via output.
- DB writes: `TBSIG_ENVELOPE`.
- Called by:
- SQL template `EnvelopeGenerator.Application/Common/SQL/EnvelopeCreateReadSQL.cs`
- executor `EnvelopeGenerator.Infrastructure/Executor/EnvelopeExecutor.cs`
- used in `EnvelopeReceiverController.CreateAsync` and `CreateEnvelopeReceiverCommandHandler`.
2) `PRSIG_API_CREATE_RECEIVER`
- Purpose: upserts `TBSIG_RECEIVER` by email, adds envelope assignment row with generated access code.
- DB writes: `TBSIG_RECEIVER`, `TBSIG_ENVELOPE_RECEIVER`.
- Called by:
- SQL template `EnvelopeGenerator.Application/Common/SQL/EnvelopeReceiverAddReadSQL.cs`
- executor `EnvelopeGenerator.Infrastructure/Executor/EnvelopeReceiverExecutor.cs`
- used in `EnvelopeReceiverController.CreateAsync` and `CreateEnvelopeReceiverCommandHandler`.
3) `PRSIG_API_ADD_DOC`
- Purpose: stores one document for envelope if absent; returns doc id.
- DB writes: `TBSIG_ENVELOPE_DOCUMENT`.
- Called by:
- SQL template `EnvelopeGenerator.Application/Common/SQL/DocumentCreateReadSQL.cs`
- executor `EnvelopeGenerator.Infrastructure/Executor/DocumentExecutor.cs`
- used in `EnvelopeReceiverController.CreateAsync`.
4) `PRSIG_API_ADD_DOC_RECEIVER_ELEM`
- Purpose: inserts signature element record if `(DOC_ID, RECEIVER_ID)` pair does not exist.
- DB writes: `TBSIG_DOCUMENT_RECEIVER_ELEMENT`.
- Called directly by:
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/EnvelopeReceiverController.cs`
5) `PRSIG_API_ADD_HISTORY_STATE`
- Purpose: appends history row with status and user reference.
- DB writes: `TBSIG_ENVELOPE_HISTORY`.
- Called directly by:
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/EnvelopeReceiverController.cs`
### Legacy/other path references in repo
- `PRSIG_GET_ENVELOPES_FOR_USER` and `PRSIG_GET_CLOSED_ENVELOPES_FOR_USER` are called in legacy VB path:
- `EnvelopeGenerator.CommonServices/Models/EnvelopeModel.vb`
- `PRSIG_API_WITHDRAW_ENVELOPE` exists in DB snapshot but no active `.cs` call site found in current server flow.
## Trigger behavior and where they are wired
## How trigger names are wired in app
- Config source: `EnvelopeGenerator.Server/EnvelopeGenerator.Server/appsettings.json` -> `DbTriggerParams`.
- DI binding: `EnvelopeGenerator.Infrastructure/DependencyInjection.cs` (`AddDbTriggerParams`).
- Model binding: `EnvelopeGenerator.Infrastructure/EGDbContext.cs` -> `AddTrigger<T>()` -> `ToTable(tb => tb.HasTrigger(triggerName))`.
### Trigger definitions observed in DB snapshot
1) `TBSIG_DOCUMENT_RECEIVER_ELEMENT_AFT_INS`
- Fires on insert of `TBSIG_DOCUMENT_RECEIVER_ELEMENT`.
- Normalizes/rewrites `POSITION_X`, `POSITION_Y` and sets `CHANGED_WHO='TBSIG_DOC_REC_EL_AFT_INS'`.
2) `TBSIG_DOCUMENT_RECEIVER_ELEMENT_AFT_UPD`
- Fires on update of `TBSIG_DOCUMENT_RECEIVER_ELEMENT`.
- Rewrites coordinates and `CHANGED_WHO='TBSIG_DOC_REC_EL_AFT_UPD'` except when `CHANGED_WHO='OFFSET_Y_POS'`.
3) `TBSIG_ENVELOPE_HISTORY_AFT_INS`
- Fires on history insert.
- Central state machine side-effects:
- syncs `TBSIG_ENVELOPE.STATUS` for selected `100x` states,
- on `1003` applies Y-offset update to receiver elements,
- on `2005` calculates partial/fully signed and may insert follow-up `1006` history,
- on `2007` (type-dependent reject path) writes rejection emails into `TBEMLP_EMAIL_OUT` and updates envelope rejected state,
- on `3001` invitation-message counting can move envelope to `1004`.
4) `TBSIG_EMAIL_TEMPLATE_AFT_UPD`
- Fires on update of `TBSIG_EMAIL_TEMPLATE`.
- Sets `CHANGED_WHEN = GETDATE()`.
5) `TBSIG_ENVELOPE_RECEIVER_READ_ONLY_UPD`
- Fires on update of `TBSIG_ENVELOPE_RECEIVER_READ_ONLY`.
- Updates config timestamp row (`TBSIG_CONFIG.GUID = 1`).
6) `TBEMLP_EMAIL_OUT_AFT_UPD`
- Fires on update of `TBEMLP_EMAIL_OUT`.
- When `EMAIL_SENT` changes and `WF_ID in (3001..3005)`, writes corresponding history entries to `TBSIG_ENVELOPE_HISTORY`.
### Trigger mismatch alert (important)
- `DbTriggerParams` contains `TBEMLP_EMAIL_OUT_AFT_INS`, but this trigger was not found in the snapshot DB (`ExistsInDb = 0`).
- `TBEMLP_EMAIL_OUT_AFT_UPD` exists and is active.
This should be treated as environment drift and validated before migrations.
## Known model-vs-DB drift risks
These are not necessarily bugs, but they are migration risk hotspots:
- `Receiver.EmailAddress` is `[StringLength(250)]` in entity, DB column is `nvarchar(128)`.
- `EnvelopeReceiverReadOnly.AddedWho` entity expects `nvarchar(250)`, DB column is `varchar(100)`.
- `EnvelopeReceiver` entity does not model DB columns `GUID` and `PARENT_ENVELOPE_RECEIVER_ID`.
- `Envelope` entity does not explicitly model some DB columns (`VERIFICATION_TYPE`, `LANG_CODE`, `SOURCE`).
- `EmailOut` entity has `[NotMapped] AddedWhen`, while DB has `ADDED_WHEN` column.
Before schema changes, reconcile these deltas intentionally (either mapping update or documented accepted divergence).
## Migration-safe operating rules
- Do not rely on implicit trigger behavior; document expected side effects in feature code touching history/status flows.
- Any change to `TBSIG_ENVELOPE_HISTORY` semantics requires trigger impact review first.
- Keep `DbTriggerParams` synchronized with actual DB trigger inventory per environment.
- For stored procedure changes, update both:
- SQL wrapper templates (`Application/Common/SQL/*`)
- direct SQL callers in controllers/executors.
## Refresh checklist (read-only)
Run these metadata checks before/after DB migration:
1. Column inventory per table (`INFORMATION_SCHEMA.COLUMNS`).
2. Constraint inventory (`INFORMATION_SCHEMA.TABLE_CONSTRAINTS` + `KEY_COLUMN_USAGE`).
3. Trigger inventory (`sys.triggers`) and definitions (`OBJECT_DEFINITION`).
4. Stored procedure inventory (`sys.procedures`) and definitions (`OBJECT_DEFINITION`).
5. Repo call-site grep for `PRSIG_` names to detect dead or missing integration points.

View File

@@ -0,0 +1,17 @@
namespace EnvelopeGenerator.Application.Common.Configurations;
/// <summary>
///
/// </summary>
public class ESignParams
{
/// <summary>
///
/// </summary>
public required string ApplicationName { get; set; }
/// <summary>
///
/// </summary>
public string DefaultReason { get; set; } = string.Empty;
}

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

@@ -1,12 +1,14 @@
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Application.Common.Dto.Receiver;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Domain.Interfaces;
namespace EnvelopeGenerator.Application.Common.Dto;
// TODO: Add the “Reason” property and integrate with UI and the digital signature process.
/// <summary>
/// Data Transfer Object representing a positioned element assigned to a document receiver.
/// </summary>
public class DocReceiverElementDto : IDocReceiverElement
public class DocReceiverElementDto : IDocReceiverElement, ISignatureInkPayload
{
/// <summary>
/// Gets or sets the unique identifier of the element.
@@ -18,11 +20,21 @@ public class DocReceiverElementDto : IDocReceiverElement
/// </summary>
public int DocumentId { get; set; }
/// <summary>
///
/// </summary>
public virtual DocumentDto? Document { get; set; }
/// <summary>
/// Gets or sets the identifier of the receiver.
/// </summary>
public int ReceiverId { get; set; }
/// <summary>
///
/// </summary>
public virtual ReceiverDto? Receiver { get; set; }
/// <summary>
/// Gets or sets the type of the element.
/// </summary>
@@ -122,4 +134,41 @@ public class DocReceiverElementDto : IDocReceiverElement
///
/// </summary>
public byte[]? Ink { get; set; }
/// <summary>
/// Returns the stored signature bytes as an image payload with detected MIME type.
/// </summary>
/// <exception cref="InvalidOperationException">Thrown when signature image bytes do not exist.</exception>
public (byte[] Bytes, string ContentType, string FileExtension) ToImage()
{
if (Ink is not { Length: > 0 })
throw new InvalidOperationException("Signature image (Ink) does not exist.");
if (HasPrefix(Ink, 0x89, 0x50, 0x4E, 0x47))
return (Ink, "image/png", ".png");
if (HasPrefix(Ink, 0xFF, 0xD8, 0xFF))
return (Ink, "image/jpeg", ".jpg");
if (HasPrefix(Ink, 0x52, 0x49, 0x46, 0x46) &&
Ink.Length >= 12 &&
Ink[8] == 0x57 && Ink[9] == 0x45 && Ink[10] == 0x42 && Ink[11] == 0x50)
return (Ink, "image/webp", ".webp");
return (Ink, "application/octet-stream", ".bin");
}
private static bool HasPrefix(byte[] data, params byte[] prefix)
{
if (data.Length < prefix.Length)
return false;
for (var i = 0; i < prefix.Length; i++)
{
if (data[i] != prefix[i])
return false;
}
return true;
}
}

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,54 @@
using EnvelopeGenerator.Application.Common.Enums;
namespace EnvelopeGenerator.Application.Common.Dto;
/// <summary>
///
/// </summary>
public sealed record PdfSignOptionsDto
{
/// <summary>
///
/// </summary>
public long DocumentElementId { get; set; }
/// <summary>
///
/// </summary>
public string Name { get; set; } = null!;
/// <summary>
///
/// </summary>
public string Reason { get; set; } = null!;
/// <summary>
///
/// </summary>
public string Location { get; set; } = null!;
/// <summary>
///
/// </summary>
public string? Email { get; set; }
/// <summary>
///
/// </summary>
public string? Phone { get; set; }
/// <summary>
///
/// </summary>
public string ApplicationName { get; set; } = null!;
/// <summary>
///
/// </summary>
public PdfSignatureCertificationLevel CertificationLevel { get; set; }
/// <summary>
///
/// </summary>
public PdfSignatureAppearanceDto? Appearance { get; set; }
}

View File

@@ -0,0 +1,96 @@
namespace EnvelopeGenerator.Application.Common.Dto;
/// <summary>
///
/// </summary>
public enum PdfSignatureCoordinateUnit
{
/// <summary>
///
/// </summary>
Point = 0,
/// <summary>
///
/// </summary>
Inch = 1
}
/// <summary>
///
/// </summary>
public enum PdfSignatureCoordinateOrigin
{
/// <summary>
///
/// </summary>
BottomLeft = 0,
/// <summary>
///
/// </summary>
TopLeft = 1
}
/// <summary>
///
/// </summary>
/// <param name="PageNumber"></param>
/// <param name="Left"></param>
/// <param name="Bottom"></param>
/// <param name="Width"></param>
/// <param name="Height"></param>
/// <param name="ShowLabels"></param>
/// <param name="ShowName"></param>
/// <param name="ShowDate"></param>
/// <param name="ShowReason"></param>
/// <param name="ShowLocation"></param>
/// <param name="ShowDistinguishedName"></param>
/// <param name="DateTimeFormat"></param>
/// <param name="CoordinateUnit"></param>
/// <param name="CoordinateOrigin"></param>
public sealed record PdfSignatureAppearanceDto(
int PageNumber,
double Left,
double Bottom,
double Width,
double Height,
bool ShowLabels = false,
bool ShowName = false,
bool ShowDate = false,
bool ShowReason = false,
bool ShowLocation = false,
bool ShowDistinguishedName = false,
string DateTimeFormat = "dd.MM.yyyy HH:mm:ss zzz",
PdfSignatureCoordinateUnit CoordinateUnit = PdfSignatureCoordinateUnit.Point,
PdfSignatureCoordinateOrigin CoordinateOrigin = PdfSignatureCoordinateOrigin.BottomLeft)
{
/// <summary>
///
/// </summary>
/// <param name="FamilyName"></param>
/// <param name="Size"></param>
/// <param name="IsBold"></param>
/// <param name="IsItalic"></param>
/// <param name="IsUnderline"></param>
public sealed record FontDto(string FamilyName = "Helvetica", float Size = 10, bool IsBold = false, bool IsItalic = false, bool IsUnderline = false)
{
/// <summary>
///
/// </summary>
public RgbaColorDto Color { get; init; } = new();
}
/// <summary>
///
/// </summary>
/// <param name="R"></param>
/// <param name="G"></param>
/// <param name="B"></param>
public sealed record RgbaColorDto(double R = 0, double G = 0, double B = 0);
/// <summary>
///
/// </summary>
public FontDto Font { get; init; } = new();
}

View File

@@ -0,0 +1,57 @@
namespace EnvelopeGenerator.Application.Common.Dto;
public sealed record PdfSignaturePrincipalDto(
string? CommonName,
string? Organization,
string? Country,
string? Full);
public sealed record PdfSignatureCertificateInfoDto(
PdfSignaturePrincipalDto IssuedTo,
PdfSignaturePrincipalDto IssuedBy,
string? SerialNumber,
DateTimeOffset? ValidFrom,
DateTimeOffset? ValidTo,
bool SelfSigned,
bool Expired,
bool NotYetValid,
bool IsSigner,
int? Version,
string? KeyAlgorithm,
string? SignatureAlgorithm,
string? Pem,
int? KeySizeBits);
public sealed record PdfSignatureCheckItemDto(
bool CoversWholeDoc,
string? Reason,
string? BusinessReason,
string? Location,
string? DeclaredName,
string? ContactInfo,
string? Email,
string? Phone,
string? DigestAlgorithm,
string? SignatureAlgorithm,
string? SignerName,
string? Issuer,
string? SerialNumber,
DateTimeOffset? ValidFrom,
DateTimeOffset? ValidTo,
string ExpiryCheckedAgainst,
IReadOnlyList<PdfSignatureCertificateInfoDto> Certificates,
bool Unchanged,
string? FieldName,
bool IsCryptographicallyValid,
string? SigningTime,
string? Filter,
string? SubFilter,
string? ApplicationName,
long? DocumentElementId,
string? OriginalDocumentSha256,
string? MessageDigestHex,
string? Error);
public sealed record PdfSignatureCheckResultDto(
bool Signed,
IReadOnlyList<PdfSignatureCheckItemDto> Signatures);

View File

@@ -0,0 +1,10 @@
namespace EnvelopeGenerator.Application.Common.Dto.Receiver;
public class RecentReceiverSuggestionDto
{
public required string FullName { get; init; }
public required string EmailAddress { get; init; }
public required DateTime LastUsedAt { get; init; }
}

View File

@@ -0,0 +1,27 @@
namespace EnvelopeGenerator.Application.Common.Enums;
/// <summary>
///
/// </summary>
public enum PdfSignatureCertificationLevel
{
/// <summary>
/// All changes are allowed
/// </summary>
NoCertification = 0,
/// <summary>
/// No changes are allowed
/// </summary>
NoChangesAllowed,
/// <summary>
/// Allows users to fill in forms and sign the document.
/// </summary>
FillForms,
/// <summary>
/// Allows users to fill in forms, create, modify and delete annotations, and sign the document.
/// </summary>
FillFormsAndAnnotate
}

View File

@@ -1,8 +1,66 @@
using DigitalData.Core.Exceptions;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Domain.Interfaces;
namespace EnvelopeGenerator.Application.Common.Interfaces.Doc;
/// <summary>
///
/// </summary>
public interface IPdfPageImageRenderer
{
/// <summary>
///
/// </summary>
/// <param name="pdfBytes"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
Task<IReadOnlyList<PdfPageImageDto>> RenderPagesAsImagesAsync(byte[] pdfBytes, CancellationToken cancellationToken = default);
/// <summary>
///
/// </summary>
/// <param name="element"></param>
/// <returns></returns>
Stream ComposeSignaturePreviewPng(ISignatureInkPayload element);
/// <summary>
///
/// </summary>
/// <param name="bytes"></param>
/// <returns></returns>
public bool IsSupportedImageFormat(byte[] bytes);
}
/// <summary>
///
/// </summary>
public static class PdfPageImageRendererExtensions
{
/// <summary>
///
/// </summary>
/// <param name="renderer"></param>
/// <param name="inkBytes"></param>
/// <returns></returns>
/// <exception cref="BadRequestException"></exception>
public static byte[] EnsureSupportedImageBytes(this IPdfPageImageRenderer renderer, byte[] inkBytes)
{
if (!renderer.IsSupportedImageFormat(inkBytes))
throw new BadRequestException("Only PNG and JPEG signature formats are supported.");
return inkBytes;
}
public static byte[] ComposeSignaturePreviewPngBytes(this IPdfPageImageRenderer renderer, ISignatureInkPayload element)
{
using var stream = renderer.ComposeSignaturePreviewPng(element);
if (stream is MemoryStream memoryStream)
return memoryStream.ToArray();
using var copy = new MemoryStream();
stream.CopyTo(copy);
return copy.ToArray();
}
}

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,29 @@
using EnvelopeGenerator.Application.Common.Dto;
namespace EnvelopeGenerator.Application.Common.Interfaces.Services;
/// <summary>
/// Defines PDF digital-signing operations for the application boundary.
/// </summary>
public interface IPdfDigitalSignatureService
{
/// <summary>
/// Creates a digital signature for the provided PDF bytes and returns
/// a new signed PDF document.
/// </summary>
/// <param name="inputPdf">Source PDF content stream.</param>
/// <param name="outputPdf">Destination stream for signed PDF output.</param>
/// <param name="options">Optional signing options that map to PDF signature metadata fields.</param>
/// <param name="image">Optional image stream for signature appearance.</param>
/// <param name="cancellationToken">Cancellation token for cooperative cancellation.</param>
Task SignPdfAsync(Stream inputPdf, Stream outputPdf, PdfSignOptionsDto? options = null, Stream? image = null, CancellationToken cancellationToken = default);
/// <summary>
/// Checks whether the provided PDF contains signature field(s) and whether
/// those signatures are cryptographically valid.
/// </summary>
/// <param name="inputPdf">PDF content stream to inspect.</param>
/// <param name="cancellationToken">Cancellation token for cooperative cancellation.</param>
/// <returns>A structured result containing signature presence and per-signature details.</returns>
Task<PdfSignatureCheckResultDto> CheckPdfSignatureAsync(Stream inputPdf, CancellationToken cancellationToken = default);
}

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

@@ -0,0 +1,59 @@
using EnvelopeGenerator.Domain.Constants;
namespace EnvelopeGenerator.Application.Common.Interfaces.Services.Notification;
/// <summary>
///
/// </summary>
public record DocumentSignedNotification : EnvelopeNotificationBase
{
/// <summary>
///
/// </summary>
public required string DocumentTitle { get; init; }
/// <summary>
///
/// </summary>
public required string NameReceiver { get; init; }
/// <summary>
///
/// </summary>
public string NamePortal { get; init; } = "signFLOW Portal";
/// <summary>
///
/// </summary>
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;
}
/*
Umschlag abgeschlossen: '[DOCUMENT_TITLE]'
---
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]
*/

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

@@ -0,0 +1,18 @@
namespace EnvelopeGenerator.Application.Common.Interfaces.Services.Notification;
/// <summary>
///
/// </summary>
public interface IEnvelopeNotificationService
{
/// <summary>
///
/// </summary>
/// <typeparam name="TEnvelopeNotificationBase"></typeparam>
/// <param name="receiverEmail"></param>
/// <param name="notification"></param>
/// <param name="cancel"></param>
/// <returns></returns>
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,8 +49,10 @@ 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)));
services.AddHttpClientService<GtxMessagingParams>(config.GetSection(nameof(GtxMessagingParams)));
services.TryAddScoped<ISmsSender, GTXSmsSender>(); // Changed: Singleton → Scoped

View File

@@ -14,6 +14,15 @@ namespace EnvelopeGenerator.Application.DocReceiverElements.Queries;
/// </summary>
public record ReadDocReceiverElementQuery : EnvelopeReceiverQueryBase, IRequest<IEnumerable<DocReceiverElementDto>>
{
/// <summary>
///
/// </summary>
public int? Id { get; set; } = null;
/// <summary>
///
/// </summary>
public bool IncludeDocument { get; set; } = false;
}
/// <summary>
@@ -47,7 +56,10 @@ public class ReadDocReceiverElementQueryHandler : IRequestHandler<ReadDocReceive
{
var q = _repository.Query;
if(request.Envelope.Id is int envelopeId)
if (request.Id is not null)
q = q.Where(e => e.Id == request.Id);
if (request.Envelope.Id is int envelopeId)
q = q.Where(e => e.Document.EnvelopeId == envelopeId);
if (request.Envelope.Uuid is string envelopeUuid)
@@ -59,6 +71,9 @@ public class ReadDocReceiverElementQueryHandler : IRequestHandler<ReadDocReceive
if (request.Receiver.Signature is string signature)
q = q.Where(e => e.Receiver.Signature == signature);
if( request.IncludeDocument)
q = q.Include(e => e.Document);
var elements = await q.ToListAsync(cancellationToken);
return _mapper.Map<IEnumerable<DocReceiverElementDto>>(elements);

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

@@ -4,6 +4,7 @@
<TargetFrameworks>net7.0;net8.0;net9.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
</PropertyGroup>

View File

@@ -2,7 +2,7 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
namespace EnvelopeGenerator.Application.EnvelopeReceivers.Commands;
namespace EnvelopeGenerator.Application.EnvelopeReceivers.Commands.CreateEnvelopeReceiver;
/// <summary>
/// Befehl zur Erstellung eines Umschlags.

View File

@@ -4,7 +4,7 @@ using MediatR;
using EnvelopeGenerator.Application.Common.Dto.Receiver;
using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor;
namespace EnvelopeGenerator.Application.EnvelopeReceivers.Commands;
namespace EnvelopeGenerator.Application.EnvelopeReceivers.Commands.CreateEnvelopeReceiver;
/// <summary>
/// Handles the creation of an envelope along with its associated document and recipients.

View File

@@ -1,7 +1,7 @@
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Dto.Receiver;
namespace EnvelopeGenerator.Application.EnvelopeReceivers.Commands;
namespace EnvelopeGenerator.Application.EnvelopeReceivers.Commands.CreateEnvelopeReceiver;
/// <summary>
///

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

@@ -16,5 +16,9 @@ public class MappingProfile : Profile
public MappingProfile()
{
CreateMap<Receiver, ReceiverDto>();
CreateMap<EnvelopeReceiver, RecentReceiverSuggestionDto>()
.ForMember(dest => dest.FullName, opt => opt.MapFrom(src => src.Name))
.ForMember(dest => dest.EmailAddress, opt => opt.MapFrom(src => src.Receiver.EmailAddress));
}
}

View File

@@ -21,7 +21,24 @@ namespace EnvelopeGenerator.Application.EnvelopeReceivers.Queries;
/// </remarks>
public record ReadEnvelopeReceiverSecretQuery
: EnvelopeReceiverQueryBase<ReadEnvelopeQuery, ReadReceiverQuery>,
IRequest<EnvelopeReceiverSecretDto?>;
IRequest<EnvelopeReceiverSecretDto?>
{
/// <summary>
///
/// </summary>
public ReadEnvelopeReceiverSecretQuery() { }
/// <summary>
///
/// </summary>
/// <param name="envelopeId"></param>
/// <param name="receiverId"></param>
public ReadEnvelopeReceiverSecretQuery(int envelopeId, int receiverId)
{
Envelope.Id = envelopeId;
Receiver.Id = receiverId;
}
}
/// <summary>
/// Extension methods for dispatching <see cref="ReadEnvelopeReceiverSecretQuery"/> via <see cref="IMediator"/>.

View File

@@ -0,0 +1,98 @@
using DigitalData.Core.Abstraction.Application.Repository;
using EnvelopeGenerator.Application.Common.Dto.Receiver;
using EnvelopeGenerator.Domain.Entities;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace EnvelopeGenerator.Application.EnvelopeReceivers.Queries;
/// <summary>
/// Reads recent receiver suggestions for envelope creation.
/// </summary>
/// <remarks>
/// Sender scope is assigned via chain method to avoid exposing username mutation from API query binding.
/// </remarks>
public record ReadRecentReceiverSuggestionsQuery : IRequest<IReadOnlyList<RecentReceiverSuggestionDto>>
{
/// <summary>
/// Sender username used for scoping suggestion results.
/// </summary>
internal string? Username { get; private init; }
/// <summary>
/// Desired number of results.
/// </summary>
public int? Take { get; init; } = null;
/// <summary>
/// Returns a new query instance scoped to the specified sender username.
/// </summary>
/// <param name="username">Sender username from authenticated claims.</param>
/// <returns>Scoped query instance.</returns>
public ReadRecentReceiverSuggestionsQuery ForSender(string username)
{
return this with { Username = username };
}
}
/// <summary>
/// Handles <see cref="ReadRecentReceiverSuggestionsQuery"/>.
/// </summary>
public class ReadRecentReceiverSuggestionsQueryHandler(IRepository<EnvelopeReceiver> repository)
: IRequestHandler<ReadRecentReceiverSuggestionsQuery, IReadOnlyList<RecentReceiverSuggestionDto>>
{
/// <summary>
/// Retrieves distinct recent receiver suggestions grouped by receiver email.
/// </summary>
/// <param name="request">Suggestion query.</param>
/// <param name="cancel">Cancellation token.</param>
/// <returns>Ordered list of recent receiver suggestions.</returns>
public async Task<IReadOnlyList<RecentReceiverSuggestionDto>> Handle(ReadRecentReceiverSuggestionsQuery request, CancellationToken cancel)
{
var query = repository.Query
.AsNoTracking()
.Where(er => er.Receiver != null);
if (request.Username is not null)
query = query.Where(er => er.Envelope != null && er.Envelope.User.Username == request.Username);
// 1) For each email, compute the latest usage timestamp.
var latestPerEmail = query
.GroupBy(er => er.Receiver!.EmailAddress)
.Select(g => new
{
EmailAddress = g.Key,
LastUsedAt = g.Max(er => er.AddedWhen),
});
// 2) Join back to rows that match the latest timestamp per email.
var latestRows =
from er in query
join latest in latestPerEmail
on new { EmailAddress = er.Receiver!.EmailAddress, LastUsedAt = er.AddedWhen }
equals new { latest.EmailAddress, latest.LastUsedAt }
select new
{
EmailAddress = er.Receiver!.EmailAddress,
FullName = er.Name,
LastUsedAt = er.AddedWhen,
};
// 3) Tie-break: if multiple rows share the same latest timestamp for an email,
// pick a deterministic name via Max(FullName), then order by most recent usage.
var suggestions = latestRows
.GroupBy(x => new { x.EmailAddress, x.LastUsedAt })
.Select(g => new RecentReceiverSuggestionDto
{
EmailAddress = g.Key.EmailAddress,
FullName = g.Max(x => x.FullName) ?? string.Empty,
LastUsedAt = g.Key.LastUsedAt,
})
.OrderByDescending(x => x.LastUsedAt)
.ThenBy(x => x.EmailAddress);
return request.Take is int take
? await suggestions.Take(take).ToListAsync(cancel)
: await suggestions.ToListAsync(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

@@ -1,5 +1,5 @@
using AutoMapper;
using EnvelopeGenerator.Application.EnvelopeReceivers.Commands;
using EnvelopeGenerator.Application.EnvelopeReceivers.Commands.CreateEnvelopeReceiver;
using EnvelopeGenerator.Application.Envelopes.Commands;
using EnvelopeGenerator.Domain.Entities;

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

@@ -0,0 +1,62 @@
using AutoMapper;
using DigitalData.Core.Abstraction.Application.Repository;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Domain.Entities;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace EnvelopeGenerator.Application.Envelopes.Queries;
/// <summary>
///
/// </summary>
public record ReadSignedEnvelopeQuery : IRequest<IEnumerable<EnvelopeDto>>
{
/// <summary>
///
/// </summary>
public bool? Burned { get; init; } = null;
internal int? UserId { get; private set; } = null;
/// <summary>
///
/// </summary>
/// <param name="userId"></param>
/// <returns></returns>
public ReadSignedEnvelopeQuery Authorized(int userId)
{
UserId = userId;
return this;
}
}
/// <summary>
///
/// </summary>
/// <param name="Repo"></param>
/// <param name="Mapper"></param>
public class ReadSignedEnvelopeQueryHandler(IRepository<Envelope> Repo, IMapper Mapper) : IRequestHandler<ReadSignedEnvelopeQuery, IEnumerable<EnvelopeDto>>
{
/// <summary>
///
/// </summary>
/// <param name="request"></param>
/// <param name="cancel"></param>
/// <returns></returns>
/// <exception cref="NotImplementedException"></exception>
public async Task<IEnumerable<EnvelopeDto>> Handle(ReadSignedEnvelopeQuery request, CancellationToken cancel)
{
var eQuery = Repo.Query.Where(e => e.Status == Domain.Constants.EnvelopeStatus.EnvelopeCompletelySigned);
if (request.Burned is bool burned)
eQuery = burned ? eQuery.Where(e => e.DocResult != null) : eQuery.Where(e => e.DocResult == null);
if(request.UserId is int userId)
eQuery = eQuery.Where(e => e.UserId == userId);
var envelopes = await eQuery.ToListAsync(cancel);
return Mapper.Map<IEnumerable<EnvelopeDto>>(envelopes);
}
}

View File

@@ -0,0 +1,109 @@
using DigitalData.Core.Abstraction.Application.Repository;
using DigitalData.Core.Exceptions;
using EnvelopeGenerator.Application.Common.Interfaces.Services.Notification;
using EnvelopeGenerator.Application.Common.Query;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Domain.Entities;
using EnvelopeGenerator.Domain.Interfaces;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace EnvelopeGenerator.Application.PdfSigning;
/// <summary>
///
/// </summary>
public record FinalizeEnvelopeCommand : EnvelopeQueryBase, IRequest
{
internal int? UserId { get; private set; } = null;
/// <summary>
///
/// </summary>
/// <param name="userId"></param>
/// <returns></returns>
public FinalizeEnvelopeCommand Authorized(int userId)
{
UserId = userId;
return this;
}
/// <summary>
///
/// </summary>
public bool BurnedAllowed { get; init; } = false;
/// <summary>
///
/// </summary>
public bool SaveDocResult { get; init; } = true;
}
/// <summary>
///
/// </summary>
/// <param name="Repo"></param>
/// <param name="Sender"></param>
/// <param name="NtfServer"></param>
public class FinalizeEnvelopeCommandHandler(IRepository<Envelope> Repo, ISender Sender, IEnvelopeNotificationService NtfServer) : IRequestHandler<FinalizeEnvelopeCommand>
{
/// <summary>
///
/// </summary>
/// <param name="request"></param>
/// <param name="cancel"></param>
/// <returns></returns>
/// <exception cref="NotFoundException"></exception>
public async Task Handle(FinalizeEnvelopeCommand request, CancellationToken cancel)
{
var envelopeQuery = Repo.Query;
if (request.Id is int id)
envelopeQuery = envelopeQuery.Where(e => e.Id == id);
else if (request.Uuid is string uuid)
envelopeQuery = envelopeQuery.Where(e => e.Uuid == uuid);
var envelope = await envelopeQuery
.Include(e => e.EnvelopeReceivers).ThenInclude(er => er.Receiver)
.Include(e => e.Documents)
.SingleOrDefaultAsync(cancel)
?? throw new NotFoundException($"Envelope is not found ID: {request.Id} UUID: {request.Uuid}");
if(request.UserId is int userId && userId != envelope.UserId)
throw new InvalidOperationException($"Envelope is not owned by user ID: {request.Id} UUID: {request.Uuid}");
// already burned
if (!request.BurnedAllowed && envelope.IsBurned())
throw new InvalidOperationException($"Envelope is already burned ID: {request.Id} UUID: {request.Uuid}");
// envelope is not completely signed
if (envelope.Status!= EnvelopeStatus.EnvelopeCompletelySigned)
throw new InvalidOperationException($"Envelope status is not completely signed ID: {request.Id} UUID: {request.Uuid}");
using var docStream = await Sender.Send(new SignPdfByEnvelopeCommand()
{
Envelope = envelope
}, cancel);
if (docStream.CanSeek)
docStream.Position = 0;
using var ms = new MemoryStream();
docStream.CopyTo(ms);
byte[] doc = ms.ToArray();
foreach (var er in envelope.EnvelopeReceivers)
{
await NtfServer.Notify(er.Receiver.EmailAddress, new DocumentSignedNotification()
{
DocumentTitle = envelope.Title,
NameReceiver = er.Name,
Attachments = [($"unterzeichnetes-dokument.pdf", doc)],
IsReadAndConfirm = envelope.IsReadAndConfirm(),
}, cancel);
}
if (request.SaveDocResult)
await Repo.UpdateAsync(e => e.DocResult = doc, e => e.Id == envelope.Id, cancel);
}
}

View File

@@ -0,0 +1,133 @@
using DigitalData.Core.Exceptions;
using EnvelopeGenerator.Application.Common.Configurations;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Enums;
using EnvelopeGenerator.Application.Common.Interfaces.Doc;
using EnvelopeGenerator.Application.Common.Interfaces.Services;
using EnvelopeGenerator.Application.DocReceiverElements.Queries;
using MediatR;
using Microsoft.Extensions.Options;
namespace EnvelopeGenerator.Application.PdfSigning;
/// <summary>
///
/// </summary>
public record SignPdfByElementCommand : IRequest<Stream>
{
/// <summary>
///
/// </summary>
public DocReceiverElementDto? Element { get; set; }
/// <summary>
///
/// </summary>
public int? ElementId { get; init; }
/// <summary>
/// Custom document
/// </summary>
public Stream? Document { get; set; }
/// <summary>
///
/// </summary>
public string? Phone { get; set; }
/// <summary>
///
/// </summary>
public string? Reason { get; set; }
/// <summary>
///
/// </summary>
public PdfSignatureCertificationLevel CertificationLevel { get; init; } = PdfSignatureCertificationLevel.NoChangesAllowed;
}
/// <summary>
///
/// </summary>
/// <param name="sender"></param>
/// <param name="signService"></param>
/// <param name="imgRenderer"></param>
/// <param name="options"></param>
public class SignPdfCommandHandler(ISender sender, IPdfDigitalSignatureService signService, IPdfPageImageRenderer imgRenderer, IOptionsMonitor<ESignParams> options) : IRequestHandler<SignPdfByElementCommand, Stream>
{
/// <summary>
///
/// </summary>
/// <param name="request"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public async Task<Stream> Handle(SignPdfByElementCommand request, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
// ensure element
if (request.Element is null)
{
var elements = await sender.Send(new ReadDocReceiverElementQuery()
{
Id = request.ElementId!,
IncludeDocument = request.Document is null
}, cancellationToken);
request.Element = elements.FirstOrDefault() ?? throw new NotFoundException($"Element with ID {request.ElementId} not found."); ;
}
// ensure document
if(request.Document is null)
{
if (request.Element?.Document?.ByteData is not { Length: > 0 })
throw new NotFoundException("Document data is missing or empty.");
request.Document = new MemoryStream(request.Element.Document.ByteData);
}
ValidateAfterDataIsComplete(request);
// create signature image from element
var img = imgRenderer.ComposeSignaturePreviewPng(request.Element);
// sign pdf
var outputPdf = new MemoryStream();
await signService.SignPdfAsync(
inputPdf: request.Document,
outputPdf: outputPdf,
options: new PdfSignOptionsDto
{
Name = request.Element.FullName!,
Location = request.Element.Position!,
Phone = request.Phone,
Reason = request.Reason ?? options.CurrentValue.DefaultReason,
Email = request.Element.Receiver!.EmailAddress,
ApplicationName = options.CurrentValue.ApplicationName,
DocumentElementId = request.Element.Id,
CertificationLevel = request.CertificationLevel,
Appearance = new PdfSignatureAppearanceDto(
PageNumber: request.Element.Page,
Left: request.Element.Left,
Bottom: request.Element.Y,
Width: request.Element.Width,
Height: request.Element.Height,
CoordinateUnit: PdfSignatureCoordinateUnit.Inch,
CoordinateOrigin: PdfSignatureCoordinateOrigin.TopLeft)
},
image: img,
cancellationToken: cancellationToken);
return outputPdf;
}
private static void ValidateAfterDataIsComplete(SignPdfByElementCommand request)
{
if(request.Element!.FullName is null)
throw new BadRequestException($"Document is not signed. FullName is required. Element ID: {request.Element.Id}");
else if(request.Element.Position is null)
throw new BadRequestException($"Document is not signed. Position is required. Element ID: {request.Element.Id}");
}
}

View File

@@ -0,0 +1,18 @@
using FluentValidation;
namespace EnvelopeGenerator.Application.PdfSigning;
public class SignPdfByElementCommandValidator : AbstractValidator<SignPdfByElementCommand>
{
public SignPdfByElementCommandValidator()
{
RuleFor(x => x)
.Must(x => (x.Element is not null) ^ x.ElementId.HasValue)
.WithMessage("Exactly one of Element or ElementId must be provided.");
RuleFor(x => x.ElementId)
.GreaterThan(0)
.When(x => x.ElementId.HasValue)
.WithMessage("ElementId must be greater than 0.");
}
}

View File

@@ -0,0 +1,94 @@
using DigitalData.Core.Abstraction.Application.Repository;
using DigitalData.Core.Exceptions;
using EnvelopeGenerator.Application.Common.Query;
using EnvelopeGenerator.Domain.Entities;
using EnvelopeGenerator.Domain.Exceptions;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace EnvelopeGenerator.Application.PdfSigning;
/// <summary>
///
/// </summary>
public record SignPdfByEnvelopeCommand : EnvelopeQueryBase, IRequest<Stream>
{
/// <summary>
///
/// </summary>
public int? ReceiverId { get; init; } = null;
internal int? UserId { get; private set; } = null;
/// <summary>
///
/// </summary>
/// <param name="userId"></param>
/// <returns></returns>
public SignPdfByEnvelopeCommand Authorized(int userId)
{
UserId = userId;
return this;
}
internal Envelope? Envelope { get; set; }
}
/// <summary>
///
/// </summary>
public class SignPdfByEnvelopeCommandHandler(IRepository<Envelope> Repo, ISender sender) : IRequestHandler<SignPdfByEnvelopeCommand, Stream>
{
/// <summary>
///
/// </summary>
/// <param name="request"></param>
/// <param name="cancel"></param>
/// <returns></returns>
/// <exception cref="NotImplementedException"></exception>
public async Task<Stream> Handle(SignPdfByEnvelopeCommand request, CancellationToken cancel)
{
if (request.Envelope is null)
{
var envelopeQuery = Repo.Query;
if (request.Id is int id)
envelopeQuery = envelopeQuery.Where(e => e.Id == id);
else if (request.Uuid is string uuid)
envelopeQuery = envelopeQuery.Where(e => e.Uuid == uuid);
request.Envelope = await envelopeQuery.Include(e => e.EnvelopeReceivers).Include(e => e.Documents).SingleOrDefaultAsync(cancel)
?? throw new NotFoundException($"Envelope is not found ID: {request.Id} UUID: {request.Uuid}");
}
var envelope = request.Envelope;
if (request.UserId is int userId && userId != envelope.UserId)
throw new AuthorizationException($"Envelope is not authorized for user with Id: {request.UserId} Envelope ID: {request.Id} UUID: {request.Uuid}");
if (envelope.EnvelopeReceivers.Count == 0)
throw new NotFoundException($"Envelope has no receivers to sign ID: {request.Id} UUID: {request.Uuid}");
if(envelope.Documents.FirstOrDefault() is not Document document || document.ByteData is not byte[] byteData || byteData.Length == 0)
throw new NotFoundException($"Envelope has no documents to sign ID: {request.Id} UUID: {request.Uuid}");
Stream doc = new MemoryStream(byteData);
var eReceivers = request.ReceiverId is int receiverId
? envelope.EnvelopeReceivers.Where(e => e.ReceiverId == receiverId)
: envelope.EnvelopeReceivers;
foreach (var eReceiver in eReceivers)
{
var signedDoc = await sender.Send(new SignPdfByEnvelopeReceiverCommand()
{
EnvelopeId = eReceiver.EnvelopeId,
ReceiverId = eReceiver.ReceiverId
}, cancel);
await doc.DisposeAsync();
doc = signedDoc;
}
return doc;
}
}

View File

@@ -0,0 +1,99 @@
using AutoMapper;
using DigitalData.Core.Abstraction.Application.Repository;
using DigitalData.Core.Exceptions;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Enums;
using EnvelopeGenerator.Application.Documents.Queries;
using EnvelopeGenerator.Application.EnvelopeReceivers.Queries;
using EnvelopeGenerator.Application.Envelopes.Queries;
using EnvelopeGenerator.Domain.Entities;
using EnvelopeGenerator.Domain.Exceptions;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace EnvelopeGenerator.Application.PdfSigning;
/// <summary>
///
/// </summary>
public record SignPdfByEnvelopeReceiverCommand : IRequest<Stream>
{
/// <summary>
///
/// </summary>
public int EnvelopeId { get; init; }
/// <summary>
///
/// </summary>
public int ReceiverId { get; init; }
/// <summary>
///
/// </summary>
public Stream? Document { get; set; }
}
/// <summary>
///
/// </summary>
public class SignPdfByEnvelopeReceiverCommandHandler(IRepository<DocReceiverElement> Repo, IMapper Mapper, ISender Sender) : IRequestHandler<SignPdfByEnvelopeReceiverCommand, Stream>
{
/// <summary>
///
/// </summary>
/// <param name="request"></param>
/// <param name="cancel"></param>
/// <returns></returns>
/// <exception cref="NotFoundException"></exception>
public async Task<Stream> Handle(SignPdfByEnvelopeReceiverCommand request, CancellationToken cancel)
{
cancel.ThrowIfCancellationRequested();
// read elements
var elements = await Repo.Where(el => el.ReceiverId == request.ReceiverId && el.Document.EnvelopeId == request.EnvelopeId)
.Include(e => e.Receiver)
.ToListAsync(cancel) ?? throw new NotFoundException($"No elements found for Envelope ID {request.EnvelopeId} and Receiver ID {request.ReceiverId}.");
var elementDtos = Mapper.Map<List<DocReceiverElementDto>>(elements)
.OrderBy(e => e.Page)
.ThenBy(e => e.Id)
.ToList();
// read secrets for phone number
var erSecrets = await Sender.Send(new ReadEnvelopeReceiverSecretQuery()
{
Envelope = new() { Id = request.EnvelopeId },
Receiver = new() { Id = request.ReceiverId }
}, cancel) ?? throw new DataIntegrityException($"No secrets found for Envelope ID {request.EnvelopeId} and Receiver ID {request.ReceiverId}.");
// read document
if(request.Document is null)
{
var unsignedDoc = await Sender.Send(new ReadDocumentQuery(EnvelopeId: request.EnvelopeId), cancel);
var unsignedDocBytes = unsignedDoc.ByteData ?? throw new NotFoundException($"Document not found for Envelope ID {request.EnvelopeId} and Receiver ID {request.ReceiverId}.");
request.Document = new MemoryStream(unsignedDocBytes);
}
Stream doc = request.Document;
// sign document for each element
foreach (var element in elementDtos)
{
var lastElement = element == elementDtos.Last();
var signedDoc = await Sender.Send(new SignPdfByElementCommand
{
Document = doc,
Element = element,
CertificationLevel = lastElement ? PdfSignatureCertificationLevel.NoChangesAllowed : PdfSignatureCertificationLevel.FillFormsAndAnnotate,
Phone = erSecrets.PhoneNumber
}, cancel);
await doc.DisposeAsync();
doc = signedDoc;
}
return doc;
}
}

View File

@@ -123,6 +123,9 @@
<data name="Back" xml:space="preserve">
<value>Zurück</value>
</data>
<data name="Next" xml:space="preserve">
<value>Weiter</value>
</data>
<data name="AuthenticatorSetup_Prefix" xml:space="preserve">
<value>Klicken Sie auf den</value>
</data>
@@ -564,6 +567,12 @@
<data name="AddReceiver" xml:space="preserve">
<value>Empfänger hinzufügen</value>
</data>
<data name="SenderWizard_RecentReceivers" xml:space="preserve">
<value>Zuletzt verwendete Empfänger</value>
</data>
<data name="Skip" xml:space="preserve">
<value>Überspringen</value>
</data>
<data name="DeleteReceiver" xml:space="preserve">
<value>Empfänger löschen</value>
</data>

View File

@@ -123,6 +123,9 @@
<data name="Back" xml:space="preserve">
<value>Back</value>
</data>
<data name="Next" xml:space="preserve">
<value>Next</value>
</data>
<data name="AuthenticatorSetup_Prefix" xml:space="preserve">
<value>Click the</value>
</data>
@@ -564,6 +567,12 @@
<data name="AddReceiver" xml:space="preserve">
<value>Add Receiver</value>
</data>
<data name="SenderWizard_RecentReceivers" xml:space="preserve">
<value>Recently used recipients</value>
</data>
<data name="Skip" xml:space="preserve">
<value>Skip</value>
</data>
<data name="DeleteReceiver" xml:space="preserve">
<value>Delete Receiver</value>
</data>

View File

@@ -123,6 +123,9 @@
<data name="Back" xml:space="preserve">
<value>Retour</value>
</data>
<data name="Next" xml:space="preserve">
<value>Suivant</value>
</data>
<data name="AuthenticatorSetup_Prefix" xml:space="preserve">
<value>Cliquez sur</value>
</data>
@@ -564,6 +567,12 @@
<data name="AddReceiver" xml:space="preserve">
<value>Ajouter un destinataire</value>
</data>
<data name="SenderWizard_RecentReceivers" xml:space="preserve">
<value>Destinataires recents</value>
</data>
<data name="Skip" xml:space="preserve">
<value>Passer</value>
</data>
<data name="DeleteReceiver" xml:space="preserve">
<value>Supprimer le destinataire</value>
</data>

View File

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

View File

@@ -67,7 +67,7 @@ namespace EnvelopeGenerator.Domain.Constants
{
public static bool IsActive(this EnvelopeStatus status)
{
return status >= EnvelopeStatus.EnvelopeCreated && status < EnvelopeStatus.EnvelopePartlySigned;
return status >= EnvelopeStatus.EnvelopeCreated && status <= EnvelopeStatus.EnvelopePartlySigned;
}
public static bool IsCompleted(this EnvelopeStatus status)

View File

@@ -11,7 +11,7 @@ using System.Collections.Generic;
namespace EnvelopeGenerator.Domain.Entities
{
[Table("TBSIG_DOCUMENT_RECEIVER_ELEMENT", Schema = "dbo")]
public class DocReceiverElement : IDocReceiverElement, IHasReceiver, IHasAddedWhen, IUpdateAuditable
public class DocReceiverElement : IDocReceiverElement, ISignatureInkPayload, IHasReceiver, IHasAddedWhen, IUpdateAuditable
{
public DocReceiverElement()
{

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; }
@@ -137,7 +141,6 @@ namespace EnvelopeGenerator.Domain.Entities
= false;
#endif
[NotMapped]
[Column("DOC_RESULT")]
public byte[]
#if nullable

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

@@ -2,6 +2,7 @@
<PropertyGroup>
<TargetFrameworks>net462;net7.0;net8.0;net9.0</TargetFrameworks>
<LangVersion>latest</LangVersion>
<AssemblyVersion>1.4.0</AssemblyVersion>
<FileVersion>1.4.0</FileVersion>
</PropertyGroup>

View File

@@ -0,0 +1,17 @@
#if NETFRAMEWORK
using System;
#endif
namespace EnvelopeGenerator.Domain.Exceptions
{
public class AuthorizationException : Exception
{
public AuthorizationException(string message) : base(message)
{
}
public AuthorizationException()
{
}
}
}

View File

@@ -0,0 +1,17 @@
#if NETFRAMEWORK
using System;
#endif
namespace EnvelopeGenerator.Domain.Exceptions
{
public class DataIntegrityException : Exception
{
public DataIntegrityException(string message) : base(message)
{
}
public DataIntegrityException() : base()
{
}
}
}

View File

@@ -3,6 +3,12 @@
public interface IEnvelope
{
int? EnvelopeTypeId { get; set; }
byte[]
#if nullable
?
#endif
DocResult { get; }
}
public static class EnvelopeExtensions
@@ -16,5 +22,10 @@
{
return envelope.EnvelopeTypeId != 2;
}
public static bool IsBurned(this IEnvelope envelope)
{
return envelope.DocResult != null;
}
}
}

View File

@@ -0,0 +1,28 @@
#if NETFRAMEWORK
using System;
#endif
namespace EnvelopeGenerator.Domain.Interfaces
{
public interface ISignatureInkPayload
{
#if nullable
byte[]? Ink { get; }
string? FullName { get; }
string? Position { get; }
string? Place { get; }
#else
byte[] Ink { get; }
string FullName { get; }
string Position { get; }
string Place { get; }
#endif
DateTime? ChangedWhen { get; }
}
}

View File

@@ -276,4 +276,61 @@
<data name="YesWithAttachment" xml:space="preserve">
<value>Yes, with Attachment</value>
</data>
<data name="ElectronicSignature" xml:space="preserve">
<value>Electronic Signature</value>
</data>
<data name="EnvelopeType" xml:space="preserve">
<value>Envelope Type</value>
</data>
<data name="Language" xml:space="preserve">
<value>Language</value>
</data>
<data name="CertificationType" xml:space="preserve">
<value>Certification</value>
</data>
<data name="TFAEnabled" xml:space="preserve">
<value>2FA enabled</value>
</data>
<data name="UseAccessCode" xml:space="preserve">
<value>Use Access Code</value>
</data>
<data name="FinalEmailToCreator" xml:space="preserve">
<value>Final Email to Creator</value>
</data>
<data name="FinalEmailToReceivers" xml:space="preserve">
<value>Final Email to Receivers</value>
</data>
<data name="SendReminderEmails" xml:space="preserve">
<value>Send Reminder Emails</value>
</data>
<data name="FirstReminderDays" xml:space="preserve">
<value>First Reminder (Days)</value>
</data>
<data name="ReminderIntervalDays" xml:space="preserve">
<value>Reminder Interval (Days)</value>
</data>
<data name="ExpiresWhenDays" xml:space="preserve">
<value>Expires After (Days)</value>
</data>
<data name="ExpiresWarningWhenDays" xml:space="preserve">
<value>Expiry Warning (Days)</value>
</data>
<data name="SenderEditor_EnvelopeSettings" xml:space="preserve">
<value>Envelope Settings</value>
</data>
<data name="SenderEditor_AdvancedOptional" xml:space="preserve">
<value>Advanced</value>
</data>
<data name="SenderEditor_SelectTemplate" xml:space="preserve">
<value>Select Template</value>
</data>
<data name="SenderEditor_Validation_ReminderInterval" xml:space="preserve">
<value>Please enter a reminder interval greater than 0 days.</value>
</data>
<data name="SenderEditor_Validation_FirstReminder" xml:space="preserve">
<value>Please enter valid days for the first reminder.</value>
</data>
<data name="SenderEditor_Validation_ExpiryWarning" xml:space="preserve">
<value>The expiry warning cannot be after the expiry date.</value>
</data>
</root>

View File

@@ -113,4 +113,61 @@
<data name="MessageInvitationSent" xml:space="preserve">
<value>Invitation envoyee</value>
</data>
<data name="ElectronicSignature" xml:space="preserve">
<value>Signature electronique</value>
</data>
<data name="EnvelopeType" xml:space="preserve">
<value>Type d'enveloppe</value>
</data>
<data name="Language" xml:space="preserve">
<value>Langue</value>
</data>
<data name="CertificationType" xml:space="preserve">
<value>Certification</value>
</data>
<data name="TFAEnabled" xml:space="preserve">
<value>2FA active</value>
</data>
<data name="UseAccessCode" xml:space="preserve">
<value>Utiliser un code d'acces</value>
</data>
<data name="FinalEmailToCreator" xml:space="preserve">
<value>E-mail final au createur</value>
</data>
<data name="FinalEmailToReceivers" xml:space="preserve">
<value>E-mail final aux destinataires</value>
</data>
<data name="SendReminderEmails" xml:space="preserve">
<value>Envoyer des e-mails de rappel</value>
</data>
<data name="FirstReminderDays" xml:space="preserve">
<value>Premier rappel (jours)</value>
</data>
<data name="ReminderIntervalDays" xml:space="preserve">
<value>Intervalle de rappel (jours)</value>
</data>
<data name="ExpiresWhenDays" xml:space="preserve">
<value>Expire apres (jours)</value>
</data>
<data name="ExpiresWarningWhenDays" xml:space="preserve">
<value>Avertissement d'expiration (jours)</value>
</data>
<data name="SenderEditor_EnvelopeSettings" xml:space="preserve">
<value>Parametres de l'enveloppe</value>
</data>
<data name="SenderEditor_AdvancedOptional" xml:space="preserve">
<value>Avance</value>
</data>
<data name="SenderEditor_SelectTemplate" xml:space="preserve">
<value>Selectionner un modele</value>
</data>
<data name="SenderEditor_Validation_ReminderInterval" xml:space="preserve">
<value>Veuillez saisir un intervalle de rappel superieur a 0 jour.</value>
</data>
<data name="SenderEditor_Validation_FirstReminder" xml:space="preserve">
<value>Veuillez saisir des jours valides pour le premier rappel.</value>
</data>
<data name="SenderEditor_Validation_ExpiryWarning" xml:space="preserve">
<value>L'avertissement d'expiration ne peut pas etre apres la date d'expiration.</value>
</data>
</root>

View File

@@ -279,4 +279,61 @@
<data name="YesWithAttachment" xml:space="preserve">
<value>Ja, mit Anhang</value>
</data>
<data name="ElectronicSignature" xml:space="preserve">
<value>Elektronische Signatur</value>
</data>
<data name="EnvelopeType" xml:space="preserve">
<value>Umschlagtyp</value>
</data>
<data name="Language" xml:space="preserve">
<value>Sprache</value>
</data>
<data name="CertificationType" xml:space="preserve">
<value>Zertifizierung</value>
</data>
<data name="TFAEnabled" xml:space="preserve">
<value>2FA aktiviert</value>
</data>
<data name="UseAccessCode" xml:space="preserve">
<value>Zugriffscode verwenden</value>
</data>
<data name="FinalEmailToCreator" xml:space="preserve">
<value>Finale E-Mail an Ersteller</value>
</data>
<data name="FinalEmailToReceivers" xml:space="preserve">
<value>Finale E-Mail an Empfänger</value>
</data>
<data name="SendReminderEmails" xml:space="preserve">
<value>Erinnerungs-E-Mails senden</value>
</data>
<data name="FirstReminderDays" xml:space="preserve">
<value>Erste Erinnerung (Tage)</value>
</data>
<data name="ReminderIntervalDays" xml:space="preserve">
<value>Erinnerungsintervall (Tage)</value>
</data>
<data name="ExpiresWhenDays" xml:space="preserve">
<value>Läuft ab nach (Tage)</value>
</data>
<data name="ExpiresWarningWhenDays" xml:space="preserve">
<value>Ablaufwarnung (Tage)</value>
</data>
<data name="SenderEditor_EnvelopeSettings" xml:space="preserve">
<value>Envelope-Einstellungen</value>
</data>
<data name="SenderEditor_AdvancedOptional" xml:space="preserve">
<value>Erweitert</value>
</data>
<data name="SenderEditor_SelectTemplate" xml:space="preserve">
<value>Vorlage auswählen</value>
</data>
<data name="SenderEditor_Validation_ReminderInterval" xml:space="preserve">
<value>Bitte geben Sie ein Erinnerungsintervall größer als 0 Tage ein.</value>
</data>
<data name="SenderEditor_Validation_FirstReminder" xml:space="preserve">
<value>Bitte geben Sie gültige Tage für die erste Erinnerung ein.</value>
</data>
<data name="SenderEditor_Validation_ExpiryWarning" xml:space="preserve">
<value>Die Ablaufwarnung darf nicht nach dem Ablaufdatum liegen.</value>
</data>
</root>

View File

@@ -105,10 +105,10 @@ Public Class frmEnvelopeMainData
End If
End Sub
Private Function TranslateCertificationType(pType As CertificationType) As String
Return Resources.Model.ResourceManager.GetString(pType.ToString())
Return CommonServices.My.Resources.Model.ResourceManager.GetString(pType.ToString())
End Function
Private Function TranslateFinalEmailType(pType As FinalEmailType) As String
Return Resources.Model.ResourceManager.GetString(pType.ToString())
Return CommonServices.My.Resources.Model.ResourceManager.GetString(pType.ToString())
End Function
Private Sub btOK_Click(sender As Object, e As EventArgs) Handles btOK.Click

View File

@@ -0,0 +1,23 @@
using DocumentService.Client.Configuration;
using DocumentService.Client.Extensions;
using EnvelopeGenerator.Application.Common.Interfaces.Services;
using EnvelopeGenerator.Infrastructure.Crypt.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace EnvelopeGenerator.Infrastructure.Crypt.DependencyInjection;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddCryptInfrastructure(this IServiceCollection services, IConfiguration configuration)
{
var baseUrl = configuration["DocumentService:BaseUrl"]
?? throw new InvalidOperationException("DocumentService:BaseUrl is not configured.");
services.AddDocumentServiceClients(options => options.BaseUrl = baseUrl);
services.AddSingleton<IPdfDigitalSignatureService, PdfDigitalSignatureService>();
return services;
}
}

View File

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DocumentService.Client" Version="1.3.2" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.10" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\EnvelopeGenerator.Application\EnvelopeGenerator.Application.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,384 @@
# EnvelopeGenerator.Infrastructure.Crypt
Umfassende technische Dokumentation der PKCS#11/SoftHSM-Integration fuer `EnvelopeGenerator`.
Dieses Dokument konsolidiert und ersetzt die verteilten Runbooks aus:
- `pkcs11-proxy-production/README.md`
- `pkcs11-proxy-production/SSH_TEMP_ACCESS_MUST_DO.md`
- aeltere Abschnitte in diesem README
Zielgruppe: Senior Developer, Platform Engineers, DevOps, Security Owner.
---
## 1. Architekturueberblick
### 1.1 Ziel
`EnvelopeGenerator.Infrastructure.Crypt` stellt die kryptographische Infrastruktur fuer PDF-Signaturen bereit:
- Signaturerzeugung mit Schluesseln in SoftHSM/PKCS#11
- Zertifikat-Auslese aus Token
- Signaturpruefung auf PDF-Ebene
- Health/Startup-Selbsttest fuer Betriebsfaehigkeit
### 1.2 Topologie (aktueller Betriebsmodus)
Der aktuell implementierte Produktions-nahe Pfad ist:
1. Linux hostet SoftHSM + `pkcs11-daemon` (Proxy-Server).
2. Windows hostet `EnvelopeGenerator.Server`.
3. Windows laedt ein Windows-kompatibles Proxy-PKCS#11-Modul (`libpkcs11-proxy.dll`).
4. PKCS#11-Operationen werden ueber Socket (`PKCS11_PROXY_SOCKET`) an Linux weitergeleitet.
Wichtig:
- Ein Windows-Prozess kann **kein Linux `.so`** direkt laden.
- Private Keys verbleiben im Token (nicht exportierbar).
### 1.3 Laufzeitpfade in der Anwendung
`Pkcs11InteropBootstrap` loest Assets relativ zu `Crypt:Pkcs11:AssetsRootPath` auf:
- Modul: `pkcs11\windows\proxy\libpkcs11-proxy.dll`
- Tool-Fallback: `pkcs11\windows\opensc\tools\pkcs11-tool.exe`
Wenn `AssetsRootPath` leer ist, wird `AppContext.BaseDirectory` verwendet.
---
## 2. Linux-Seite: SoftHSM + Proxy Setup
### 2.1 Basispakete
Beispiel (Debian/Ubuntu):
```bash
sudo apt-get update && sudo apt-get install -y softhsm2 opensc p11-kit libengine-pkcs11-openssl
```
### 2.2 SoftHSM-Modulpfad pruefen
```bash
ldconfig -p | grep softhsm
find /usr -name "libsofthsm2.so" 2>/dev/null
```
### 2.3 Token initialisieren
```bash
softhsm2-util --show-slots
softhsm2-util --init-token --slot <uninitialized-slot> --label CompanyProdToken --so-pin <so-pin> --pin <user-pin>
softhsm2-util --show-slots
```
Hinweis: Nach `--init-token` wird der Slot i. d. R. neu zugewiesen.
### 2.4 Schluesselpaar erzeugen
```bash
pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so \
--slot <slot-id> --login --pin <user-pin> \
--keypairgen --key-type rsa:3072 --label company-sign-key-v1 --id 01
```
### 2.5 Zertifikat importieren
```bash
pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so \
--slot <slot-id> --login --pin <user-pin> \
--write-object ./company-sign-cert-v1.der --type cert --id 01 --label company-sign-cert-v1
```
### 2.6 Objektzustand verifizieren
```bash
pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so --slot <slot-id> --login --pin <user-pin> --list-objects
pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so --slot <slot-id> --login --pin <user-pin> --list-objects --type cert
```
### 2.7 Proxy-Daemon endpoint stabilisieren
Ein wiederkehrendes Betriebsproblem war ein zufaelliger/local-only Listener. Fix:
```bash
sudo mkdir -p /etc/systemd/system/pkcs11-proxy.service.d
printf "[Service]\nEnvironment=PKCS11_DAEMON_SOCKET=tcp://0.0.0.0:5657\n" | sudo tee /etc/systemd/system/pkcs11-proxy.service.d/override.conf
sudo systemctl daemon-reload
sudo systemctl restart pkcs11-proxy.service
```
Pruefung:
```bash
systemctl show pkcs11-proxy.service -p Environment --no-pager
ss -ltnp | grep 5657
```
---
## 3. Web-API/Server-Seite: Installation und Asset-Bereitstellung
### 3.1 Bezugsquellen der Windows-Artefakte
Fuer den Proxy-Betrieb werden auf Windows benoetigt:
1. Proxy-PKCS#11-DLL (`libpkcs11-proxy.dll`)
2. Abhaengige Runtime-DLLs (z. B. MinGW runtime, je nach Build)
3. `pkcs11-tool.exe` + zugehoerige OpenSC-DLLs fuer Fallback
Beschaffung:
- Build aus derselben `pkcs11-proxy`-Codebasis/Commit wie Linux-Daemon (Kompatibilitaet sicherstellen)
- oder kontrolliert bereitgestellte, intern freigegebene Binaries
### 3.2 Empfohlene Ablage im Repo
Bundled Assets liegen unter:
- `EnvelopeGenerator.Infrastructure.Crypt/resources/pkcs11/windows/proxy/`
- `EnvelopeGenerator.Infrastructure.Crypt/resources/pkcs11/windows/opensc/tools/`
- `EnvelopeGenerator.Infrastructure.Crypt/resources/pkcs11/windows/opensc/pkcs11/`
Diese werden beim Build in den Output kopiert (siehe `.csproj`-Copy-Regeln).
### 3.3 Deployment-Muster
Es gibt zwei gueltige Wege:
1. **Bundled (Dev/QA, schnelle Reproduzierbarkeit)**
- Assets werden mit dem Build ausgeliefert.
- `AssetsRootPath = null` -> Aufloesung via `AppContext.BaseDirectory`.
2. **Zentraler Asset-Root (Prod, betriebliche Kontrolle)**
- Assets in zentralem Pfad (z. B. `E:\DataFiles\Digital Data\EnvelopeGenerator`).
- `AssetsRootPath` in `appsettings.json` auf diesen Root setzen.
### 3.4 Anwendungskonfiguration (`Crypt:Pkcs11`)
Beispiel:
```json
{
"Crypt": {
"Pkcs11": {
"AssetsRootPath": "E:\\DataFiles\\Digital Data\\EnvelopeGenerator",
"SlotId": 2021549410,
"TokenLabel": "CompanyProdToken",
"ProxySocket": "tcp://172.24.12.64:5657",
"UserPin": "<secret>",
"PrivateKeyLabel": "company-sign-key-v1",
"CertificateLabel": "company-sign-cert-v1",
"ApplicationName": "signFLOW",
"DefaultReason": null,
"UseUtcSigningTime": true,
"DefaultCertificationLevel": "NoCertification"
}
}
}
```
Sicherheitsregel:
- `UserPin` niemals in versionierte Dateien einchecken.
- Secrets nur in Secret Store / nicht versionierte `appsettings.Secrets.json` / Vault.
---
## 4. Implementierte Services, Modelle, DTOs
### 4.1 DI und Startverhalten
- `AddCryptInfrastructure(...)` in `DependencyInjection/ServiceCollectionExtensions.cs`
- bindet `Pkcs11Options`
- registriert `IPdfDigitalSignatureService`
- registriert Startup-Self-Test Hosted Service
### 4.2 Konfigurationsmodell
- `Pkcs11Options` (`EnvelopeGenerator.Application/Common/Configurations/Pkcs11Options.cs`)
- `AssetsRootPath`, `SlotId`, `TokenLabel`, `ProxySocket`
- `UserPin`, `PrivateKeyLabel`, `CertificateLabel`
- `ApplicationName`, `DefaultReason`, `UseUtcSigningTime`, `DefaultCertificationLevel`
- `ResolvedLibraryPath`, `ResolvedToolPath` (Runtime-resolved)
### 4.3 Kernservices
1. `PdfDigitalSignatureService`
- Signiert PDF via DevExpress `PdfDeferredSignatureBuilder`
- Nutzt `Pkcs11ExternalSigner` fuer externe Signatur
- Erzwingt `ApplicationName` aus Config (nicht client-overridable)
- Erweitert `Reason` um strukturiertes Metaformat:
- `SFMETA:v1;doc=<uuid>;sha256=<hash>`
- Parse beim Verify und Mapping in DTO
2. `Pkcs11InteropBootstrap`
- Loest Modul-/Toolpfade auf
- Setzt `PKCS11_PROXY_SOCKET`/`PKCS11_DAEMON_SOCKET`
- PATH-Ergaenzung fuer native Abhaengigkeiten
- Warmup via `pkcs11-tool -L`
3. `Pkcs11SessionRunner`
- Interop Library-Load mit mehreren Init-Modi (Fallback-Reihenfolge)
- Slot-Resolution (`SlotId`/`TokenLabel`)
- Session Login/Logout
4. `Pkcs11ToolBridge`
- Fallback via Prozessaufruf von `pkcs11-tool`
- Cert read und DigestInfo-Signatur
5. `Pkcs11StartupSelfTestHostedService`
- Fuehrt beim Start Interop-Probe aus
- Fallback-Probe bei Interop-Fehler
- Schreibt strukturierten Status
### 4.4 Wichtige Interfaces/DTOs
- `IPdfDigitalSignatureService`
- `SignPdfAsync(...)`, `CheckPdfSignatureAsync(...)`, `ExtractOriginalPdfAsync(...)`
- `IPkcs11SelfTestStatusProvider`
- `PdfSignOptionsDto`
- `UUID`, `Reason`, `Name`, `Location`, `Email`, `Phone`, `CertificationLevel`
- `PdfSignatureCheckResultDto` / `PdfSignatureCheckItemDto`
- inklusive `BusinessReason`, `UUID`, `OriginalDocumentSha256`
- `Pkcs11SelfTestStatusDto`
---
## 5. Betriebserfahrungen und Challenges
### 5.1 Interop vs Tool-Verhalten
Beobachtetes Muster in der realen Umgebung:
- `pkcs11-tool` konnte erfolgreich auf Token zugreifen und signieren.
- `Pkcs11Interop` scheiterte mit `CKR_FUNCTION_NOT_SUPPORTED` bzw. fehlendem `C_Initialize` Pointer in bestimmten Modi.
Konsequenz:
- Architektur wurde auf **interop-first + controlled tool-fallback** erweitert.
- Health-Endpunkt differenziert Modus (`interop`, `pkcs11-tool`, `failed`).
### 5.2 Typische Fehlbilder
- `CKR_DEVICE_ERROR` bei Socket-/Proxy-Problemen
- `CRYPT_PROVIDER_UNAVAILABLE` bei Pfad/ABI/Dependency-Mismatch
- `CRYPT_SLOT_NOT_FOUND` bei Slot-/Label-Mismatch
- `CKR_PIN_INCORRECT` bei PIN-Fehler
- 307 Redirects bei HTTP/HTTPS-Mix in manuellen Tests
### 5.3 Plattformfallen
- MSVC vs MinGW Build-Unterschiede fuer Proxy-Client
- fehlende Laufzeit-DLLs (`PATH`/Side-by-side)
- Architektur-Mismatch x86 vs x64
- Daemon-Bindings nicht stabil (random localhost port statt fixed endpoint)
---
## 6. Health, Monitoring und Verifikation
### 6.1 API Health
- Endpoint: `GET /api/health/crypt`
- Liefert:
- `success`, `mode`, `summary`, `interopError`, `toolError`, `checkedAtUtc`
Empfehlung:
- In Monitoring aufnehmen und bei `mode=pkcs11-tool` ueber laengere Zeit Warnung triggern.
### 6.2 Smoke-Test (empfohlen vor Release)
1. `GET /api/health/crypt` -> `success=true`
2. `POST /api/PdfSignature/sign` mit Test-PDF
3. `POST /api/PdfSignature/is-signed`
4. Pruefen:
- `signed=true`
- `allSignaturesCryptographicallyValid=true`
- `UUID`/`OriginalDocumentSha256` korrekt gemappt
---
## 7. Sicherheits- und Betriebsrichtlinien
### 7.1 Geheimnisse
- PIN, SO-PIN, Root-Creds niemals in Doku/Screenshots/Repo.
- Nach Troubleshooting: Credentials rotieren.
### 7.2 Temporare SSH-Freigaben zwingend rueckbauen
Wenn SSH temporaer geoeffnet wurde, MUSS danach Rollback erfolgen:
```bash
sudo systemctl disable --now ssh && sudo ufw delete allow from 172.24.10.11 to any port 22 proto tcp && sudo ufw status verbose && sudo systemctl status ssh --no-pager
```
Optional harte Bereinigung:
```bash
sudo apt-get purge -y openssh-server && sudo apt-get autoremove -y
```
### 7.3 Schluesselrotation
- Neue Labels (`...-v2`) anlegen
- App umkonfigurieren
- Sign/Verify Regression testen
- Altmaterial erst nach Freigabe entfernen
---
## 8. API-Verhalten bei Signaturmetadaten
Aktueller Standard:
- `UUID` kommt aus Sign-Input (nullable string). Wenn ungeltig/leer -> neue Guid wird erzeugt.
- `Reason` wird intern angereichert mit strukturiertem Meta (`SFMETA:v1`).
- `ApplicationName` ist rein serverseitig aus `appsettings` gesetzt.
- `SigningTime` ist rein serverseitig und wird automatisch gesetzt (`UseUtcSigningTime=true` => `UtcNow`, sonst lokale Zeit).
- `CertificationLevel` wird im Controller serverseitig gesetzt (`FormFillingAndAnnotations`), nicht vom Client.
Damit ist das Verhalten deterministisch und manipulationsaermer fuer Integrationsclients.
---
## 9. Troubleshooting-Checkliste (praxisnah)
1. Erreichbarkeit Linux Proxy:
- `Test-NetConnection <linux-host> -Port 5657`
2. Token sichtbar?
- `pkcs11-tool --module <dll> -L -v`
3. Zertifikat lesbar?
- `--read-object --type cert --label <cert-label>`
4. Signatur technisch moeglich?
- `--sign -m SHA256-RSA-PKCS ...`
5. App-Health check:
- `/api/health/crypt`
6. App-sign/verify end-to-end:
- `/api/PdfSignature/sign` + `/api/PdfSignature/is-signed`
Wenn Interop scheitert, aber Tool funktioniert:
- Betrieb bleibt verfuegbar (Fallback),
- aber Incident/Bug fuer Interop-Kompatibilitaet erfassen.
---
## 10. Ausblick / empfohlene Verbesserungen
1. Binary Transport statt Base64 bei grossen PDFs in Render/Sign-Pipelines.
2. Persistente Metriken fuer Fallback-Quote (`interop` vs `tool`).
3. Zertifikatskette/OCSP/CRL Validierung als optionales Policy-Modul.
4. CI-Pruefung auf unsichere SQL-Muster (parallel zum Security-Workstream).
5. Standardisierte Toast/UX-Rueckmeldungen fuer Signierfehler in UI.
---
## 11. Referenzen
- SoftHSMv2: `https://github.com/softhsm/SoftHSMv2`
- OpenSC / pkcs11-tool: `https://github.com/OpenSC/OpenSC/wiki/Using-pkcs11-tool-and-OpenSSL`
- PKCS#11 Standard: `https://www.oasis-open.org/committees/pkcs11/`

View File

@@ -0,0 +1,77 @@
using DocumentService.Client.Interfaces;
using DocumentService.Client.Models.Requests;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Interfaces.Services;
using ClientCertLevel = DocumentService.Client.Models.Requests.PdfSignatureCertificationLevel;
using ClientCoordUnit = DocumentService.Client.Models.Requests.PdfSignatureCoordinateUnit;
using ClientCoordOrigin = DocumentService.Client.Models.Requests.PdfSignatureCoordinateOrigin;
namespace EnvelopeGenerator.Infrastructure.Crypt.Services;
/// <summary>
/// Proxy that forwards PDF signing to the remote DocumentService API via <see cref="IPdfSignatureClient"/>.
/// </summary>
public sealed class PdfDigitalSignatureService(IPdfSignatureClient signatureClient) : IPdfDigitalSignatureService
{
public async Task SignPdfAsync(Stream inputPdf, Stream outputPdf, PdfSignOptionsDto? options = null, Stream? image = null, CancellationToken cancellationToken = default)
{
var pdfBytes = await ReadAllBytesAsync(inputPdf, cancellationToken);
var imageBytes = image is null ? null : await ReadAllBytesAsync(image, cancellationToken);
using var signed = await signatureClient.SignAsync(pdfBytes, Map(options), imageBytes, cancellationToken);
await signed.CopyToAsync(outputPdf, cancellationToken);
}
public Task<PdfSignatureCheckResultDto> CheckPdfSignatureAsync(Stream inputPdf, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Signature checking is handled by DocumentService and is not available through this proxy.");
private static SignPdfOptions Map(PdfSignOptionsDto? o) => new()
{
DocumentElementId = o?.DocumentElementId ?? 0,
Name = o?.Name ?? string.Empty,
Reason = o?.Reason,
Location = o?.Location,
Email = o?.Email,
Phone = o?.Phone,
ApplicationName = o?.ApplicationName,
CertificationLevel = (ClientCertLevel)(o?.CertificationLevel ?? EnvelopeGenerator.Application.Common.Enums.PdfSignatureCertificationLevel.NoCertification),
Appearance = o?.Appearance is null ? null : MapAppearance(o.Appearance)
};
private static PdfSignatureAppearance MapAppearance(PdfSignatureAppearanceDto a) => new()
{
PageNumber = a.PageNumber,
Left = a.Left,
Bottom = a.Bottom,
Width = a.Width,
Height = a.Height,
ShowLabels = a.ShowLabels,
ShowName = a.ShowName,
ShowDate = a.ShowDate,
ShowReason = a.ShowReason,
ShowLocation = a.ShowLocation,
ShowDistinguishedName = a.ShowDistinguishedName,
DateTimeFormat = a.DateTimeFormat,
CoordinateUnit = (ClientCoordUnit)a.CoordinateUnit,
CoordinateOrigin = (ClientCoordOrigin)a.CoordinateOrigin,
Font = new PdfSignatureFont
{
FamilyName = a.Font.FamilyName,
Size = a.Font.Size,
IsBold = a.Font.IsBold,
IsItalic = a.Font.IsItalic,
IsUnderline = a.Font.IsUnderline,
Color = new PdfSignatureColor { R = a.Font.Color.R, G = a.Font.Color.G, B = a.Font.Color.B }
}
};
private static async Task<byte[]> ReadAllBytesAsync(Stream stream, CancellationToken cancellationToken)
{
if (stream is MemoryStream memory && memory.TryGetBuffer(out var buffer))
return buffer.AsSpan(0, (int)memory.Length).ToArray();
using var copy = new MemoryStream();
await stream.CopyToAsync(copy, cancellationToken);
return copy.ToArray();
}
}

View File

@@ -1,7 +1,9 @@
using DevExpress.Drawing;
using DevExpress.Pdf;
using DigitalData.Core.Exceptions;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Interfaces.Doc;
using EnvelopeGenerator.Domain.Interfaces;
namespace EnvelopeGenerator.Infrastructure.Doc;
@@ -54,4 +56,125 @@ public class PdfPageImageRenderer : IPdfPageImageRenderer
return Task.FromResult<IReadOnlyList<PdfPageImageDto>>(results);
}
public Stream ComposeSignaturePreviewPng(ISignatureInkPayload element)
{
const double sigW = 1.77 * 72;
const double sigH = 1.96 * 72;
const double imgRatio = 0.52;
const double lineH = 11.5;
const double bgPad = 3.0;
const double padding = 3.0;
var date = element.ChangedWhen!.Value.ToString("dd.MM.yyyy");
var position = string.IsNullOrWhiteSpace(element.Position) ? null : element.Position.Trim();
var x = bgPad;
var y = bgPad;
var imgH = sigH * imgRatio;
var lineY = y + imgH + 1.0;
var textY = lineY + 1.5;
var row1Y = textY;
var row2Y = row1Y + lineH;
var row3Y = position is null ? row2Y : row2Y + lineH;
var contentBottom = row3Y + lineH;
var pageWidth = sigW + (bgPad * 2);
var pageHeight = contentBottom + bgPad;
var inkBytes = this.EnsureSupportedImageBytes(element.Ink!);
using var processor = new PdfDocumentProcessor();
processor.CreateEmptyDocument();
var page = processor.AddNewPage(new PdfRectangle(0, 0, pageWidth, pageHeight));
using var graphics = processor.CreateGraphics();
using var bgBrush = new DXSolidBrush(System.Drawing.Color.FromArgb(255, 255, 253, 240));
using var linePen = new DXPen(System.Drawing.Color.FromArgb(180, 100, 100, 120), 0.5f);
using var nameBrush = new DXSolidBrush(System.Drawing.Color.FromArgb(255, 20, 20, 20));
using var infoBrush = new DXSolidBrush(System.Drawing.Color.FromArgb(255, 80, 80, 80));
var fontBold = new DXFont("Arial", 7.5f, DXFontStyle.Bold);
var fontNormal = new DXFont("Arial", 6.5f, DXFontStyle.Regular);
graphics.FillRectangle(bgBrush, new System.Drawing.RectangleF(0, 0, (float)pageWidth, (float)pageHeight));
var imageArea = new System.Drawing.RectangleF((float)x, (float)y, (float)sigW, (float)imgH);
var imageRect = FitImageContain(imageArea, inkBytes);
graphics.DrawImage(inkBytes, imageRect);
graphics.DrawLine(linePen, (float)(x + 2), (float)lineY, (float)(x + sigW - 2), (float)lineY);
graphics.DrawString(
element.FullName!.Trim(),
fontBold,
nameBrush,
new System.Drawing.RectangleF((float)(x + padding), (float)row1Y, (float)(sigW - (padding * 2)), (float)lineH));
if (position is not null)
{
graphics.DrawString(
position,
fontNormal,
infoBrush,
new System.Drawing.RectangleF((float)(x + padding), (float)row2Y, (float)(sigW - (padding * 2)), (float)lineH));
}
graphics.DrawString(
$"{element.Place!.Trim()}, {date}",
fontNormal,
infoBrush,
new System.Drawing.RectangleF((float)(x + padding), (float)row3Y, (float)(sigW - (padding * 2)), (float)lineH));
graphics.AddToPageForeground(page, 72, 72);
using var pdfStream = new MemoryStream();
processor.SaveDocument(pdfStream);
pdfStream.Position = 0;
using var rasterProcessor = new PdfDocumentProcessor();
rasterProcessor.LoadDocument(pdfStream);
var renderOptions = PdfPageRenderingParameters.CreateWithResolution(400f);
using DXBitmap bitmap = rasterProcessor.CreateDXBitmap(1, renderOptions);
var pngStream = new MemoryStream();
bitmap.Save(pngStream, DXImageFormat.Png);
pngStream.Position = 0;
return pngStream;
}
private static System.Drawing.RectangleF FitImageContain(System.Drawing.RectangleF area, byte[] imageBytes)
{
using var imageStream = new MemoryStream(imageBytes, writable: false);
using var image = DXImage.FromStream(imageStream);
if (image.Width <= 0 || image.Height <= 0)
return area;
var scale = Math.Min(area.Width / image.Width, area.Height / image.Height);
var drawW = image.Width * scale;
var drawH = image.Height * scale;
var drawX = area.X + ((area.Width - drawW) / 2f);
var drawY = area.Y + ((area.Height - drawH) / 2f);
return new System.Drawing.RectangleF(drawX, drawY, drawW, drawH);
}
/// <summary>
///
/// </summary>
/// <param name="bytes"></param>
/// <returns></returns>
public bool IsSupportedImageFormat(byte[] bytes)
{
var isPng = bytes.Length >= 4 &&
bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47;
if (isPng)
return true;
var isJpeg = bytes.Length >= 3 &&
bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF;
return isJpeg;
}
}

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

@@ -0,0 +1,49 @@
using DigitalData.MessagingService.Client;
using EnvelopeGenerator.Application.Common.Interfaces.Services.Notification;
using EnvelopeGenerator.Infrastructure.Notification.Configuration;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace EnvelopeGenerator.Infrastructure.Notification;
public static class DependencyInjection
{
public static IServiceCollection AddNotificationInfrastructure(this IServiceCollection services, IConfiguration configuration, string sectionName = NotificationOptions.SectionName)
{
var ntfConfiguration = configuration.GetSection(sectionName);
services.Configure<NotificationOptions>(ntfConfiguration);
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)
{
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

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net8.0;</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DigitalData.MessagingService.Client" Version="3.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.10" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\EnvelopeGenerator.Application\EnvelopeGenerator.Application.csproj" />
</ItemGroup>
</Project>

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

@@ -0,0 +1,45 @@
using DigitalData.Core.Abstraction.Application.Repository;
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 EnvelopeGenerator.Domain.Exceptions;
using EnvelopeGenerator.Infrastructure.Notification.Configuration;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace EnvelopeGenerator.Infrastructure.Notification;
public class EnvelopeNotificationService(IRepository<EmailTemplate> TempRepo, IMessagingServiceClient MsgClient, IOptions<NotificationOptions> ntfOpt) : IEnvelopeNotificationService
{
private readonly NotificationOptions NtfOpt = ntfOpt.Value;
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 = template.Body.ReplacePlaceholders(notification),
Subject = template.Subject.ReplacePlaceholders(notification),
Recipients = [receiverEmail],
Sender = NtfOpt.SenderEmail.ToSender(),
IsHtml = true
};
// 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

@@ -0,0 +1,24 @@
using EnvelopeGenerator.Domain.Constants;
namespace EnvelopeGenerator.Server.Client.Components.Sender;
public sealed record CreateEnvelopeWizardResult(
string Title,
string Message,
int? EnvelopeTypeId,
string Language,
bool UseAccessCode,
bool TFAEnabled,
CertificationType CertificationType,
int FinalEmailToCreator,
int FinalEmailToReceivers,
bool SendReminderEmails,
int FirstReminderDays,
int ReminderIntervalDays,
int ExpiresWhenDays,
int ExpiresWarningWhenDays,
IReadOnlyList<CreateEnvelopeWizardReceiver> Receivers,
byte[]? DocumentBytes,
string? DocumentFileName);
public sealed record CreateEnvelopeWizardReceiver(string FullName, string EmailAddress);

View File

@@ -0,0 +1,104 @@
@using Microsoft.AspNetCore.Components.Forms
<div class="sender-pdf-dropzone @DropZoneClass">
<div class="sender-pdf-dropzone__content">
<div class="sender-pdf-dropzone__title">@CurrentActionText</div>
@if (!string.IsNullOrWhiteSpace(DragDropHintText))
{
<div class="sender-pdf-dropzone__hint">@DragDropHintText</div>
}
</div>
<InputFile OnChange="OnFileSelectedAsync"
accept="@Accept"
disabled="@Disabled"
@ondragenter="OnDragEnter"
@ondragleave="OnDragLeave"
@ondragover="OnDragOver"
@ondrop="OnDrop"
class="sender-pdf-dropzone__input" />
</div>
@code {
[Parameter]
public bool Disabled { get; set; }
[Parameter]
public bool HasDocument { get; set; }
[Parameter]
public string Accept { get; set; } = ".pdf";
[Parameter]
public string UploadText { get; set; } = "PDF hochladen";
[Parameter]
public string ReplaceText { get; set; } = "PDF ersetzen";
[Parameter]
public string DragDropHintText { get; set; } = "Datei hierhin ziehen oder klicken";
[Parameter]
public EventCallback<InputFileChangeEventArgs> FileSelected { get; set; }
private int _dragDepth;
private bool _isDragActive;
private string CurrentActionText => HasDocument ? ReplaceText : UploadText;
private string DropZoneClass
{
get
{
if (Disabled)
return "sender-pdf-dropzone--disabled";
return _isDragActive ? "sender-pdf-dropzone--drag-active" : string.Empty;
}
}
private void OnDragEnter(DragEventArgs _)
{
if (Disabled)
return;
_dragDepth++;
_isDragActive = true;
}
private void OnDragLeave(DragEventArgs _)
{
if (Disabled)
return;
_dragDepth = Math.Max(0, _dragDepth - 1);
if (_dragDepth == 0)
_isDragActive = false;
}
private void OnDragOver(DragEventArgs _)
{
if (Disabled)
return;
_isDragActive = true;
}
private void OnDrop(DragEventArgs _)
{
_dragDepth = 0;
_isDragActive = false;
}
private async Task OnFileSelectedAsync(InputFileChangeEventArgs args)
{
_dragDepth = 0;
_isDragActive = false;
if (Disabled)
return;
if (FileSelected.HasDelegate)
await FileSelected.InvokeAsync(args);
}
}

View File

@@ -0,0 +1,25 @@
namespace EnvelopeGenerator.Server.Client.Components.Sender.ReceiverSuggestionFilter;
public readonly record struct ReceiverFilterCriteria
{
public readonly string FullName;
public readonly string EmailAddress;
public ReceiverFilterCriteria(string? fullName = "", string? emailAddress = "")
{
FullName = Normalize(fullName);
EmailAddress = Normalize(emailAddress);
}
public readonly bool Matches(ReceiverFilterCriteria criteria)
=> FullName.Contains(criteria.FullName, StringComparison.Ordinal)
&& EmailAddress.Contains(criteria.EmailAddress, StringComparison.Ordinal);
private static string Normalize(string? text)
{
if (string.IsNullOrWhiteSpace(text))
return string.Empty;
return string.Concat(text.Where(c => !char.IsWhiteSpace(c))).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,26 @@
using EnvelopeGenerator.Application.Common.Dto.Receiver;
namespace EnvelopeGenerator.Server.Client.Components.Sender.ReceiverSuggestionFilter;
public class ReceiverSuggestionFilterHandler(IEnumerable<RecentReceiverSuggestionDto> suggestions)
{
private readonly IReadOnlyList<Entry> _entries = suggestions
.Where(x => !string.IsNullOrWhiteSpace(x.FullName) && !string.IsNullOrWhiteSpace(x.EmailAddress))
.Select(x => new Entry(new ReceiverFilterCriteria(x.FullName, x.EmailAddress), x))
.ToList();
private readonly record struct Entry(ReceiverFilterCriteria Criteria, RecentReceiverSuggestionDto Suggestion);
public IEnumerable<RecentReceiverSuggestionDto> Filter(ReceiverFilterCriteria criteria)
{
return _entries
.Where(x => x.Criteria.Matches(criteria))
.Select(x => x.Suggestion);
}
public IEnumerable<RecentReceiverSuggestionDto> Filter(string fullName, string emailAddress)
{
var criteria = new ReceiverFilterCriteria(fullName, emailAddress);
return Filter(criteria);
}
}

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

@@ -80,7 +80,7 @@
_selected = option;
_isOpen = false;
await LocalizationService.SetLanguageAsync(option.Code);
Navigation.NavigateTo(Navigation.Uri, forceLoad: false);
Navigation.NavigateTo(Navigation.Uri, forceLoad: true);
}
private sealed record LanguageOption(string Code, string FlagClass, string ShortName);

View File

@@ -22,7 +22,7 @@
</main>
<footer class="receiver-footer">
<div class="receiver-footer__left">
<span>&copy; SignFlow 2023-2024 <a href="https://digitaldata.works" target="_blank" rel="noopener">Digital Data GmbH</a></span>
<span>&copy; signFLOW 2023-2024 <a href="https://digitaldata.works" target="_blank" rel="noopener">Digital Data GmbH</a></span>
<span class="receiver-footer__sep">&#124;</span>
<a href="@GetPrivacyPolicyPath()" target="_blank" rel="noopener">@L("PrivacyPolicy", "Datenschutz")</a>
</div>

View File

@@ -26,7 +26,7 @@
</svg>
}
<div>
<h1 class="home-hero-header__title">SignFlow</h1>
<h1 class="home-hero-header__title">signFLOW</h1>
<p class="home-hero-header__subtitle">@L("Index_Subtitle", "Willkommen im eSign-Portal")</p>
</div>
</div>

View File

@@ -6,11 +6,13 @@
@using EnvelopeGenerator.Application.Envelopes.Commands
@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
@@ -18,10 +20,13 @@
@inject DocumentService DocumentService
@inject PdfRenderService PdfRenderService
@inject ReceiverService ReceiverService
@inject EnvelopeReceiverService EnvelopeReceiverService
@inject EnvelopeService EnvelopeService
@inject EnvelopeTypeService EnvelopeTypeService
@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" />
@@ -32,64 +37,52 @@
@* ── Action Bar ── *@
<div class="envelope-action-bar">
<div class="envelope-action-bar__inner"
style="flex-direction: row; align-items: center; padding: 0.35rem 1.5rem; gap: 0.75rem;">
<div class="envelope-action-bar__inner sender-editor-action-inner">
@* Left: Title + meta inputs + receivers *@
<div style="flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: stretch; gap: 0.5rem;">
<div class="sender-editor-main-column">
@* ── Title & Message inputs ── *@
<div style="display: flex; align-items: flex-start; gap: 0.5rem; flex-wrap: wrap;">
<div class="sender-editor-meta-row">
@* Titel *@
<div style="display: flex; flex-direction: column; gap: 0.2rem; flex: 1 1 180px; min-width: 140px;">
<label style="font-size: 0.65rem; font-weight: 600; color: #6b7280;
text-transform: uppercase; letter-spacing: 0.05em;">
@L("Title", "Titel") <span style="color:#dc2626;">*</span>
<div class="sender-editor-input-field sender-editor-input-field--title">
<label class="sender-editor-label">
@L("Title", "Titel") <span class="sender-editor-required-asterisk">*</span>
</label>
<input type="text"
value="@_envelopeTitle"
@oninput="e => _envelopeTitle = e.Value?.ToString() ?? string.Empty"
placeholder='@L("SenderEditor_Title_Placeholder", "z.B. Arbeitsvertrag 2025")'
maxlength="120"
style="font-size: 0.78rem; font-weight: 500; color: #1f2937;
border: 1px solid #d1d5db; border-radius: 5px;
padding: 0.3rem 0.55rem; outline: none; width: 100%;
background: #fff;
@(_titleTouched && string.IsNullOrWhiteSpace(_envelopeTitle) ? "border-color:#dc2626;" : "")" />
class="sender-editor-text-input @(_titleTouched && string.IsNullOrWhiteSpace(_envelopeTitle) ? "sender-editor-text-input--invalid" : string.Empty)" />
@if (_titleTouched && string.IsNullOrWhiteSpace(_envelopeTitle))
{
<span style="font-size:0.6rem;color:#dc2626;">@L("RequiredField", "Pflichtfeld")</span>
<span class="sender-editor-required-text">@L("RequiredField", "Pflichtfeld")</span>
}
</div>
@* Nachricht *@
<div style="display: flex; flex-direction: column; gap: 0.2rem; flex: 2 1 260px; min-width: 180px;">
<label style="font-size: 0.65rem; font-weight: 600; color: #6b7280;
text-transform: uppercase; letter-spacing: 0.05em;">
@L("Message", "Nachricht") <span style="color:#6b7280;font-weight:400;">(@L("Optional", "optional"))</span>
<div class="sender-editor-input-field sender-editor-input-field--message">
<label class="sender-editor-label">
@L("Message", "Nachricht") <span class="sender-editor-optional">(@L("Optional", "optional"))</span>
</label>
<input type="text"
value="@_envelopeMessage"
@oninput="e => _envelopeMessage = e.Value?.ToString() ?? string.Empty"
placeholder='@L("SenderEditor_Message_Placeholder", "Bitte unterzeichnen Sie das beigefügte Dokument.")'
maxlength="500"
style="font-size: 0.78rem; color: #1f2937;
border: 1px solid #d1d5db; border-radius: 5px;
padding: 0.3rem 0.55rem; outline: none; width: 100%;
background: #fff;" />
class="sender-editor-text-input" />
</div>
@* PDF filename badge *@
@if (_pdfLoaded)
{
<div style="display:flex;align-items:flex-end;gap:0.4rem;padding-bottom:0.05rem;flex-shrink:0;">
<span style="font-size: 0.7rem; color: #6b7280;">@_fileName</span>
<div class="sender-editor-file-meta">
<span class="sender-editor-file-name">@_fileName</span>
@if (_signatureFields.Count > 0)
{
<span style="display: inline-flex; align-items: center; padding: 0.125rem 0.4rem;
background: #ede9fe; border-radius: 0.25rem; color: #6d28d9;
font-weight: 500; font-size: 0.7rem; white-space: nowrap;">
<span class="sender-editor-signature-count">
@_signatureFields.Count @L("SenderEditor_SignatureField", "Signaturfeld")@(_signatureFields.Count != 1 ? "er" : "")
</span>
}
@@ -97,122 +90,145 @@
}
</div>
<details style="border:1px solid #dbe5ef;border-radius:8px;background:#f9fbfd;">
<summary style="cursor:pointer;list-style:none;display:flex;align-items:center;justify-content:space-between;padding:0.55rem 0.7rem;font-size:0.74rem;font-weight:700;color:#1f2937;text-transform:uppercase;letter-spacing:0.04em;">
<span>@L("SenderEditor_EnvelopeSettings", "Envelope-Einstellungen")</span>
<span style="font-size:0.66rem;color:#64748b;font-weight:600;">@L("SenderEditor_AdvancedOptional", "Erweitert")</span>
</summary>
<div class="sender-settings-shell">
<DxAccordion ExpandMode="AccordionExpandMode.SingleOrNone" CssClass="sender-settings-accordion">
<Items>
<DxAccordionItem Text='@L("SenderEditor_EnvelopeSettings", "Envelope-Einstellungen")' Expanded="false">
<ContentTemplate>
<div class="sender-settings-body">
<div class="sender-settings-grid">
<div class="sender-settings-field">
<label class="sender-settings-label">@L("EnvelopeType", "Umschlagtyp")</label>
<select value="@(_selectedEnvelopeTypeId?.ToString() ?? string.Empty)"
@onchange="OnEnvelopeTypeChanged"
class="sender-settings-select">
<option value="">@L("SenderEditor_SelectTemplate", "Vorlage auswählen")</option>
@foreach (var type in _envelopeTypes)
{
<option value="@type.Id">@type.Title</option>
}
</select>
</div>
<div style="padding:0 0.7rem 0.7rem;display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:0.45rem;">
<div style="display:flex;flex-direction:column;gap:0.2rem;">
<label style="font-size:0.65rem;font-weight:600;color:#64748b;">@L("EnvelopeType", "Umschlagtyp")</label>
<select value="@(_selectedEnvelopeTypeId?.ToString() ?? string.Empty)"
@onchange="OnEnvelopeTypeChanged"
style="font-size:0.78rem;border:1px solid #d1d5db;border-radius:5px;padding:0.3rem 0.45rem;background:#fff;">
<option value="">@L("SenderEditor_SelectTemplate", "Vorlage auswählen")</option>
@foreach (var type in _envelopeTypes)
{
<option value="@type.Id">@type.Title</option>
}
</select>
</div>
<div class="sender-settings-field">
<label class="sender-settings-label">@L("Language", "Sprache")</label>
<select @bind="_language" class="sender-settings-select">
<option value="de">Deutsch (de)</option>
<option value="en">English (en)</option>
<option value="de-DE">Deutsch (de-DE)</option>
<option value="en-US">English (en-US)</option>
</select>
</div>
<div style="display:flex;flex-direction:column;gap:0.2rem;">
<label style="font-size:0.65rem;font-weight:600;color:#64748b;">@L("Language", "Sprache")</label>
<select @bind="_language"
style="font-size:0.78rem;border:1px solid #d1d5db;border-radius:5px;padding:0.3rem 0.45rem;background:#fff;">
<option value="de">Deutsch (de)</option>
<option value="en">English (en)</option>
<option value="de-DE">Deutsch (de-DE)</option>
<option value="en-US">English (en-US)</option>
</select>
</div>
<div class="sender-settings-field">
<label class="sender-settings-label">@L("CertificationType", "Zertifizierung")</label>
<select @bind="_certificationType" class="sender-settings-select">
<option value="@CertificationType.AdvancedElectronicSignature">@L("AdvancedElectronicSignature", "Advanced Electronic Signature")</option>
<option value="@CertificationType.ElectronicSignature">@L("ElectronicSignature", "Electronic Signature")</option>
</select>
</div>
<div style="display:flex;flex-direction:column;gap:0.2rem;">
<label style="font-size:0.65rem;font-weight:600;color:#64748b;">@L("CertificationType", "Zertifizierung")</label>
<select @bind="_certificationType"
style="font-size:0.78rem;border:1px solid #d1d5db;border-radius:5px;padding:0.3rem 0.45rem;background:#fff;">
<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-settings-toggles">
<label class="sender-settings-toggle">
<input type="checkbox" checked="@_tfaEnabled" @onchange="OnTfaChanged" />
@L("TFAEnabled", "2FA aktiviert")
</label>
<label class="sender-settings-toggle">
<input type="checkbox" checked="@_useAccessCode" @onchange="OnUseAccessCodeChanged" disabled="@_tfaEnabled" />
@L("UseAccessCode", "Zugriffscode verwenden")
</label>
</div>
<div style="display:flex;flex-direction:column;gap:0.3rem;justify-content:flex-end;padding-bottom:0.25rem;">
<label style="display:flex;align-items:center;gap:0.45rem;font-size:0.74rem;color:#1f2937;">
<input type="checkbox" checked="@_tfaEnabled" @onchange="OnTfaChanged" />
@L("TFAEnabled", "2FA aktiviert")
</label>
<label style="display:flex;align-items:center;gap:0.45rem;font-size:0.74rem;color:#1f2937;">
<input type="checkbox" checked="@_useAccessCode" @onchange="OnUseAccessCodeChanged" disabled="@_tfaEnabled" />
@L("UseAccessCode", "Zugriffscode verwenden")
</label>
</div>
<div class="sender-settings-field">
<label class="sender-settings-label">@L("FinalEmailToCreator", "Finale E-Mail an Ersteller")</label>
<select @bind="_finalEmailToCreator" class="sender-settings-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 style="display:flex;flex-direction:column;gap:0.2rem;">
<label style="font-size:0.65rem;font-weight:600;color:#64748b;">@L("FinalEmailToCreator", "Finale E-Mail an Ersteller")</label>
<select @bind="_finalEmailToCreator"
style="font-size:0.78rem;border:1px solid #d1d5db;border-radius:5px;padding:0.3rem 0.45rem;background:#fff;">
<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-settings-field">
<label class="sender-settings-label">@L("FinalEmailToReceivers", "Finale E-Mail an Empfänger")</label>
<select @bind="_finalEmailToReceivers" class="sender-settings-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 style="display:flex;flex-direction:column;gap:0.2rem;">
<label style="font-size:0.65rem;font-weight:600;color:#64748b;">@L("FinalEmailToReceivers", "Finale E-Mail an Empfänger")</label>
<select @bind="_finalEmailToReceivers"
style="font-size:0.78rem;border:1px solid #d1d5db;border-radius:5px;padding:0.3rem 0.45rem;background:#fff;">
<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-settings-toggles sender-settings-toggles--single">
<label class="sender-settings-toggle">
<input type="checkbox" checked="@_sendReminderEmails" @onchange="OnSendReminderEmailsChanged" />
@L("SendReminderEmails", "Erinnerungs-E-Mails senden")
</label>
</div>
<div style="display:flex;flex-direction:column;gap:0.3rem;justify-content:flex-end;padding-bottom:0.25rem;">
<label style="display:flex;align-items:center;gap:0.45rem;font-size:0.74rem;color:#1f2937;">
<input type="checkbox" checked="@_sendReminderEmails" @onchange="OnSendReminderEmailsChanged" />
@L("SendReminderEmails", "Erinnerungs-E-Mails senden")
</label>
</div>
<div class="sender-settings-field">
<label class="sender-settings-label">@L("FirstReminderDays", "Erste Erinnerung (Tage)")</label>
<InputNumber @bind-Value="_firstReminderDays" class="form-control form-control-sm sender-settings-number" disabled="@(!_sendReminderEmails)" />
</div>
<div style="display:flex;flex-direction:column;gap:0.2rem;">
<label style="font-size:0.65rem;font-weight:600;color:#64748b;">@L("FirstReminderDays", "Erste Erinnerung (Tage)")</label>
<InputNumber @bind-Value="_firstReminderDays" class="form-control form-control-sm" disabled="@(!_sendReminderEmails)" />
</div>
<div class="sender-settings-field">
<label class="sender-settings-label">@L("ReminderIntervalDays", "Erinnerungsintervall (Tage)")</label>
<InputNumber @bind-Value="_reminderIntervalDays" class="form-control form-control-sm sender-settings-number" disabled="@(!_sendReminderEmails)" />
</div>
<div style="display:flex;flex-direction:column;gap:0.2rem;">
<label style="font-size:0.65rem;font-weight:600;color:#64748b;">@L("ReminderIntervalDays", "Erinnerungsintervall (Tage)")</label>
<InputNumber @bind-Value="_reminderIntervalDays" class="form-control form-control-sm" disabled="@(!_sendReminderEmails)" />
</div>
<div class="sender-settings-field">
<label class="sender-settings-label">@L("ExpiresWhenDays", "Läuft ab nach (Tage)")</label>
<InputNumber @bind-Value="_expiresWhenDays" class="form-control form-control-sm sender-settings-number" />
</div>
<div style="display:flex;flex-direction:column;gap:0.2rem;">
<label style="font-size:0.65rem;font-weight:600;color:#64748b;">@L("ExpiresWhenDays", "Läuft ab nach (Tage)")</label>
<InputNumber @bind-Value="_expiresWhenDays" class="form-control form-control-sm" />
</div>
<div style="display:flex;flex-direction:column;gap:0.2rem;">
<label style="font-size:0.65rem;font-weight:600;color:#64748b;">@L("ExpiresWarningWhenDays", "Ablaufwarnung (Tage)")</label>
<InputNumber @bind-Value="_expiresWarningWhenDays" class="form-control form-control-sm" />
</div>
</div>
</details>
<div class="sender-settings-field">
<label class="sender-settings-label">@L("ExpiresWarningWhenDays", "Ablaufwarnung (Tage)")</label>
<InputNumber @bind-Value="_expiresWarningWhenDays" class="form-control form-control-sm sender-settings-number" />
</div>
</div>
</div>
</ContentTemplate>
</DxAccordionItem>
</Items>
</DxAccordion>
</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)
@@ -221,41 +237,11 @@
@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="border-left: 3px solid @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
@(_pendingReceiverForPlacement?.Id == receiver.Id ? "pdf-toolbar__btn--signature-change-active" : "")"
@onclick="() => ActivatePlacementForReceiver(receiver)"
style="@(_pendingReceiverForPlacement?.Id == receiver.Id ? $"background:{receiver.Color};color:#fff;border-color:{receiver.Color};" : $"color:{receiver.Color};border-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>
@* Right: Buttons *@
<div style="display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0;">
<div class="sender-editor-actions">
<button class="pdf-toolbar__btn pdf-toolbar__btn--reset"
@onclick="Cancel"
@@ -264,14 +250,12 @@
</button>
@* PDF Upload *@
<label class="pdf-toolbar__btn"
<label class="pdf-toolbar__btn sender-editor-upload-btn @((!_isPageReady || _isPdfUploadInProgress) ? "sender-editor-upload-btn--disabled" : string.Empty)"
title='@L("UploadPdf", "PDF hochladen")'
style="cursor: @((_isPageReady && !_isPdfUploadInProgress) ? "pointer" : "not-allowed");
opacity:@((_isPageReady && !_isPdfUploadInProgress) ? "1" : "0.6");
display: inline-flex; align-items: center; gap: 0.3rem; padding: 0.3rem 0.75rem; font-size: 0.75rem; font-weight: 500;">
>
@if (_isPdfUploadInProgress)
{
<span class="spinner-border spinner-border-sm" role="status" style="width:0.8rem;height:0.8rem;border-width:2px;"></span>
<span class="spinner-border spinner-border-sm sender-editor-spinner" role="status"></span>
}
else
{
@@ -299,7 +283,7 @@
<InputFile OnChange="OnPdfFileSelectedAsync"
accept=".pdf"
disabled="@(!_isPageReady || _isPdfUploadInProgress)"
style="display: none;" />
class="sender-editor-hidden-input" />
</label>
@if (_pdfLoaded && _signatureFields.Count > 0)
@@ -320,8 +304,7 @@
title='@L("Save", "Speichern")'>
@if (_isSaving)
{
<span class="spinner-border spinner-border-sm me-1" role="status"
style="width:0.8rem;height:0.8rem;border-width:2px;"></span>
<span class="spinner-border spinner-border-sm me-1 sender-editor-spinner" role="status"></span>
}
else
{
@@ -336,12 +319,10 @@
<button class="pdf-toolbar__btn pdf-toolbar__btn--success"
@onclick="SendAsync"
disabled="@_isSaving"
title='@L("SendEnvelope", "Umschlag senden")'
style="background: linear-gradient(135deg,#059669,#047857); color:#fff; border-radius:6px; padding:0.3rem 0.75rem; font-size:0.75rem; font-weight:600; border:none;">
title='@L("SendEnvelope", "Umschlag senden")'>
@if (_isSaving)
{
<span class="spinner-border spinner-border-sm me-1" role="status"
style="width:0.8rem;height:0.8rem;border-width:2px;"></span>
<span class="spinner-border spinner-border-sm me-1 sender-editor-spinner" role="status"></span>
}
else
{
@@ -357,11 +338,9 @@
@* Placement mode hint bar *@
@if (_pendingReceiverForPlacement is not null)
{
<div style="background: @_pendingReceiverForPlacement.Color; color: white; font-size: 0.75rem; font-weight: 500;
padding: 0.3rem 1.5rem; text-align: center; letter-spacing: 0.01em;">
<div class="sender-editor-placement-hint" style="--placement-color:@_pendingReceiverForPlacement.Color;">
📌 @L("SenderEditor_ClickDocumentFor", "Klicken Sie auf die Stelle im Dokument für") <strong>@_pendingReceiverForPlacement.FullName</strong>.
&nbsp;<button @onclick="CancelPlacement"
style="background:none;border:none;color:white;cursor:pointer;font-size:0.75rem;text-decoration:underline;padding:0;">
&nbsp;<button @onclick="CancelPlacement" class="sender-editor-placement-cancel">
@L("Cancel", "Abbrechen")
</button>
</div>
@@ -369,30 +348,25 @@
</div>
@* ── Content ── *@
<div class="envelope-content" style="padding: 0; overflow: hidden;">
<div class="envelope-content sender-editor-content">
@if (!_pdfLoaded)
{
@* Empty state *@
<div class="d-flex justify-content-center align-items-center h-100">
<div class="text-center" style="max-width: 420px;">
<div style="width: 72px; height: 72px; background: rgba(255,255,255,0.15);
border-radius: 50%; display: flex; align-items: center;
justify-content: center; margin: 0 auto 1.25rem;">
<div class="text-center sender-editor-empty-state">
<div class="sender-editor-empty-state__icon-wrap">
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" fill="white" viewBox="0 0 16 16">
<path d="M14 4.5V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h5.5L14 4.5zm-3 0A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4.5h-2z" />
</svg>
</div>
<h5 class="text-white mb-2">@L("SenderEditor_NoDocumentLoaded", "Kein Dokument geladen")</h5>
<p class="text-white mb-4" style="opacity: 0.75; font-size: 0.85rem;">
<p class="text-white mb-4 sender-editor-empty-state__description">
@L("SenderEditor_UploadPdfToPlaceFields", "Laden Sie eine PDF-Datei hoch, um Signaturfelder zu platzieren.")
</p>
<label class="btn btn-light btn-sm"
style="cursor: @((_isPageReady && !_isPdfUploadInProgress) ? "pointer" : "not-allowed");
opacity:@((_isPageReady && !_isPdfUploadInProgress) ? "1" : "0.8");
font-weight: 500;">
<label class="btn btn-light btn-sm sender-editor-empty-state__upload @((!_isPageReady || _isPdfUploadInProgress) ? "sender-editor-empty-state__upload--disabled" : string.Empty)">
@if (_isPdfUploadInProgress)
{
<span class="spinner-border spinner-border-sm me-1" role="status" style="width:0.8rem;height:0.8rem;border-width:2px;"></span>
<span class="spinner-border spinner-border-sm me-1 sender-editor-spinner" role="status"></span>
}
else
{
@@ -420,7 +394,7 @@
<InputFile OnChange="OnPdfFileSelectedAsync"
accept=".pdf"
disabled="@(!_isPageReady || _isPdfUploadInProgress)"
style="display: none;" />
class="sender-editor-hidden-input" />
</label>
</div>
</div>
@@ -544,18 +518,17 @@
<BodyContentTemplate>
@if (_saveErrorMessage is null)
{
<div style="display:flex;align-items:flex-start;gap:1rem;padding:0.5rem 0;">
<div style="flex-shrink:0;width:40px;height:40px;background:#d1fae5;border-radius:50%;
display:flex;align-items:center;justify-content:center;">
<div class="sender-editor-save-result">
<div class="sender-editor-save-result__icon sender-editor-save-result__icon--success">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#065f46" viewBox="0 0 16 16">
<path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z" />
</svg>
</div>
<div>
<p style="margin:0 0 0.4rem;font-weight:600;color:#1f2937;font-size:0.95rem;">
<p class="sender-editor-save-result__title">
@_saveSuccessTitle
</p>
<p style="margin:0;color:#6b7280;font-size:0.85rem;line-height:1.5;">
<p class="sender-editor-save-result__message">
@_saveSuccessBody
</p>
</div>
@@ -563,19 +536,18 @@
}
else
{
<div style="display:flex;align-items:flex-start;gap:1rem;padding:0.5rem 0;">
<div style="flex-shrink:0;width:40px;height:40px;background:#fee2e2;border-radius:50%;
display:flex;align-items:center;justify-content:center;">
<div class="sender-editor-save-result">
<div class="sender-editor-save-result__icon sender-editor-save-result__icon--error">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#991b1b" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
<path d="M7.002 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995z" />
</svg>
</div>
<div>
<p style="margin:0 0 0.4rem;font-weight:600;color:#1f2937;font-size:0.95rem;">
<p class="sender-editor-save-result__title">
@L("SenderEditor_ErrorCreatingEnvelope", "Fehler beim Erstellen des Umschlags")
</p>
<p style="margin:0;color:#6b7280;font-size:0.85rem;line-height:1.5;word-break:break-word;">
<p class="sender-editor-save-result__message sender-editor-save-result__message--break">
@_saveErrorMessage
</p>
</div>
@@ -583,30 +555,25 @@
}
</BodyContentTemplate>
<FooterContentTemplate>
<div style="display:flex;gap:0.5rem;justify-content:flex-end;width:100%;padding:0.5rem 0;">
<div class="sender-editor-save-actions">
@if (_saveErrorMessage is null)
{
<button class="btn btn-outline-secondary"
@onclick="() => _savePopupVisible = false"
style="border-radius:6px;padding:0.5rem 1.25rem;font-weight:500;">
<button class="btn btn-outline-secondary sender-editor-save-actions__secondary"
@onclick="() => _savePopupVisible = false">
@L("Close", "Schließen")
</button>
@if (_saveNavigateToDashboard)
{
<button class="btn btn-primary"
@onclick="GoToDashboard"
style="background:linear-gradient(135deg,#059669,#047857);border:none;border-radius:6px;
padding:0.5rem 1.5rem;font-weight:600;color:#fff;">
<button class="btn btn-primary sender-editor-save-actions__primary" @onclick="GoToDashboard">
@L("SenderEditor_ToOverview", "Zur Übersicht")
</button>
}
}
else
{
<button class="btn btn-outline-secondary"
@onclick="() => _savePopupVisible = false"
style="border-radius:6px;padding:0.5rem 1.25rem;font-weight:500;">
<button class="btn btn-outline-secondary sender-editor-save-actions__secondary"
@onclick="() => _savePopupVisible = false">
@L("Close", "Schließen")
</button>
}
@@ -618,10 +585,11 @@
[SupplyParameterFromQuery(Name = "envelopeId")]
public int? EnvelopeId { get; set; }
// ── Constants ──
// Signature field size in PDF points (fixed): 1.77" × 1.96"
const double SigWidthPt = 1.77 * 72; // 127.44 pt
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";
@@ -636,7 +604,6 @@
int _pageCount;
List<PdfPageImageDto> _pagePreviews = [];
int? _loadedEnvelopeId;
int? _editingEnvelopeId;
bool _isLoadingEnvelopeForEdit;
List<SignatureFieldDraft> _signatureFields = [];
@@ -669,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;
@@ -691,8 +658,6 @@
static readonly System.ComponentModel.DataAnnotations.EmailAddressAttribute ReceiverEmailValidator = new();
bool IsEditMode => EnvelopeId.HasValue && EnvelopeId.Value > 0;
// ── Lifecycle ──
protected override async Task OnInitializedAsync()
@@ -707,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)
{
@@ -723,28 +682,23 @@
protected override async Task OnParametersSetAsync()
{
if (EnvelopeId.HasValue && EnvelopeId.Value > 0)
_editingEnvelopeId = EnvelopeId.Value;
if (IsEditMode && _loadedEnvelopeId != EnvelopeId)
if (!EnvelopeId.HasValue || EnvelopeId.Value <= 0)
{
await LoadEnvelopeForEditAsync(EnvelopeId!.Value);
NavigationManager.NavigateTo("/sender", replace: true);
return;
}
if (IsEditMode)
return;
if (_loadedEnvelopeId != EnvelopeId && !_isLoadingEnvelopeForEdit)
await LoadEnvelopeForEditAsync(EnvelopeId.Value);
}
protected override Task OnAfterRenderAsync(bool firstRender)
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
_isPageReady = true;
StateHasChanged();
await InvokeAsync(StateHasChanged);
}
return Task.CompletedTask;
}
async Task LoadEnvelopeForEditAsync(int envelopeId)
@@ -772,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;
@@ -788,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))
@@ -815,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);
})
@@ -854,7 +820,6 @@
_fileName = $"Envelope-{envelopeId}.pdf";
_pendingReceiverForPlacement = null;
_loadedEnvelopeId = envelopeId;
_editingEnvelopeId = envelopeId;
}
catch (Exception ex)
{
@@ -1054,6 +1019,7 @@
XPt: xPt,
YPt: yPt,
Page: page1Based,
ReceiverDraftId: _pendingReceiverForPlacement.Id,
ReceiverName: _pendingReceiverForPlacement.FullName,
Color: _pendingReceiverForPlacement.Color);
@@ -1137,12 +1103,17 @@
}
async Task SaveDraftAsync()
{
await SaveDraftCoreAsync(navigateToDashboard: true);
}
async Task<int?> SaveDraftCoreAsync(bool navigateToDashboard)
{
if (!ValidateSettings(out var settingsError))
{
_saveErrorMessage = settingsError;
_savePopupVisible = true;
return;
return null;
}
_isSaving = true;
@@ -1151,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)
@@ -1161,24 +1131,23 @@
EnvelopeId = result.Id;
_loadedEnvelopeId = result.Id;
_editingEnvelopeId = result.Id;
if (previousEnvelopeId != result.Id)
{
NavigationManager.NavigateTo($"/sender/editor?envelopeId={result.Id}", forceLoad: false);
}
if (navigateToDashboard)
NavigationManager.NavigateTo("/sender", forceLoad: false);
NavigationManager.NavigateTo("/sender", forceLoad: false);
return result.Id;
}
catch (Exception ex)
{
Logger.LogError(ex, "[SenderEditor] Failed to save draft");
_saveErrorMessage = ex.Message;
_savePopupVisible = true;
return null;
}
finally
{
_isSaving = false;
await InvokeAsync(StateHasChanged);
}
}
@@ -1221,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)
{
@@ -1236,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.");
@@ -1281,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)
@@ -1296,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,
@@ -1375,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()
{
@@ -1558,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 =
@@ -1586,7 +1550,11 @@
string L(string key, string fallback)
{
var value = UiLocalizer[key];
return value.ResourceNotFound ? fallback : value.Value;
var modelValue = ModelLocalizer[key];
if (!modelValue.ResourceNotFound)
return modelValue.Value;
var uiValue = UiLocalizer[key];
return uiValue.ResourceNotFound ? fallback : uiValue.Value;
}
}

View File

@@ -7,17 +7,22 @@
@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
@using Microsoft.Extensions.Localization
@inject EnvelopeGenerator.Server.Client.Services.EnvelopeService EnvelopeService
@inject EnvelopeGenerator.Server.Client.Services.DocumentService DocumentService
@inject EnvelopeGenerator.Server.Client.Services.PdfRenderService PdfRenderService
@inject EnvelopeGenerator.Server.Client.Services.EnvelopeTypeService EnvelopeTypeService
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
@inject EnvelopeGenerator.Server.Client.Services.ThemeService ThemeService
@inject EnvelopeGenerator.Server.Client.Services.UserPreferencesService UserPreferencesService
@inject EnvelopeGenerator.Server.Client.Services.CustomImagesService CustomImagesService
@inject ILogger<EnvelopeSenderPage> Logger
@inject EnvelopeGenerator.Server.Client.Services.PdfSignatureService PdfSignatureService
@inject EnvelopeGenerator.Server.Client.Services.EnvelopeReceiverService EnvelopeReceiverService
@inject ILogger<SenderPage> Logger
@inject NavigationManager Navigation
@inject IJSRuntime JSRuntime
@inject AppVersionService AppVersion
@@ -311,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)
{
@@ -345,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
{
@@ -414,9 +433,14 @@
</div>
<CreateEnvelopeWizardPopup Visible="@_createWizardVisible"
VisibleChanged="OnCreateWizardVisibleChangedAsync"
EnvelopeTypes="@_envelopeTypes"
StartEditorRequested="StartEditorFromWizardAsync" />
@* ── PDF Vorschau Popup ────────────────────────────────────────────────── *@
<DxPopup @bind-Visible="@_previewVisible"
HeaderText="@($"{L("Preview", "Vorschau")} — {_selectedEnvelope?.Title} (#{_selectedEnvelope?.Id})")"
HeaderText="@($"{(_activeTab == "completed" && _selectedEnvelope?.Status == EnvelopeStatus.EnvelopeCompletelySigned ? L("SignedPdf", "Signiertes PDF") : L("Preview", "Vorschau"))} — {_selectedEnvelope?.Title} (#{_selectedEnvelope?.Id})")"
Width="80vw"
MaxWidth="80vw"
Height="90vh"
@@ -535,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;
@@ -567,6 +592,9 @@
private string _deleteReason = string.Empty;
private string? _deleteErrorMessage;
private bool _createWizardVisible = false;
private IReadOnlyList<EnvelopeTypeDto> _envelopeTypes = [];
// Stable keys used to store/restore each tab's layout in UserPreferences.GridLayouts
private const string GridKeyActive = "sender.active-envelopes";
private const string GridKeyCompleted = "sender.completed-envelopes";
@@ -591,11 +619,23 @@
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;
_companyLogo = logos.Company;
try
{
_envelopeTypes = (await EnvelopeTypeService.GetAsync()).ToList();
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to load envelope types for create wizard.");
_envelopeTypes = [];
}
await LoadEnvelopesAsync();
}
@@ -607,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).
@@ -693,7 +733,55 @@
void CreateEnvelope()
{
Navigation.NavigateTo("/sender/editor");
_createWizardVisible = true;
}
Task OnCreateWizardVisibleChangedAsync(bool visible)
{
_createWizardVisible = visible;
return Task.CompletedTask;
}
async Task StartEditorFromWizardAsync(CreateEnvelopeWizardResult result)
{
var docBase64 = result.DocumentBytes is { Length: > 0 }
? Convert.ToBase64String(result.DocumentBytes)
: null;
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()
@@ -932,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
@@ -970,12 +1087,14 @@
private async Task OpenPreviewAsync(EnvelopeDto envelope)
{
var showSigned = _activeTab == "completed" && envelope.Status == EnvelopeStatus.EnvelopeCompletelySigned;
// Unmount the viewer first — let the previous instance fully dispose
_previewViewerMounted = false;
_previewPdfBytes = null;
_previewPageCount = 0;
_previewPagePreviews = [];
_previewFileName = $"envelope-{envelope.Id}.pdf";
_previewFileName = showSigned ? $"envelope-{envelope.Id}-signed.pdf" : $"envelope-{envelope.Id}.pdf";
_previewErrorMessage = null;
_previewLoading = true;
_previewVisible = true;
@@ -983,7 +1102,10 @@
try
{
_previewPdfBytes = await DocumentService.GetSenderDocumentBytesByEnvelopeIdAsync(envelope.Id);
_previewPdfBytes = showSigned
? await PdfSignatureService.GetSignedPdfAsync(envelope.Id)
: await DocumentService.GetSenderDocumentBytesByEnvelopeIdAsync(envelope.Id);
if (_previewPdfBytes is null || _previewPdfBytes.Length == 0)
{
_previewErrorMessage = L("DocumentCouldNotBeLoaded", "Dokument konnte nicht geladen werden");

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>();
@@ -45,6 +50,7 @@ builder.Services.AddScoped<EnvelopeTypeService>();
builder.Services.AddScoped<LocalizationService>();
builder.Services.AddScoped<ReceiverService>();
builder.Services.AddScoped<PdfRenderService>();
builder.Services.AddScoped<CreateEnvelopeBootstrapStorageService>();
// UI Preferences & Theme
builder.Services.AddScoped<UserPreferencesService>();
@@ -52,6 +58,7 @@ builder.Services.AddScoped<ThemeService>();
// Logo configuration (loaded once after login, in-memory cached)
builder.Services.AddScoped<CustomImagesService>();
builder.Services.AddScoped<PdfSignatureService>();
// DevExpress WASM
builder.Services.AddDevExpressWebAssemblyBlazorPdfViewer();

View File

@@ -0,0 +1,65 @@
using System.Text.Json;
using EnvelopeGenerator.Domain.Constants;
using Microsoft.JSInterop;
namespace EnvelopeGenerator.Server.Client.Services;
public sealed class CreateEnvelopeBootstrapStorageService(IJSRuntime jsRuntime)
{
private const string StorageKeyPrefix = "sender.createEnvelope.bootstrap.";
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public async Task<string> StoreAsync(CreateEnvelopeBootstrapPayload payload)
{
var token = Guid.NewGuid().ToString("N");
var key = BuildStorageKey(token);
var json = JsonSerializer.Serialize(payload, JsonOptions);
await jsRuntime.InvokeVoidAsync("sessionStorage.setItem", key, json);
return token;
}
public async Task<CreateEnvelopeBootstrapPayload?> ConsumeAsync(string? token)
{
if (string.IsNullOrWhiteSpace(token))
return null;
var key = BuildStorageKey(token);
var raw = await jsRuntime.InvokeAsync<string?>("sessionStorage.getItem", key);
await jsRuntime.InvokeVoidAsync("sessionStorage.removeItem", key);
if (string.IsNullOrWhiteSpace(raw))
return null;
try
{
return JsonSerializer.Deserialize<CreateEnvelopeBootstrapPayload>(raw, JsonOptions);
}
catch
{
return null;
}
}
private static string BuildStorageKey(string token) => $"{StorageKeyPrefix}{token}";
}
public sealed record CreateEnvelopeBootstrapPayload(
string Title,
string Message,
int? EnvelopeTypeId,
string Language,
bool UseAccessCode,
bool TFAEnabled,
CertificationType CertificationType,
int FinalEmailToCreator,
int FinalEmailToReceivers,
bool SendReminderEmails,
int FirstReminderDays,
int ReminderIntervalDays,
int ExpiresWhenDays,
int ExpiresWarningWhenDays,
IReadOnlyList<CreateEnvelopeBootstrapReceiver> Receivers,
byte[]? DocumentBytes,
string? DocumentFileName);
public sealed record CreateEnvelopeBootstrapReceiver(string FullName, string EmailAddress);

View File

@@ -2,8 +2,9 @@ using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using EnvelopeGenerator.Application.EnvelopeReceivers.Commands;
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

@@ -0,0 +1,24 @@
using System.Net;
using System.Net.Http.Json;
namespace EnvelopeGenerator.Server.Client.Services;
public class PdfSignatureService(IHttpClientFactory httpClientFactory)
{
/// <summary>
/// Calls POST /api/PdfSignature with the envelope id and returns the fully signed PDF bytes.
/// </summary>
public async Task<byte[]> GetSignedPdfAsync(int envelopeId, CancellationToken cancel = default)
{
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
var response = await http.PostAsJsonAsync("/api/PdfSignature", new { Id = envelopeId }, cancel);
if (!response.IsSuccessStatusCode)
throw new HttpRequestException(
$"Failed to load signed PDF. Status: {(int)response.StatusCode} ({response.ReasonPhrase ?? "Unknown"})",
null,
response.StatusCode);
return await response.Content.ReadAsByteArrayAsync(cancel);
}
}

View File

@@ -1,5 +1,6 @@
using System.Net.Http.Json;
using System.Text.Json;
using EnvelopeGenerator.Application.Common.Dto.Receiver;
namespace EnvelopeGenerator.Server.Client.Services;
@@ -26,4 +27,21 @@ public class ReceiverService(IHttpClientFactory httpClientFactory)
var emails = await response.Content.ReadFromJsonAsync<List<string>>(JsonOptions, cancel);
return emails ?? [];
}
/// <summary>
/// Fetches recent receiver suggestions for the authenticated sender.
/// Calls GET /api/EnvelopeReceiver/recent-suggestions?take={take}.
/// </summary>
public async Task<IReadOnlyList<RecentReceiverSuggestionDto>> GetRecentReceiverSuggestionsAsync(int take = 20, CancellationToken cancel = default)
{
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
var url = $"/api/EnvelopeReceiver/recent-suggestions?take={take}";
var response = await http.GetAsync(url, cancel);
if (!response.IsSuccessStatusCode)
return [];
var suggestions = await response.Content.ReadFromJsonAsync<List<RecentReceiverSuggestionDto>>(JsonOptions, cancel);
return suggestions ?? [];
}
}

Some files were not shown because too many files have changed in this diff Show More