Compare commits

..

81 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
111 changed files with 5097 additions and 923 deletions

1
.gitignore vendored
View File

@@ -370,3 +370,4 @@ FodyWeavers.xsd
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/publish-output /EnvelopeGenerator.Server/EnvelopeGenerator.Server/publish-output
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/tekh_softHSM_test.md /EnvelopeGenerator.Server/EnvelopeGenerator.Server/tekh_softHSM_test.md
/ignore/EnvelopeGenerator.Form /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 - 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. **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 ### Deployment Docs
@@ -268,6 +278,7 @@ Manual testing workflow:
## Technical Debt TODOs ## 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. - 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). - 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. - Keep `EnvelopeSenderPage.razor` focused on UI composition/state orchestration; avoid embedding heavy domain logic directly in the Razor component.
@@ -293,11 +304,51 @@ Manual testing workflow:
**Key Tables:** **Key Tables:**
- `TBSIG_ENVELOPE` - Envelope metadata - `TBSIG_ENVELOPE` - Envelope metadata
- `TBSIG_ENVELOPE_RECEIVER` - Receiver assignments - `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_RECEIVER` - Receiver registry
- `TBSIG_DOCUMENT` - PDF binary data - `TBSIG_ENVELOPE_DOCUMENT` - PDF binary data
- `TBSIG_ENVELOPE_HISTORY` - Audit trail - `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 ## DevExpress
**License:** Commercial (v25.2.3) **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; using EnvelopeGenerator.Domain.Interfaces;
namespace EnvelopeGenerator.Application.Common.Dto; namespace EnvelopeGenerator.Application.Common.Dto;
// TODO: Add the “Reason” property and integrate with UI and the digital signature process.
/// <summary> /// <summary>
/// Data Transfer Object representing a positioned element assigned to a document receiver. /// Data Transfer Object representing a positioned element assigned to a document receiver.
/// </summary> /// </summary>
public class DocReceiverElementDto : IDocReceiverElement public class DocReceiverElementDto : IDocReceiverElement, ISignatureInkPayload
{ {
/// <summary> /// <summary>
/// Gets or sets the unique identifier of the element. /// Gets or sets the unique identifier of the element.
@@ -18,11 +20,21 @@ public class DocReceiverElementDto : IDocReceiverElement
/// </summary> /// </summary>
public int DocumentId { get; set; } public int DocumentId { get; set; }
/// <summary>
///
/// </summary>
public virtual DocumentDto? Document { get; set; }
/// <summary> /// <summary>
/// Gets or sets the identifier of the receiver. /// Gets or sets the identifier of the receiver.
/// </summary> /// </summary>
public int ReceiverId { get; set; } public int ReceiverId { get; set; }
/// <summary>
///
/// </summary>
public virtual ReceiverDto? Receiver { get; set; }
/// <summary> /// <summary>
/// Gets or sets the type of the element. /// Gets or sets the type of the element.
/// </summary> /// </summary>
@@ -122,4 +134,41 @@ public class DocReceiverElementDto : IDocReceiverElement
/// ///
/// </summary> /// </summary>
public byte[]? Ink { get; set; } 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> /// <summary>
/// Gets or sets the binary data of the document, if available. /// Gets or sets the binary data of the document, if available.
/// </summary> /// </summary>
public byte[]? ByteData { get; init; } public byte[]? ByteData { get; set; }
} }

View File

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

View File

@@ -1,4 +1,6 @@
namespace EnvelopeGenerator.Application.Common.Dto; using EnvelopeGenerator.Domain.Constants;
namespace EnvelopeGenerator.Application.Common.Dto;
/// <summary> /// <summary>
/// Data Transfer Object representing a type of envelope with its configuration settings. /// Data Transfer Object representing a type of envelope with its configuration settings.
@@ -28,7 +30,7 @@ public class EnvelopeTypeDto
/// <summary> /// <summary>
/// Gets or sets the certification type identifier. /// Gets or sets the certification type identifier.
/// </summary> /// </summary>
public int? CertificationType { get; set; } public CertificationType? CertificationType { get; set; }
/// <summary> /// <summary>
/// Gets or sets a value indicating whether an access code is required. /// Gets or sets a value indicating whether an access code is required.
@@ -78,5 +80,5 @@ public class EnvelopeTypeDto
/// <summary> /// <summary>
/// Gets or sets the contract type associated with the envelope type. /// Gets or sets the contract type associated with the envelope type.
/// </summary> /// </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,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.Application.Common.Dto;
using EnvelopeGenerator.Domain.Interfaces;
namespace EnvelopeGenerator.Application.Common.Interfaces.Doc; namespace EnvelopeGenerator.Application.Common.Interfaces.Doc;
/// <summary>
///
/// </summary>
public interface IPdfPageImageRenderer public interface IPdfPageImageRenderer
{ {
/// <summary>
///
/// </summary>
/// <param name="pdfBytes"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
Task<IReadOnlyList<PdfPageImageDto>> RenderPagesAsImagesAsync(byte[] pdfBytes, CancellationToken cancellationToken = default); 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 DigitalData.EmailProfilerDispatcher.Abstraction.Entities;
using EnvelopeGenerator.Application.Common.Configurations; using EnvelopeGenerator.Application.Common.Configurations;
using EnvelopeGenerator.Application.Common.Extensions; using EnvelopeGenerator.Application.Common.Extensions;
using EnvelopeGenerator.Application.Common.Notifications.EnvelopeDeleted;
using EnvelopeGenerator.Domain.Constants; using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Domain.Entities; using EnvelopeGenerator.Domain.Entities;
using MediatR; using MediatR;
@@ -96,6 +97,10 @@ public abstract class SendMailHandler<TNotification> : INotificationHandler<TNot
/// <exception cref="NotImplementedException"></exception> /// <exception cref="NotImplementedException"></exception>
public virtual async Task Handle(TNotification notification, CancellationToken cancel) 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 placeHolders = CreatePlaceHolders(notification);
var temp = await TempRepo var temp = await TempRepo

View File

@@ -49,8 +49,10 @@ public static class DependencyInjection
services.Configure<DispatcherParams>(config.GetSection(nameof(DispatcherParams))); services.Configure<DispatcherParams>(config.GetSection(nameof(DispatcherParams)));
services.Configure<MailParams>(config.GetSection(nameof(MailParams))); services.Configure<MailParams>(config.GetSection(nameof(MailParams)));
services.Configure<SignatureFieldParams>(config.GetSection(nameof(SignatureFieldParams)));
services.Configure<AuthenticatorParams>(config.GetSection(nameof(AuthenticatorParams))); services.Configure<AuthenticatorParams>(config.GetSection(nameof(AuthenticatorParams)));
services.Configure<TotpSmsParams>(config.GetSection(nameof(TotpSmsParams))); services.Configure<TotpSmsParams>(config.GetSection(nameof(TotpSmsParams)));
services.Configure<ESignParams>(config.GetSection(nameof(ESignParams)));
services.AddHttpClientService<GtxMessagingParams>(config.GetSection(nameof(GtxMessagingParams))); services.AddHttpClientService<GtxMessagingParams>(config.GetSection(nameof(GtxMessagingParams)));
services.TryAddScoped<ISmsSender, GTXSmsSender>(); // Changed: Singleton → Scoped services.TryAddScoped<ISmsSender, GTXSmsSender>(); // Changed: Singleton → Scoped

View File

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

View File

@@ -1,5 +1,3 @@
using AutoMapper;
using AutoMapper.QueryableExtensions;
using DigitalData.Core.Abstraction.Application.Repository; using DigitalData.Core.Abstraction.Application.Repository;
using EnvelopeGenerator.Application.Common.Dto; using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Domain.Entities; using EnvelopeGenerator.Domain.Entities;
@@ -21,12 +19,10 @@ public record ReadDocumentDetailsQuery(int EnvelopeId, int? UserId = null) : IRe
public class ReadDocumentDetailsQueryHandler : IRequestHandler<ReadDocumentDetailsQuery, DocumentDetailsDto?> public class ReadDocumentDetailsQueryHandler : IRequestHandler<ReadDocumentDetailsQuery, DocumentDetailsDto?>
{ {
private readonly IRepository<Document> _repo; private readonly IRepository<Document> _repo;
private readonly IMapper _mapper;
public ReadDocumentDetailsQueryHandler(IRepository<Document> repo, IMapper mapper) public ReadDocumentDetailsQueryHandler(IRepository<Document> repo)
{ {
_repo = repo; _repo = repo;
_mapper = mapper;
} }
public async Task<DocumentDetailsDto?> Handle(ReadDocumentDetailsQuery query, CancellationToken cancel) public async Task<DocumentDetailsDto?> Handle(ReadDocumentDetailsQuery query, CancellationToken cancel)
@@ -36,9 +32,71 @@ public class ReadDocumentDetailsQueryHandler : IRequestHandler<ReadDocumentDetai
if (query.UserId is int userId) if (query.UserId is int userId)
docQuery = docQuery.Where(doc => doc.Envelope.UserId == 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) .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); .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

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

View File

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

@@ -21,7 +21,24 @@ namespace EnvelopeGenerator.Application.EnvelopeReceivers.Queries;
/// </remarks> /// </remarks>
public record ReadEnvelopeReceiverSecretQuery public record ReadEnvelopeReceiverSecretQuery
: EnvelopeReceiverQueryBase<ReadEnvelopeQuery, ReadReceiverQuery>, : 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> /// <summary>
/// Extension methods for dispatching <see cref="ReadEnvelopeReceiverSecretQuery"/> via <see cref="IMediator"/>. /// Extension methods for dispatching <see cref="ReadEnvelopeReceiverSecretQuery"/> via <see cref="IMediator"/>.

View File

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

View File

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

View File

@@ -1,68 +1,66 @@
using AutoMapper; using AutoMapper;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using DigitalData.Core.Abstraction.Application.Repository; using DigitalData.Core.Abstraction.Application.Repository;
using DigitalData.EmailProfilerDispatcher.Abstraction.Entities;
using DigitalData.Core.Exceptions; using DigitalData.Core.Exceptions;
using EnvelopeGenerator.Domain.Entities; using DigitalData.EmailProfilerDispatcher.Abstraction.Entities;
using EnvelopeGenerator.Application.Common.Dto; using DigitalData.UserManager.Domain.Entities;
using EnvelopeGenerator.Application.Common.Configurations; using EnvelopeGenerator.Application.Common.Configurations;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Extensions; using EnvelopeGenerator.Application.Common.Extensions;
using EnvelopeGenerator.Application.Common.Interfaces;
using EnvelopeGenerator.Application.Common.Interfaces.Services; using EnvelopeGenerator.Application.Common.Interfaces.Services;
using EnvelopeGenerator.Application.Common.Interfaces.Services.Notification;
using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor; using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor;
using EnvelopeGenerator.Application.Histories.Commands; using EnvelopeGenerator.Application.Histories.Commands;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Domain; using EnvelopeGenerator.Domain;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Domain.Entities;
using EnvelopeGenerator.Domain.Interfaces;
using MediatR;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace EnvelopeGenerator.Application.Envelopes.Commands; namespace EnvelopeGenerator.Application.Envelopes.Commands;
/// <summary> /// <summary>
/// ///
/// </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 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;
}
/// <summary> /// <summary>
/// ///
@@ -230,7 +228,7 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
.ThenInclude(er => er.Receiver) .ThenInclude(er => er.Receiver)
.FirstAsync(e => e.Id == envelope.Id, cancel); .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) 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), Language: ResolveLanguage(request.Language, selectedType?.Language, existing?.Language),
UseAccessCode: useAccessCode, UseAccessCode: useAccessCode,
CertificationType: request.CertificationType CertificationType: request.CertificationType
?? (int?)selectedType?.CertificationType ?? selectedType?.CertificationType
?? existing?.CertificationType ?? existing?.CertificationType
?? (int)CertificationType.AdvancedElectronicSignature, ?? CertificationType.AdvancedElectronicSignature,
FinalEmailToCreator: request.FinalEmailToCreator FinalEmailToCreator: request.FinalEmailToCreator
?? (int?)selectedType?.FinalEmailToCreator ?? (int?)selectedType?.FinalEmailToCreator
?? existing?.FinalEmailToCreator ?? existing?.FinalEmailToCreator
@@ -299,7 +297,7 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
ExpiresWarningWhen: expiresWarningWhenDays > 0 ? DateTime.Now.AddDays(expiresWarningWhenDays) : null, ExpiresWarningWhen: expiresWarningWhenDays > 0 ? DateTime.Now.AddDays(expiresWarningWhenDays) : null,
ContractType: existing?.ContractType ContractType: existing?.ContractType
?? selectedType?.ContractType ?? selectedType?.ContractType
?? (int)ContractType.Contract); ?? ContractType.Contract);
} }
private static string ResolveLanguage(string? preferred, string? fallback1, string? fallback2) private static string ResolveLanguage(string? preferred, string? fallback1, string? fallback2)
@@ -320,7 +318,7 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
int? EnvelopeTypeId, int? EnvelopeTypeId,
string Language, string Language,
bool UseAccessCode, bool UseAccessCode,
int CertificationType, CertificationType CertificationType,
int FinalEmailToCreator, int FinalEmailToCreator,
int FinalEmailToReceivers, int FinalEmailToReceivers,
bool SendReminderEmails, bool SendReminderEmails,
@@ -330,7 +328,7 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
int ExpiresWarningWhenDays, int ExpiresWarningWhenDays,
DateTime? ExpiresWhen, DateTime? ExpiresWhen,
DateTime? ExpiresWarningWhen, DateTime? ExpiresWarningWhen,
int ContractType); ContractType ContractType);
private async Task<string> ResolveSenderReferenceAsync(Envelope envelope, CancellationToken cancel) private async Task<string> ResolveSenderReferenceAsync(Envelope envelope, CancellationToken cancel)
{ {
@@ -444,8 +442,8 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
ElementType = 1, ElementType = 1,
X = field.X, X = field.X,
Y = field.Y, Y = field.Y,
Width = 1.77, Width = SignatureFieldParams.Width,
Height = 1.96, Height = SignatureFieldParams.Height,
Page = field.Page, Page = field.Page,
Required = true, Required = true,
ReadOnly = false, ReadOnly = false,
@@ -529,15 +527,15 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
if (!hasDocument) if (!hasDocument)
throw new BadRequestException("Cannot send envelope without a document."); throw new BadRequestException("Cannot send envelope without a document.");
var links = await EnvelopeReceiverRepository.Query var envRcvs = await EnvelopeReceiverRepository.Query
.Include(er => er.Receiver) .Include(er => er.Receiver)
.Where(er => er.EnvelopeId == envelope.Id) .Where(er => er.EnvelopeId == envelope.Id)
.ToListAsync(cancel); .ToListAsync(cancel);
if (links.Count == 0) if (envRcvs.Count == 0)
throw new BadRequestException("Cannot send envelope without receivers."); 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 var documentIds = await DocumentRepository.Query
.Where(d => d.EnvelopeId == envelope.Id) .Where(d => d.EnvelopeId == envelope.Id)
.Select(d => d.Id) .Select(d => d.Id)
@@ -550,7 +548,7 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
.Select(g => new { ReceiverId = g.Key, Count = g.Count() }) .Select(g => new { ReceiverId = g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.ReceiverId, x => x.Count, cancel); .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) .Where(link => !receiverFieldCounts.TryGetValue(link.ReceiverId, out var count) || count <= 0)
.Select(link => link.Name) .Select(link => link.Name)
.ToList(); .ToList();
@@ -574,70 +572,39 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
Status = EnvelopeStatus.EnvelopeQueued Status = EnvelopeStatus.EnvelopeQueued
}, cancel); }, cancel);
await QueueInvitationEmailsAsync(envelope, links, userReference, cancel); await SendInvitationEmailsAsync(envelope, envRcvs, cancel);
envelope.Status = EnvelopeStatus.EnvelopeQueued; envelope.Status = EnvelopeStatus.EnvelopeQueued;
} }
private async Task QueueInvitationEmailsAsync( private async Task SendInvitationEmailsAsync(
Envelope envelope, Envelope envelope,
IReadOnlyCollection<EnvelopeReceiver> links, IReadOnlyCollection<EnvelopeReceiver> envRcvs,
string senderReference,
CancellationToken cancel) CancellationToken cancel)
{ {
var template = await EmailTemplateRepository.Query foreach (var er in envRcvs)
.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)
{ {
var receiver = link.Receiver; var advancedSignature = envelope.CertificationType == CertificationType.AdvancedElectronicSignature;
if (receiver is null || string.IsNullOrWhiteSpace(receiver.EmailAddress) || string.IsNullOrWhiteSpace(receiver.Signature)) var origin = new Uri(ReqCntxt.Origin ?? throw new InvalidOperationException($"The request origin is missing in {nameof(CreateEnvelopeCommandHandler)}."));
continue; try
var envelopeKey = (envelope.Uuid, receiver.Signature).ToEnvelopeKey();
var documentLink = $"{signatureHost.TrimEnd('/')}/envelope/login/{Uri.EscapeDataString(envelopeKey)}";
var placeholders = new Dictionary<string, string>(MailParams.Placeholders)
{ {
["[NAME_RECEIVER]"] = link.Name ?? string.Empty, await EnvNtfService.Notify(er.Receiver.EmailAddress, new DocumentReceivedNotification
["[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
{ {
EmailAddress = receiver.EmailAddress, AdvancedSignature = advancedSignature,
EmailSubj = subject, DocumentTitle = envelope.Title ?? string.Empty,
EmailBody = body, NameReceiver = er.Name ?? string.Empty,
AddedWhen = DateTime.Now, EnvelopeUuid = envelope.Uuid,
AddedWho = DispatcherParams.AddedWho, IsReadAndConfirm = envelope.IsReadAndConfirm(),
SendingProfile = DispatcherParams.SendingProfile, Message = envelope.Message ?? string.Empty,
ReminderTypeId = DispatcherParams.ReminderTypeId, NameSender = $"{envelope.User.Prename} {envelope.User.Name}",
EmailAttmt1 = DispatcherParams.EmailAttmt1, Origin = origin,
WfId = (int)EnvelopeStatus.MessageInvitationSent, ReceiverSignature = er.Receiver.Signature,
ReferenceString = receiver.EmailAddress, }, cancel);
ReferenceId = link.ReceiverId }
}; catch(Exception ex)
{
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 AutoMapper;
using EnvelopeGenerator.Application.EnvelopeReceivers.Commands; using EnvelopeGenerator.Application.EnvelopeReceivers.Commands.CreateEnvelopeReceiver;
using EnvelopeGenerator.Application.Envelopes.Commands; using EnvelopeGenerator.Application.Envelopes.Commands;
using EnvelopeGenerator.Domain.Entities; using EnvelopeGenerator.Domain.Entities;

View File

@@ -2,6 +2,9 @@
using EnvelopeGenerator.Domain.Constants; using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Application.Common.Query; using EnvelopeGenerator.Application.Common.Query;
using EnvelopeGenerator.Application.Common.Dto; 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 AutoMapper;
using DigitalData.Core.Abstraction.Application.Repository; using DigitalData.Core.Abstraction.Application.Repository;
using EnvelopeGenerator.Domain.Entities; using EnvelopeGenerator.Domain.Entities;
@@ -14,6 +17,11 @@ namespace EnvelopeGenerator.Application.Envelopes.Queries;
/// </summary> /// </summary>
public record ReadEnvelopeQuery : EnvelopeQueryBase, IRequest<IEnumerable<EnvelopeDto>> public record ReadEnvelopeQuery : EnvelopeQueryBase, IRequest<IEnumerable<EnvelopeDto>>
{ {
/// <summary>
///
/// </summary>
public bool IncludeDocAndDocRes { get; init; } = true;
/// <summary> /// <summary>
/// ///
/// </summary> /// </summary>
@@ -148,6 +156,86 @@ public class ReadEnvelopeQueryHandler : IRequestHandler<ReadEnvelopeQuery, IEnum
if (request is { OnlyCompleted: true }) if (request is { OnlyCompleted: true })
query = query.Where(e => Status.Completed.Contains(e.Status)); 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 var envelopes = await query
.Include(e => e.Type) .Include(e => e.Type)
.Include(e => e.EnvelopeReceivers).ThenInclude(er => er.Receiver) .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

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

View File

@@ -67,7 +67,7 @@ namespace EnvelopeGenerator.Domain.Constants
{ {
public static bool IsActive(this EnvelopeStatus status) 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) public static bool IsCompleted(this EnvelopeStatus status)

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@
<PropertyGroup> <PropertyGroup>
<TargetFrameworks>net462;net7.0;net8.0;net9.0</TargetFrameworks> <TargetFrameworks>net462;net7.0;net8.0;net9.0</TargetFrameworks>
<LangVersion>latest</LangVersion>
<AssemblyVersion>1.4.0</AssemblyVersion> <AssemblyVersion>1.4.0</AssemblyVersion>
<FileVersion>1.4.0</FileVersion> <FileVersion>1.4.0</FileVersion>
</PropertyGroup> </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 public interface IEnvelope
{ {
int? EnvelopeTypeId { get; set; } int? EnvelopeTypeId { get; set; }
byte[]
#if nullable
?
#endif
DocResult { get; }
} }
public static class EnvelopeExtensions public static class EnvelopeExtensions
@@ -16,5 +22,10 @@
{ {
return envelope.EnvelopeTypeId != 2; 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

@@ -105,10 +105,10 @@ Public Class frmEnvelopeMainData
End If End If
End Sub End Sub
Private Function TranslateCertificationType(pType As CertificationType) As String 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 End Function
Private Function TranslateFinalEmailType(pType As FinalEmailType) As String 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 End Function
Private Sub btOK_Click(sender As Object, e As EventArgs) Handles btOK.Click 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.Drawing;
using DevExpress.Pdf; using DevExpress.Pdf;
using DigitalData.Core.Exceptions;
using EnvelopeGenerator.Application.Common.Dto; using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Interfaces.Doc; using EnvelopeGenerator.Application.Common.Interfaces.Doc;
using EnvelopeGenerator.Domain.Interfaces;
namespace EnvelopeGenerator.Infrastructure.Doc; namespace EnvelopeGenerator.Infrastructure.Doc;
@@ -54,4 +56,125 @@ public class PdfPageImageRenderer : IPdfPageImageRenderer
return Task.FromResult<IReadOnlyList<PdfPageImageDto>>(results); 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

@@ -5,6 +5,7 @@
@using EnvelopeGenerator.Domain.Resources @using EnvelopeGenerator.Domain.Resources
@using EnvelopeGenerator.Server.Client.Components.Sender.ReceiverSuggestionFilter @using EnvelopeGenerator.Server.Client.Components.Sender.ReceiverSuggestionFilter
@using EnvelopeGenerator.Server.Client.Services @using EnvelopeGenerator.Server.Client.Services
@using MudBlazor
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using Microsoft.Extensions.Localization @using Microsoft.Extensions.Localization
@inject IStringLocalizer<Model> ModelLocalizer @inject IStringLocalizer<Model> ModelLocalizer
@@ -27,28 +28,23 @@
<div class="sender-create-wizard" <div class="sender-create-wizard"
tabindex="0" tabindex="0"
@onkeydown="OnWizardKeyDownAsync"> @onkeydown="OnWizardKeyDownAsync">
<div class="sender-create-wizard__progress"> <MudPaper Class="sender-create-wizard__stepper-shell" Elevation="0">
<div class="sender-create-wizard__progress-track"></div> <MudStepper Vertical="true"
NonLinear="false"
ShowResetButton="false"
ActiveIndex="@_currentStep"
Class="sender-create-wizard__stepper">
<ChildContent>
@for (var step = 0; step < TotalSteps; step++) @for (var step = 0; step < TotalSteps; step++)
{ {
var isActive = step == _currentStep; var stepIndex = step;
var isDone = step < _currentStep;
<div class="sender-create-wizard__step @(isActive ? "sender-create-wizard__step--active" : string.Empty) @(isDone ? "sender-create-wizard__step--done" : string.Empty)">
<span class="sender-create-wizard__step-index">@(step + 1)</span>
</div>
}
</div>
<div class="sender-create-wizard__meta"> <MudStep Title="@GetStepTitle(stepIndex)">
<div class="sender-create-wizard__title">@CurrentStepTitle</div> <div class="sender-create-wizard__stepper-description">@GetStepDescription(stepIndex)</div>
<div class="sender-create-wizard__description">@CurrentStepDescription</div>
</div>
@if (!string.IsNullOrWhiteSpace(_validationMessage)) @if (stepIndex == _currentStep)
{ {
<div class="sender-create-wizard__validation">@_validationMessage</div> <div class="sender-create-wizard__step-content">
}
@if (_currentStep == 0) @if (_currentStep == 0)
{ {
<div class="sender-create-wizard__form-grid"> <div class="sender-create-wizard__form-grid">
@@ -78,7 +74,7 @@
</div> </div>
</div> </div>
} }
else if (_currentStep == 1) else if (_currentStep == 3)
{ {
<div class="sender-create-wizard__form-grid"> <div class="sender-create-wizard__form-grid">
<div class="sender-create-wizard__field sender-create-wizard__field--full"> <div class="sender-create-wizard__field sender-create-wizard__field--full">
@@ -118,9 +114,8 @@
<div class="sender-create-wizard__field"> <div class="sender-create-wizard__field">
<label class="sender-create-wizard__label">@L("CertificationType", "Zertifizierung")</label> <label class="sender-create-wizard__label">@L("CertificationType", "Zertifizierung")</label>
<select @bind="_certificationType" class="sender-create-wizard__select"> <select @bind="_certificationType" class="sender-create-wizard__select">
<option value="1">@L("AdvancedElectronicSignature", "Advanced Electronic Signature")</option> <option value="@CertificationType.AdvancedElectronicSignature">@L("AdvancedElectronicSignature", "Advanced Electronic Signature")</option>
<option value="2">@L("ElectronicSignature", "Electronic Signature")</option> <option value="@CertificationType.ElectronicSignature">@L("ElectronicSignature", "Electronic Signature")</option>
<option value="3">@L("QualifiedSignature", "Qualified Signature")</option>
</select> </select>
</div> </div>
@@ -193,7 +188,7 @@
</div> </div>
</div> </div>
} }
else if (_currentStep == 3) else if (_currentStep == 1)
{ {
<div class="sender-create-wizard__suggestions"> <div class="sender-create-wizard__suggestions">
<div class="sender-create-wizard__suggestions-title">@L("SenderWizard_RecentReceivers", "Zuletzt verwendete Empfänger")</div> <div class="sender-create-wizard__suggestions-title">@L("SenderWizard_RecentReceivers", "Zuletzt verwendete Empfänger")</div>
@@ -356,6 +351,19 @@
</div> </div>
} }
</div> </div>
}
</MudStep>
}
</ChildContent>
</MudStepper>
</MudPaper>
@if (!string.IsNullOrWhiteSpace(_validationMessage))
{
<div class="sender-create-wizard__validation">@_validationMessage</div>
}
</div>
</BodyContentTemplate> </BodyContentTemplate>
<FooterContentTemplate> <FooterContentTemplate>
<div class="sender-create-wizard__footer"> <div class="sender-create-wizard__footer">
@@ -421,7 +429,7 @@
private string _language = "de-DE"; private string _language = "de-DE";
private bool _useAccessCode; private bool _useAccessCode;
private bool _tfaEnabled; private bool _tfaEnabled;
private int _certificationType = (int)CertificationType.AdvancedElectronicSignature; private CertificationType _certificationType = CertificationType.AdvancedElectronicSignature;
private int _finalEmailToCreator = (int)FinalEmailType.No; private int _finalEmailToCreator = (int)FinalEmailType.No;
private int _finalEmailToReceivers = (int)FinalEmailType.No; private int _finalEmailToReceivers = (int)FinalEmailType.No;
private bool _sendReminderEmails; private bool _sendReminderEmails;
@@ -457,7 +465,7 @@
private bool IsFirstStep => _currentStep <= 0; private bool IsFirstStep => _currentStep <= 0;
private bool IsLastStep => _currentStep >= TotalSteps - 1; private bool IsLastStep => _currentStep >= TotalSteps - 1;
private bool CanSkipCurrentStep => _currentStep == 1; private bool CanSkipCurrentStep => _currentStep == 3;
private IReadOnlyList<RecentReceiverSuggestionDto> FilteredRecentSuggestions => private IReadOnlyList<RecentReceiverSuggestionDto> FilteredRecentSuggestions =>
BuildFilteredRecentSuggestions(); BuildFilteredRecentSuggestions();
@@ -466,22 +474,26 @@
? $"data:application/pdf;base64,{Convert.ToBase64String(_documentBytes)}" ? $"data:application/pdf;base64,{Convert.ToBase64String(_documentBytes)}"
: null; : null;
private string CurrentStepTitle => _currentStep switch private string CurrentStepTitle => GetStepTitle(_currentStep);
private string CurrentStepDescription => GetStepDescription(_currentStep);
private string GetStepTitle(int step) => step switch
{ {
0 => L("SenderWizard_Step_Basics", "Grunddaten"), 0 => L("SenderWizard_Step_Basics", "Grunddaten"),
1 => L("SenderWizard_Step_Message", "Nachricht und Optionen"), 1 => L("SenderWizard_Step_Receivers", "Empfänger"),
2 => L("SenderEditor_EnvelopeSettings", "Envelope-Einstellungen"), 2 => L("SenderEditor_EnvelopeSettings", "Envelope-Einstellungen"),
3 => L("SenderWizard_Step_Receivers", "Empfänger"), 3 => L("SenderWizard_Step_Message", "Nachricht und Optionen"),
4 => L("SenderWizard_Step_Document", "Dokument"), 4 => L("SenderWizard_Step_Document", "Dokument"),
_ => L("SenderWizard_Step_Basics", "Grunddaten") _ => L("SenderWizard_Step_Basics", "Grunddaten")
}; };
private string CurrentStepDescription => _currentStep switch private string GetStepDescription(int step) => step switch
{ {
0 => L("SenderWizard_Step_Basics_Desc", "Starten Sie mit den Pflichtangaben für den Umschlag."), 0 => L("SenderWizard_Step_Basics_Desc", "Starten Sie mit den Pflichtangaben für den Umschlag."),
1 => L("SenderWizard_Step_Message_Desc", "Fügen Sie optionale Nachricht und Einstellungen hinzu."), 1 => L("SenderWizard_Step_Receivers_Desc", "Definieren Sie die Empfänger für den Signaturprozess."),
2 => L("SenderWizard_Step_Settings_Desc", "Konfigurieren Sie Sicherheits-, Erinnerungs- und Ablaufoptionen."), 2 => L("SenderWizard_Step_Settings_Desc", "Konfigurieren Sie Sicherheits-, Erinnerungs- und Ablaufoptionen."),
3 => L("SenderWizard_Step_Receivers_Desc", "Definieren Sie die Empfänger für den Signaturprozess."), 3 => L("SenderWizard_Step_Message_Desc", "Fügen Sie optionale Nachricht und Einstellungen hinzu."),
4 => L("SenderWizard_Step_Document_Desc", "Laden Sie das Dokument hoch und platzieren Sie Signaturfelder."), 4 => L("SenderWizard_Step_Document_Desc", "Laden Sie das Dokument hoch und platzieren Sie Signaturfelder."),
_ => L("SenderWizard_Step_Basics_Desc", "Starten Sie mit den Pflichtangaben für den Umschlag.") _ => L("SenderWizard_Step_Basics_Desc", "Starten Sie mit den Pflichtangaben für den Umschlag.")
}; };
@@ -704,7 +716,7 @@
{ {
_currentStep++; _currentStep++;
if (_currentStep == 3) if (_currentStep == 1)
await EnsureRecentSuggestionsLoadedAsync(); await EnsureRecentSuggestionsLoadedAsync();
} }
} }
@@ -798,10 +810,10 @@
if (!string.Equals(args.Key, "Enter", StringComparison.OrdinalIgnoreCase)) if (!string.Equals(args.Key, "Enter", StringComparison.OrdinalIgnoreCase))
return; return;
if (_currentStep == 1) if (_currentStep == 3)
return; return;
if (_currentStep == 3 && (!string.IsNullOrWhiteSpace(_receiverName) || !string.IsNullOrWhiteSpace(_receiverEmail))) if (_currentStep == 1 && (!string.IsNullOrWhiteSpace(_receiverName) || !string.IsNullOrWhiteSpace(_receiverEmail)))
{ {
await AddReceiver(); await AddReceiver();
return; return;
@@ -895,7 +907,7 @@
return true; return true;
} }
if (_currentStep == 3) if (_currentStep == 1)
{ {
if (_receivers.Count == 0) if (_receivers.Count == 0)
{ {
@@ -1116,7 +1128,7 @@
_language = "de-DE"; _language = "de-DE";
_useAccessCode = false; _useAccessCode = false;
_tfaEnabled = false; _tfaEnabled = false;
_certificationType = (int)CertificationType.AdvancedElectronicSignature; _certificationType = CertificationType.AdvancedElectronicSignature;
_finalEmailToCreator = (int)FinalEmailType.No; _finalEmailToCreator = (int)FinalEmailType.No;
_finalEmailToReceivers = (int)FinalEmailType.No; _finalEmailToReceivers = (int)FinalEmailType.No;
_sendReminderEmails = false; _sendReminderEmails = false;

View File

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

View File

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

View File

@@ -22,7 +22,7 @@
</main> </main>
<footer class="receiver-footer"> <footer class="receiver-footer">
<div class="receiver-footer__left"> <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> <span class="receiver-footer__sep">&#124;</span>
<a href="@GetPrivacyPolicyPath()" target="_blank" rel="noopener">@L("PrivacyPolicy", "Datenschutz")</a> <a href="@GetPrivacyPolicyPath()" target="_blank" rel="noopener">@L("PrivacyPolicy", "Datenschutz")</a>
</div> </div>

View File

@@ -26,7 +26,7 @@
</svg> </svg>
} }
<div> <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> <p class="home-hero-header__subtitle">@L("Index_Subtitle", "Willkommen im eSign-Portal")</p>
</div> </div>
</div> </div>

View File

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

View File

@@ -7,6 +7,7 @@
@using EnvelopeGenerator.Server.Client.Services @using EnvelopeGenerator.Server.Client.Services
@using EnvelopeGenerator.Application.Common.Dto @using EnvelopeGenerator.Application.Common.Dto
@using EnvelopeGenerator.Application.Common.Dto.History @using EnvelopeGenerator.Application.Common.Dto.History
@using EnvelopeGenerator.Application.Envelopes.Commands
@using EnvelopeGenerator.Application.Resources @using EnvelopeGenerator.Application.Resources
@using EnvelopeGenerator.Domain.Resources @using EnvelopeGenerator.Domain.Resources
@using EnvelopeGenerator.Server.Client.Components.Sender @using EnvelopeGenerator.Server.Client.Components.Sender
@@ -19,7 +20,8 @@
@inject EnvelopeGenerator.Server.Client.Services.ThemeService ThemeService @inject EnvelopeGenerator.Server.Client.Services.ThemeService ThemeService
@inject EnvelopeGenerator.Server.Client.Services.UserPreferencesService UserPreferencesService @inject EnvelopeGenerator.Server.Client.Services.UserPreferencesService UserPreferencesService
@inject EnvelopeGenerator.Server.Client.Services.CustomImagesService CustomImagesService @inject EnvelopeGenerator.Server.Client.Services.CustomImagesService CustomImagesService
@inject EnvelopeGenerator.Server.Client.Services.CreateEnvelopeBootstrapStorageService CreateEnvelopeBootstrapStorageService @inject EnvelopeGenerator.Server.Client.Services.PdfSignatureService PdfSignatureService
@inject EnvelopeGenerator.Server.Client.Services.EnvelopeReceiverService EnvelopeReceiverService
@inject ILogger<SenderPage> Logger @inject ILogger<SenderPage> Logger
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject IJSRuntime JSRuntime @inject IJSRuntime JSRuntime
@@ -319,6 +321,7 @@
var isSigned = IsReceiverSigned(envelope, receiver); var isSigned = IsReceiverSigned(envelope, receiver);
var accessCode = receiver.AccessCode; var accessCode = receiver.AccessCode;
var hasAccessCode = !string.IsNullOrWhiteSpace(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;"> <span class="receiver-badge receiver-badge--@(isRejected ? "unsigned" : (isSigned ? "signed" : "unsigned"))" style="min-width: 100px;">
@if (isRejected) @if (isRejected)
@@ -364,6 +367,19 @@
<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"/> <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> </svg>
</button> </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>
</div> </div>
@@ -424,7 +440,7 @@
@* ── PDF Vorschau Popup ────────────────────────────────────────────────── *@ @* ── PDF Vorschau Popup ────────────────────────────────────────────────── *@
<DxPopup @bind-Visible="@_previewVisible" <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" Width="80vw"
MaxWidth="80vw" MaxWidth="80vw"
Height="90vh" Height="90vh"
@@ -543,6 +559,7 @@
private string _activeTab = "active"; private string _activeTab = "active";
private bool _isLoading = true; private bool _isLoading = true;
private bool _isLoggingOut = false; private bool _isLoggingOut = false;
private bool _canUseReceiverEnvelopeUrl = false;
private string? _errorMessage; private string? _errorMessage;
private DxGrid? _grid; private DxGrid? _grid;
@@ -602,6 +619,8 @@
try { await ThemeService.LoadFromServerAsync(); } try { await ThemeService.LoadFromServerAsync(); }
catch { /* swallow — default theme remains active */ } catch { /* swallow — default theme remains active */ }
_canUseReceiverEnvelopeUrl = await EnvelopeReceiverService.GetReceiverEnvelopeUrlFeatureEnabledAsync();
// Load logo config — authenticated, so this should succeed // Load logo config — authenticated, so this should succeed
var logos = await CustomImagesService.GetAsync(); var logos = await CustomImagesService.GetAsync();
_appLogo = logos.App; _appLogo = logos.App;
@@ -628,7 +647,7 @@
try try
{ {
_allEnvelopes = await EnvelopeService.GetAsync() ?? []; _allEnvelopes = await EnvelopeService.GetAsync(includeDocAndDocRes: false) ?? [];
// Split into active and completed based on effective status // Split into active and completed based on effective status
// (history-aware: rejected envelopes should not stay in active tab). // (history-aware: rejected envelopes should not stay in active tab).
@@ -725,29 +744,44 @@
async Task StartEditorFromWizardAsync(CreateEnvelopeWizardResult result) async Task StartEditorFromWizardAsync(CreateEnvelopeWizardResult result)
{ {
var payload = new CreateEnvelopeBootstrapPayload( var docBase64 = result.DocumentBytes is { Length: > 0 }
Title: result.Title, ? Convert.ToBase64String(result.DocumentBytes)
Message: result.Message, : null;
EnvelopeTypeId: result.EnvelopeTypeId,
Language: result.Language,
UseAccessCode: result.UseAccessCode,
TFAEnabled: result.TFAEnabled,
CertificationType: result.CertificationType,
FinalEmailToCreator: result.FinalEmailToCreator,
FinalEmailToReceivers: result.FinalEmailToReceivers,
SendReminderEmails: result.SendReminderEmails,
FirstReminderDays: result.FirstReminderDays,
ReminderIntervalDays: result.ReminderIntervalDays,
ExpiresWhenDays: result.ExpiresWhenDays,
ExpiresWarningWhenDays: result.ExpiresWarningWhenDays,
Receivers: result.Receivers
.Select(x => new CreateEnvelopeBootstrapReceiver(x.FullName, x.EmailAddress))
.ToList(),
DocumentBytes: result.DocumentBytes,
DocumentFileName: result.DocumentFileName);
var token = await CreateEnvelopeBootstrapStorageService.StoreAsync(payload); var command = new CreateEnvelopeCommand
Navigation.NavigateTo($"/sender/editor?bootstrapToken={token}"); {
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() void EditEnvelope()
@@ -986,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) void OnCustomizeElement(GridCustomizeElementEventArgs e)
{ {
// Future: Add custom row coloring based on status if needed // Future: Add custom row coloring based on status if needed
@@ -1024,12 +1087,14 @@
private async Task OpenPreviewAsync(EnvelopeDto envelope) private async Task OpenPreviewAsync(EnvelopeDto envelope)
{ {
var showSigned = _activeTab == "completed" && envelope.Status == EnvelopeStatus.EnvelopeCompletelySigned;
// Unmount the viewer first — let the previous instance fully dispose // Unmount the viewer first — let the previous instance fully dispose
_previewViewerMounted = false; _previewViewerMounted = false;
_previewPdfBytes = null; _previewPdfBytes = null;
_previewPageCount = 0; _previewPageCount = 0;
_previewPagePreviews = []; _previewPagePreviews = [];
_previewFileName = $"envelope-{envelope.Id}.pdf"; _previewFileName = showSigned ? $"envelope-{envelope.Id}-signed.pdf" : $"envelope-{envelope.Id}.pdf";
_previewErrorMessage = null; _previewErrorMessage = null;
_previewLoading = true; _previewLoading = true;
_previewVisible = true; _previewVisible = true;
@@ -1037,7 +1102,10 @@
try 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) if (_previewPdfBytes is null || _previewPdfBytes.Length == 0)
{ {
_previewErrorMessage = L("DocumentCouldNotBeLoaded", "Dokument konnte nicht geladen werden"); _previewErrorMessage = L("DocumentCouldNotBeLoaded", "Dokument konnte nicht geladen werden");

View File

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

View File

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

View File

@@ -2,8 +2,9 @@ using System.Net;
using System.Net.Http; using System.Net.Http;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
using EnvelopeGenerator.Application.EnvelopeReceivers.Commands; using EnvelopeGenerator.Application.EnvelopeReceivers.Commands.CreateEnvelopeReceiver;
using EnvelopeGenerator.Server.Client.Models; using EnvelopeGenerator.Server.Client.Models;
using Microsoft.AspNetCore.WebUtilities;
namespace EnvelopeGenerator.Server.Client.Services; namespace EnvelopeGenerator.Server.Client.Services;
@@ -15,6 +16,8 @@ namespace EnvelopeGenerator.Server.Client.Services;
public class EnvelopeReceiverService(IHttpClientFactory httpClientFactory) public class EnvelopeReceiverService(IHttpClientFactory httpClientFactory)
{ {
private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web); private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
private sealed record ReceiverEnvelopeUrlResponse(string Url);
private sealed record ReceiverEnvelopeUrlEnabledResponse(bool Enabled);
/// <summary> /// <summary>
/// Fetches the envelope receiver data for the given envelope key from the API. /// 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); 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, string? uuid = null,
bool? onlyActive = null, bool? onlyActive = null,
bool? onlyCompleted = null, bool? onlyCompleted = null,
bool? includeDocAndDocRes = null,
CancellationToken cancel = default) CancellationToken cancel = default)
{ {
var baseUrl = $"/api/Envelope"; var baseUrl = $"/api/Envelope";
@@ -46,6 +47,10 @@ public class EnvelopeService(IHttpClientFactory clientFactory)
{ {
queryParams["OnlyCompleted"] = onlyCompleted.Value.ToString(); queryParams["OnlyCompleted"] = onlyCompleted.Value.ToString();
} }
if (includeDocAndDocRes.HasValue)
{
queryParams["IncludeDocAndDocRes"] = includeDocAndDocRes.Value.ToString();
}
var url = QueryHelpers.AddQueryString(baseUrl, queryParams); 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

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

View File

@@ -56,7 +56,7 @@
title="@L("Complete", "Abschließen")" title="@L("Complete", "Abschließen")"
style="flex-shrink: 0;"> style="flex-shrink: 0;">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" 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"/> <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> </svg>
<span class="pdf-toolbar__btn-text">@L("Complete", "Abschließen")</span> <span class="pdf-toolbar__btn-text">@L("Complete", "Abschließen")</span>
</button> </button>
@@ -114,7 +114,7 @@
<div style="display: flex; align-items: flex-start; gap: 1rem; padding: 0.5rem 0;"> <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 style="flex-shrink: 0; width: 40px; height: 40px; background: #d1fae5; border-radius: 50%; display: flex; align-items: center; justify-content: center;">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#065f46" viewBox="0 0 16 16"> <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"/> <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> </svg>
</div> </div>
<div> <div>

View File

@@ -0,0 +1,99 @@
using DigitalData.Core.Abstraction.Application.Repository;
using DevExpress.Drawing;
using DevExpress.Pdf;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Domain.Entities;
using EnvelopeGenerator.Domain.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using EnvelopeGenerator.Application.Common.Interfaces.Doc;
namespace EnvelopeGenerator.Server.Controllers;
/// <summary>
/// Temporary development endpoint to download stored signature ink image by element id.
/// </summary>
[ApiController]
[AllowAnonymous]
[Route("api/dev/signature-ink")]
public class DevSignatureInkController(
IRepository<DocReceiverElement> repository,
IWebHostEnvironment environment,
IConfiguration configuration,
IPdfPageImageRenderer renderer) : ControllerBase
{
/// <summary>
/// Downloads the signature image stored in INK for the given DocumentReceiverElement id.
/// </summary>
[HttpGet("{id:int}")]
public async Task<IActionResult> DownloadInk([FromRoute] int id, CancellationToken cancel)
{
if (!IsEndpointEnabled())
return NotFound();
var element = await repository.Query
.AsNoTracking()
.Where(e => e.Id == id)
.Select(e => new DocReceiverElementDto
{
Id = e.Id,
Ink = e.Ink
})
.SingleOrDefaultAsync(cancel);
if (element is null)
return NotFound($"DocumentReceiverElement not found for id={id}.");
if (element.Ink is not { Length: > 0 })
return NotFound($"No signature image (Ink) found for id={id}.");
var image = element.ToImage();
return File(image.Bytes, image.ContentType, $"signature-ink-{id}{image.FileExtension}");
}
/// <summary>
/// Downloads a composed signature image (background + ink + signer metadata) for the given element id.
/// </summary>
[HttpGet("{id:int}/composed")]
public async Task<IActionResult> DownloadComposedInk([FromRoute] int id, CancellationToken cancel)
{
if (!IsEndpointEnabled())
return NotFound();
var element = await repository.Query
.AsNoTracking()
.Where(e => e.Id == id)
.Select(e => new DocReceiverElementDto
{
Id = e.Id,
Ink = e.Ink,
FullName = e.FullName,
Position = e.Position,
Place = e.Place,
ChangedWhen = e.ChangedWhen
})
.SingleOrDefaultAsync(cancel);
if (element is null)
return NotFound($"DocumentReceiverElement not found for id={id}.");
if (element.Ink is not { Length: > 0 })
return NotFound($"No signature image (Ink) found for id={id}.");
if (element.ChangedWhen is null)
return Conflict($"DocumentReceiverElement id={id} has null ChangedWhen. Element is not signed yet or data integrity is broken.");
if (string.IsNullOrWhiteSpace(element.FullName) || string.IsNullOrWhiteSpace(element.Place))
return Conflict($"DocumentReceiverElement id={id} is missing signer metadata (FullName or Place).");
if (!renderer.IsSupportedImageFormat(element.Ink!))
return StatusCode(StatusCodes.Status415UnsupportedMediaType);
var composedStream = renderer.ComposeSignaturePreviewPng(element);
return File(composedStream, "image/png", $"signature-composed-{id}.png");
}
private bool IsEndpointEnabled()
=> environment.IsDevelopment() || configuration.GetValue<bool>("DevEndpoints:EnableSignatureInkInProduction");
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,132 @@
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Interfaces.Services;
using EnvelopeGenerator.Application.PdfSigning;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Server.Extensions;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace EnvelopeGenerator.Server.Controllers;
/// <summary>
///
/// </summary>
/// <param name="SignatureService"></param>
/// <param name="Mediator"></param>
[ApiController]
[Route("api/[controller]")]
public class PdfSignatureController(IPdfDigitalSignatureService SignatureService, IMediator Mediator) : ControllerBase
{
/// <summary>
///
/// </summary>
/// <param name="command"></param>
/// <param name="cancel"></param>
/// <returns></returns>
[HttpPost]
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
public async Task<IActionResult> SignDocument(SignPdfByEnvelopeCommand command, CancellationToken cancel)
{
var senderId = User.SenderId();
var result = await Mediator.Send(command.Authorized(senderId), cancel);
result.Position = 0;
var fileName = Path.GetFileNameWithoutExtension($"e{command.Id.ToString() ?? command.Uuid}_s{senderId}");
return File(result, "application/pdf", $"{fileName}_signed.pdf");
}
/// <summary>
/// Gets the document for the specified envelope key.
/// </summary>
/// <param name="envelopeKey"></param>
/// <param name="cancel"></param>
/// <returns></returns>
[Authorize(Policy = AuthPolicy.Receiver)]
[HttpPost("{envelopeKey}")]
public async Task<IActionResult> SignDocumentByReceiver(string envelopeKey, CancellationToken cancel)
{
int envelopeId = User.EnvelopeId();
int receiverId = User.ReceiverId();
var senderDoc = await Mediator.Send(new SignPdfByEnvelopeReceiverCommand
{
EnvelopeId = envelopeId,
ReceiverId = receiverId
}, cancel);
senderDoc.Position = 0;
var fileName = Path.GetFileNameWithoutExtension($"e{envelopeId}_r{receiverId}");
return File(senderDoc, "application/pdf", $"{fileName}_signed.pdf");
}
#region Test Endpoints
/// <summary>
///
/// </summary>
public class CheckPdfSignatureForm
{
/// <summary>
///
/// </summary>
public virtual required IFormFile File { get; init; }
}
/// <summary>
///
/// </summary>
public sealed class SignPdfForm : CheckPdfSignatureForm
{
/// <summary>
///
/// </summary>
public required IFormFile SignatureImage { get; init; }
/// <summary>
///
/// </summary>
public PdfSignOptionsDto Options { get; init; } = new PdfSignOptionsDto();
}
/// <summary>
///
/// </summary>
/// <param name="request"></param>
/// <param name="cancel"></param>
/// <returns></returns>
[NonAction]
[AllowAnonymous]
[HttpPost("sign")]
[Consumes("multipart/form-data")]
public async Task<IActionResult> Sign([FromForm] SignPdfForm request, CancellationToken cancel)
{
await using var signatureImage = request.SignatureImage.OpenReadStream();
await using var input = request.File.OpenReadStream();
var output = new MemoryStream();
await SignatureService.SignPdfAsync(input, output, request.Options, signatureImage, cancel);
output.Position = 0;
var fileName = Path.GetFileNameWithoutExtension(request.File.FileName);
return File(output, "application/pdf", $"{fileName}-signed.pdf");
}
/// <summary>
///
/// </summary>
/// <param name="request"></param>
/// <param name="cancel"></param>
/// <returns></returns>
[NonAction]
[AllowAnonymous]
[HttpPost("is-signed")]
[Consumes("multipart/form-data")]
public async Task<IActionResult> IsSigned([FromForm] CheckPdfSignatureForm request, CancellationToken cancel)
{
await using var input = request.File.OpenReadStream();
var result = await SignatureService.CheckPdfSignatureAsync(input, cancel);
return Ok(result);
}
#endregion
}

View File

@@ -10,24 +10,27 @@
<Authors>Digital Data GmbH</Authors> <Authors>Digital Data GmbH</Authors>
<Company>Digital Data GmbH</Company> <Company>Digital Data GmbH</Company>
<Product>EnvelopeGenerator.Server</Product> <Product>EnvelopeGenerator.Server</Product>
<Version>1.4.0-beta</Version> <Version>1.8.0-beta</Version>
<FileVersion>1.4.0.0</FileVersion> <FileVersion>1.8.0.0</FileVersion>
<AssemblyVersion>1.4.0.0</AssemblyVersion> <AssemblyVersion>1.8.0.0</AssemblyVersion>
<Copyright>Copyright © 2026 Digital Data GmbH. All rights reserved.</Copyright> <Copyright>Copyright © 2026 Digital Data GmbH. All rights reserved.</Copyright>
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile> <DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\EnvelopeGenerator.Infrastructure.Notification\EnvelopeGenerator.Infrastructure.Notification.csproj" />
<ProjectReference Include="..\EnvelopeGenerator.Server.Client\EnvelopeGenerator.Server.Client.csproj" /> <ProjectReference Include="..\EnvelopeGenerator.Server.Client\EnvelopeGenerator.Server.Client.csproj" />
<ProjectReference Include="..\..\EnvelopeGenerator.Application\EnvelopeGenerator.Application.csproj" /> <ProjectReference Include="..\..\EnvelopeGenerator.Application\EnvelopeGenerator.Application.csproj" />
<ProjectReference Include="..\..\EnvelopeGenerator.Domain\EnvelopeGenerator.Domain.csproj" /> <ProjectReference Include="..\..\EnvelopeGenerator.Domain\EnvelopeGenerator.Domain.csproj" />
<ProjectReference Include="..\..\EnvelopeGenerator.Infrastructure.Doc\EnvelopeGenerator.Infrastructure.Doc.csproj" /> <ProjectReference Include="..\..\EnvelopeGenerator.Infrastructure.Doc\EnvelopeGenerator.Infrastructure.Doc.csproj" />
<ProjectReference Include="..\..\EnvelopeGenerator.Infrastructure.Crypt\EnvelopeGenerator.Infrastructure.Crypt.csproj" />
<ProjectReference Include="..\..\EnvelopeGenerator.Infrastructure\EnvelopeGenerator.Infrastructure.csproj" /> <ProjectReference Include="..\..\EnvelopeGenerator.Infrastructure\EnvelopeGenerator.Infrastructure.csproj" />
<ProjectReference Include="..\..\EnvelopeGenerator.PdfEditor\EnvelopeGenerator.PdfEditor.csproj" /> <ProjectReference Include="..\..\EnvelopeGenerator.PdfEditor\EnvelopeGenerator.PdfEditor.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="8.0.22" /> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="8.0.22" />
<PackageReference Include="MudBlazor" Version="8.0.0" />
<PackageReference Include="DevExpress.Blazor" Version="25.2.3" /> <PackageReference Include="DevExpress.Blazor" Version="25.2.3" />
<PackageReference Include="DevExpress.Blazor.PdfViewer" Version="25.2.3" /> <PackageReference Include="DevExpress.Blazor.PdfViewer" Version="25.2.3" />
<PackageReference Include="DevExpress.Blazor.Reporting.Viewer" Version="25.2.3" /> <PackageReference Include="DevExpress.Blazor.Reporting.Viewer" Version="25.2.3" />
@@ -47,7 +50,6 @@
<PackageReference Include="NLog.Web.AspNetCore" Version="5.3.0" /> <PackageReference Include="NLog.Web.AspNetCore" Version="5.3.0" />
<PackageReference Include="PDFsharp" Version="6.2.4" /> <PackageReference Include="PDFsharp" Version="6.2.4" />
<PackageReference Include="Scalar.AspNetCore" Version="2.2.1" /> <PackageReference Include="Scalar.AspNetCore" Version="2.2.1" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.1" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.1" />
<PackageReference Include="DigitalData.EmailProfilerDispatcher.Abstraction" Version="3.2.0" /> <PackageReference Include="DigitalData.EmailProfilerDispatcher.Abstraction" Version="3.2.0" />
<PackageReference Include="System.DirectoryServices" Version="8.0.0" /> <PackageReference Include="System.DirectoryServices" Version="8.0.0" />

View File

@@ -1,6 +1,7 @@
namespace EnvelopeGenerator.Server.Middleware; namespace EnvelopeGenerator.Server.Middleware;
using DigitalData.Core.Exceptions; using DigitalData.Core.Exceptions;
using EnvelopeGenerator.Domain.Exceptions;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System.Net; using System.Net;
@@ -74,6 +75,16 @@ public class ExceptionHandlingMiddleware
message = forbiddenEx.Message; message = forbiddenEx.Message;
break; break;
case AuthorizationException authorizationEx:
context.Response.StatusCode = (int)HttpStatusCode.Forbidden;
message = authorizationEx.Message;
break;
case DataIntegrityException dataIntegrityEx:
context.Response.StatusCode = (int)HttpStatusCode.Conflict;
message = dataIntegrityEx.Message;
break;
default: default:
logger.LogError(exception, "Unhandled exception occurred."); logger.LogError(exception, "Unhandled exception occurred.");
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;

View File

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

View File

@@ -1,33 +1,38 @@
using DevExpress.Blazor;
using DigitalData.Auth.Claims;
using DigitalData.Auth.Client;
using DigitalData.Core.Abstractions;
using DigitalData.Core.Abstractions.Security.Extensions;
using DigitalData.Core.API;
using DigitalData.Core.Application;
using DigitalData.EmailProfilerDispatcher;
using DigitalData.UserManager.DependencyInjection;
using EnvelopeGenerator.Application;
using EnvelopeGenerator.Application.Common.Interfaces;
using EnvelopeGenerator.Application.Common.Interfaces.Doc;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Infrastructure;
using EnvelopeGenerator.Infrastructure.Crypt.DependencyInjection;
using EnvelopeGenerator.Infrastructure.Doc;
using EnvelopeGenerator.Infrastructure.Notification;
using EnvelopeGenerator.Server;
using EnvelopeGenerator.Server.Client.Services;
using EnvelopeGenerator.Server.Components; using EnvelopeGenerator.Server.Components;
using EnvelopeGenerator.Server.Models; using EnvelopeGenerator.Server.Models;
using EnvelopeGenerator.Server.Options; using EnvelopeGenerator.Server.Options;
using DevExpress.Blazor; using EnvelopeGenerator.Server.Services;
using EnvelopeGenerator.Server.Client.Services;
using DigitalData.Core.API;
using DigitalData.Core.Application;
using EnvelopeGenerator.Infrastructure;
using EnvelopeGenerator.Domain.Constants;
using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Localization; using Microsoft.AspNetCore.Localization;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using System.Globalization;
using Scalar.AspNetCore;
using Microsoft.OpenApi.Models;
using DigitalData.UserManager.DependencyInjection;
using EnvelopeGenerator.Application;
using DigitalData.Auth.Client;
using DigitalData.Core.Abstractions;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using DigitalData.Core.Abstractions.Security.Extensions; using Microsoft.OpenApi.Models;
using NLog.Web;
using NLog; using NLog;
using DigitalData.Auth.Claims; using NLog.Web;
using EnvelopeGenerator.Server; using Scalar.AspNetCore;
using DigitalData.EmailProfilerDispatcher; using System.Globalization;
using EnvelopeGenerator.Application.Common.Interfaces.Doc; using MudBlazor.Services;
using EnvelopeGenerator.Infrastructure.Doc;
var logger = LogManager.Setup().LoadConfigurationFromAppSettings().GetCurrentClassLogger(); var logger = LogManager.Setup().LoadConfigurationFromAppSettings().GetCurrentClassLogger();
logger.Info("EnvelopeGenerator.Server logging initialized!"); logger.Info("EnvelopeGenerator.Server logging initialized!");
@@ -38,6 +43,7 @@ try
// Load YARP configuration from yarp.json // Load YARP configuration from yarp.json
builder.Configuration.AddJsonFile("yarp.json", optional: true, reloadOnChange: true); builder.Configuration.AddJsonFile("yarp.json", optional: true, reloadOnChange: true);
builder.Configuration.AddJsonFile("appsettings.Secrets.json", optional: true, reloadOnChange: true);
builder.Logging.SetMinimumLevel(Microsoft.Extensions.Logging.LogLevel.Trace); builder.Logging.SetMinimumLevel(Microsoft.Extensions.Logging.LogLevel.Trace);
@@ -55,6 +61,7 @@ try
builder.Services.AddRazorComponents() builder.Services.AddRazorComponents()
.AddInteractiveServerComponents() .AddInteractiveServerComponents()
.AddInteractiveWebAssemblyComponents(); .AddInteractiveWebAssemblyComponents();
builder.Services.AddMudServices();
// Add API Controllers // Add API Controllers
builder.Services.AddControllers() builder.Services.AddControllers()
@@ -295,6 +302,7 @@ try
// Cache options // Cache options
builder.Services.Configure<CacheOptions>(config.GetSection(CacheOptions.SectionName)); builder.Services.Configure<CacheOptions>(config.GetSection(CacheOptions.SectionName));
builder.Services.Configure<SenderFeatureAccessOptions>(config.GetSection(SenderFeatureAccessOptions.SectionName));
// Distributed Cache - SQL Server // Distributed Cache - SQL Server
builder.Services.AddDistributedSqlServerCache(options => builder.Services.AddDistributedSqlServerCache(options =>
@@ -323,7 +331,8 @@ try
}); });
opt.AddSQLExecutor(executor => executor.ConnectionString = connStr); opt.AddSQLExecutor(executor => executor.ConnectionString = connStr);
}) })
.AddEnvelopeGeneratorServices(config); .AddEnvelopeGeneratorServices(config)
.AddNotificationInfrastructure(config);
#pragma warning restore CS0618 #pragma warning restore CS0618
builder.Services.AddDispatcher<EGDbContext>(); builder.Services.AddDispatcher<EGDbContext>();
@@ -342,6 +351,7 @@ try
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.UserPreferencesService>(); builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.UserPreferencesService>();
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.ThemeService>(); builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.ThemeService>();
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.CustomImagesService>(); builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.CustomImagesService>();
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.PdfSignatureService>();
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.LocalizationService>(); builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.LocalizationService>();
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.ReceiverService>(); builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.ReceiverService>();
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.PdfRenderService>(); builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.PdfRenderService>();
@@ -349,13 +359,14 @@ try
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.CreateEnvelopeBootstrapStorageService>(); builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.CreateEnvelopeBootstrapStorageService>();
// Business Services (Server specific) // Business Services (Server specific)
builder.Services.AddScoped<DocumentService>(); builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.DocumentService>();
builder.Services.AddScoped<AuthService>(); builder.Services.AddScoped<AuthService>();
builder.Services.AddScoped<AnnotationService>(); builder.Services.AddScoped<AnnotationService>();
builder.Services.AddScoped<EnvelopeReceiverService>(); builder.Services.AddScoped<EnvelopeReceiverService>();
builder.Services.AddScoped<SignatureService>(); builder.Services.AddScoped<SignatureService>();
builder.Services.AddScoped<SignatureCacheService>(); builder.Services.AddScoped<SignatureCacheService>();
builder.Services.AddDocInfrastructure(); builder.Services.AddDocInfrastructure();
builder.Services.AddCryptInfrastructure(config);
builder.Services.AddScoped<EnvelopeGenerator.Application.Common.Interfaces.Services.IEnvelopeMailService, EnvelopeGenerator.Application.Services.EnvelopeMailService>(); builder.Services.AddScoped<EnvelopeGenerator.Application.Common.Interfaces.Services.IEnvelopeMailService, EnvelopeGenerator.Application.Services.EnvelopeMailService>();
builder.Services.AddSingleton<AppVersionService>(); builder.Services.AddSingleton<AppVersionService>();
@@ -369,6 +380,7 @@ try
builder.Services.AddScoped<EnvelopeGenerator.Server.Services.IEnvelopeAuthService, EnvelopeGenerator.Server.Services.EnvelopeAuthService>(); builder.Services.AddScoped<EnvelopeGenerator.Server.Services.IEnvelopeAuthService, EnvelopeGenerator.Server.Services.EnvelopeAuthService>();
builder.Services.AddScoped<EnvelopeGenerator.Server.Services.EnvelopeReceiverAuthorizationService>(); builder.Services.AddScoped<EnvelopeGenerator.Server.Services.EnvelopeReceiverAuthorizationService>();
builder.Services.AddScoped<EnvelopeGenerator.Server.Services.EnvelopeReceiverPageDataService>(); builder.Services.AddScoped<EnvelopeGenerator.Server.Services.EnvelopeReceiverPageDataService>();
builder.Services.AddHostedService<EnvelopeGenerator.Server.Services.EnvelopeFinalizationWorker>();
// DevExpress Server-Side Services (CRITICAL for DxPdfViewer) // DevExpress Server-Side Services (CRITICAL for DxPdfViewer)
builder.Services.AddDevExpressBlazor(); builder.Services.AddDevExpressBlazor();
@@ -384,6 +396,8 @@ try
builder.Services.Configure<EnvelopeGenerator.Server.Client.Options.PdfViewerOptions>( builder.Services.Configure<EnvelopeGenerator.Server.Client.Options.PdfViewerOptions>(
builder.Configuration.GetSection("PdfViewerOptions")); builder.Configuration.GetSection("PdfViewerOptions"));
builder.Services.AddScoped<IRequestContext, RequestContext>();
var app = builder.Build(); var app = builder.Build();
deferredProvider.Factory = () => app.Services; deferredProvider.Factory = () => app.Services;

View File

@@ -0,0 +1,64 @@
using EnvelopeGenerator.Application.PdfSigning;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Infrastructure;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace EnvelopeGenerator.Server.Services;
public sealed class EnvelopeFinalizationWorker(
IServiceScopeFactory scopeFactory,
ILogger<EnvelopeFinalizationWorker> logger) : BackgroundService
{
private static readonly TimeSpan PollInterval = TimeSpan.FromMinutes(1);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<EGDbContext>();
var sender = scope.ServiceProvider.GetRequiredService<ISender>();
var envelopeIds = await dbContext.Envelopes
.AsNoTracking()
.Where(e => e.Status == EnvelopeStatus.EnvelopeCompletelySigned && e.DocResult == null)
.Select(e => e.Id)
.ToListAsync(stoppingToken);
foreach(var envelopeId in envelopeIds)
{
try
{
await sender.Send(new FinalizeEnvelopeCommand()
{
Id = envelopeId,
BurnedAllowed = false,
SaveDocResult = true,
}, stoppingToken);
}
catch(Exception ex)
{
// TODO: integrate rabbit mq and handle exception (such as delete)
logger.LogError(ex, "Unexpected exception happened during finalization of envelope. Envelope ID: {EnvelopeId}", envelopeId);
}
}
// continue here
logger.LogDebug("EnvelopeFinalizationWorker found {Count} signed envelope(s) without DOC_RESULT.", envelopeIds.Count);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
logger.LogError(ex, "EnvelopeFinalizationWorker failed while reading envelopes.");
}
await Task.Delay(PollInterval, stoppingToken);
}
}
}

View File

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

View File

@@ -1,10 +1,11 @@
{ {
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
}, }
,
"AuthClientParams": { "AuthClientParams": {
"Url": "http://172.24.12.39:9090/auth-hub", "Url": "http://172.24.12.39:9090/auth-hub",
"PublicKeys": [ "PublicKeys": [

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