Compare commits

..

11 Commits

Author SHA1 Message Date
380a5f4c0c Add signature submission feature and pipeline updates
Introduced a new feature for submitting signatures, including
backend and frontend changes. Added `SignatureSubmitDto` and
`SubmitAsync` in `SignatureService` to handle signature data
submission. Implemented a new `Submit` endpoint in
`SignatureController` to validate and process signature payloads.

Updated pipeline behaviors to include `SaveSignatureBehavior`
and adjusted the execution order. Enhanced query logic in
`EnvelopeReceiverResolutionBehavior` and `SaveSignatureBehavior`
to align with the updated data model.

Refactored `EGDbContext` to improve trigger handling and logging.
Updated `ReceiverSignedPage.razor` to integrate `SignatureService`
and handle submission errors gracefully. Adjusted application
URLs in `launchSettings.json` and added new database triggers
in `appsettings.json`.
2026-09-28 12:21:44 +02:00
0f53d0509e remove depr API 2026-09-28 09:51:51 +02:00
435be882c9 Complete SF-74 implementation and fix PDF issues
Updated SF-74-PLAN.md to document completed phases (6a–6e), including theme selection, dark mode, grid persistence, and logo placement. Highlighted pending tasks for phase 6f and clarified open questions.

Revised MIGRATION_PLAN.md with deep analysis findings and detailed steps for migrating `EnvelopeGenerator.Service_legacy` to a modern C# Worker Service. Addressed connection string handling, Quartz version mismatch, and TempFiles architecture.

Removed outdated markdown files from EnvelopeGenerator.sln and updated the solution structure for consistency.

Fixed incorrect "Read and confirmed" label in Signature Certificate reports for "Read and Sign" envelopes. Added branching logic in `ReportItem.vb` and updated resource files.

Resolved signature field formatting issues in finalized PDFs based on WISAG feedback. Added truncation logic in `PDFBurner.vb` and updated `PDFBurnerParams.vb` with per-field character limits.
2026-09-25 09:17:50 +02:00
09257d7766 Refactor sender title and improve responsiveness
Refactored the sender title in `EnvelopeSenderPage.razor` to use a structured layout with accessibility improvements. Introduced `.sender-title-main` and `.sender-title-sub` classes for better styling and responsiveness. Updated `.sender-page.css` to enhance layout flexibility, reduce gaps, and improve scaling for logos and titles.

Added media queries for better responsiveness on smaller screens, including adjustments to `.sender-title-section` and logo dimensions. Introduced theme-specific overrides for "purple" and "blazing-dark" themes to ensure consistent styling.

Minor adjustments were made to padding, margins, and font sizes to improve overall layout and visual hierarchy.
2026-09-25 00:30:03 +02:00
16fa4b460f Remove "Grid persistence hint" feature
The "Grid persistence hint" feature was removed from `EnvelopeSenderPage.razor`, including its associated HTML `<div>` and SVG icon.

Corresponding styles for `.sf-grid-persistence-hint` were removed from `sender-page.css`, including display, alignment, padding, font size, color, background, and border.

Adjusted the padding for `.sender-grid-wrapper` in `sender-page.css` to account for the removal of the hint banner, setting it to `1rem 2rem 2rem`.
2026-09-25 00:16:36 +02:00
f4aae8c787 Improve logo rendering and page responsiveness
Updated `EnvelopeSenderPage.razor` to enhance customer logo rendering with dynamic height and class styling. Refined the "Nur ausgewählte" checkbox label for better styling.

Modified `sender-page.css` to improve layout and responsiveness:
- Added styles for `.sender-customer-logo-area` and `.signflow-company-logo` for proper scaling and alignment.
- Removed `.sender-export-label` styles and introduced `.sender-export-selected-text` for improved checkbox label design.
- Adjusted media queries for better layout on screens up to 1320px and 768px.

Enhanced overall responsiveness and accessibility of the sender page.
2026-09-25 00:14:08 +02:00
ed1468194d Add export functionality to /sender Dashboard (Phase 6e)
Finalized the implementation of the export feature for the
`EnvelopeSenderPage.razor` in the Blazor WASM Client. The feature
supports exporting grid data in XLSX, CSV, and PDF formats, with
an option to export only selected rows.

- Updated `SF-74-PLAN.6e.md` to reflect the completed Phase 6e.
- Integrated a compact UI for export controls in the toolbar:
  - Dropdown for format selection (XLSX, CSV, PDF).
  - Checkbox for "Only selected rows" option.
  - Single `Exportieren` button to trigger the export.
- Implemented `ExportSelectedFormatAsync` to handle export logic:
  - Supported formats: XLSX, CSV, PDF.
  - Added options for `ExportSelectedRowsOnly` and grouping.
- Replaced placeholder `ExportToExcelAsync` with finalized logic.
- Documented known limitations (e.g., template columns not exported).
- Updated project status table to mark Phase 6e as complete.

This commit ensures a streamlined and user-friendly export UX,
consistent with the existing design, while addressing known
DevExpress limitations.
2026-09-24 18:38:11 +02:00
f9f94c900e Implement export functionality with loading spinner
Replaced placeholder export method with a fully implemented
`ExportSelectedFormatAsync` method to support exporting grid
data in CSV, PDF, or XLSX formats. Added a loading spinner
to the export button to indicate progress during export
operations. Updated the `IsExportDisabled` method to account
for the new `_isExporting` state.

Disabled exporting for "Status" and "EnvelopeReceivers"
columns by setting their `ExportEnabled` property to `false`.
Made minor grid layout adjustments, including adding the
`KeyFieldName` property for proper grid behavior.
2026-09-24 18:32:03 +02:00
18df6221c9 Add export feature UI to EnvelopeSenderPage (placeholder)
Introduced a new export feature to the EnvelopeSenderPage, including a dropdown for selecting export formats (XLSX, CSV, PDF) and a checkbox to export only selected rows. Added a placeholder export button ("Exportieren") that is currently disabled, with export logic to be implemented in a future phase.

Added private fields `_selectedExportFormat` and `_exportSelectedRowsOnly` to manage the export state. Implemented `IsExportDisabled()` to control button availability and a placeholder method `ExportSelectedFormatPlaceholderAsync()`.

Updated `sender-page.css` to style the export feature, including the export button, dropdown, and checkbox. Added theme-specific styles for "purple" and "blazing-dark" themes. Adjusted toolbar element order to position the export group before the logout button.
2026-09-24 18:11:37 +02:00
b15f95755c Add multi-format export UI to EnvelopeSenderPage
Implemented the first phase of a planned multi-format export feature for the `/sender` dashboard. Added a compact export block to the right action bar in `EnvelopeSenderPage.razor`, including a format dropdown (XLSX, CSV, PDF), a "Selected Rows Only" toggle, and an "Export" button.

The UI is fully integrated with responsive design and theme consistency across `blazing-berry`, `blazing-dark`, and `purple`. No export logic is implemented yet; placeholder handlers are in place for the export button. Styles were added to `wwwroot/css/sender-page.css` to ensure alignment with the existing design language and mobile usability.

This commit completes Phase 1 of the implementation plan. Phase 2 will add the actual export functionality.
2026-09-24 18:07:05 +02:00
4be9e38a9e Mark Phase 6d as complete; add PDF preview details
Updated the "Letzte Aktualisierung" date in SF-74-PLAN.md to
reflect the latest progress. Added requirement 6d for a PDF
Preview Popup using `DxPdfViewer` and marked it as completed.
Provided additional context for Phase 6d, explaining the
read-only behavior of envelopes after sending and the technical
approach for implementing the PDF preview functionality.
2026-09-24 16:28:16 +02:00
68 changed files with 495 additions and 4541 deletions

View File

@@ -50,19 +50,6 @@ This section explains every markdown file in the repo: what it contains, why it
---
### Ticket / Feature Plans (Active Work Items)
#### `EnvelopeGenerator.Server/SF-74-PLAN.md`
**What:** Implementation plan for SF-74 ticket — Theme selection, Dark Mode, Grid Layout Persistence, signFLOW logo, and customer logo area on the sender dashboard. Includes the original ticket requirements, component breakdown, and implementation steps.
**Why it exists:** Captures the agreed scope and implementation decisions for SF-74 so work can be resumed or reviewed without re-reading the ticket system.
**When you need it:**
- Resuming work on theme, dark mode, or grid persistence features
- Checking what was agreed for logo positioning and size
- Verifying which features of SF-74 are complete vs. pending
**Maintenance:** Mark items as complete as they are implemented. Archive when the ticket is closed.
---
### Deployment Docs
#### `EnvelopeGenerator.Server/EnvelopeGenerator.Server/README.md`
@@ -76,33 +63,6 @@ This section explains every markdown file in the repo: what it contains, why it
---
### Fix Reports (Historical — Read-Only)
These files document completed bug fixes. They exist as audit trails and as references if a similar issue resurfaces. Do not modify them.
#### `fix-report-label-read-and-confirmed.md`
**What:** Documents the fix for an incorrect label in the Signature Certificate report (`Signierungszertifikat`). For "Read and Sign" envelope type (`EnvelopeTypeId = 2`), the history event showed "Document signed" instead of "Read and confirmed" / "Gelesen und bestätigt".
**Branch:** `fix/report-label-read-and-confirmed` — **COMPLETED**
**When you need it:** If a similar report label regression appears, or when adding new envelope type-specific label logic to the certificate report.
#### `fix-signature-field-formatting.md`
**What:** Documents a signature stamp layout fix triggered by WISAG customer feedback (email 3/7/2026). The finalized PDF's signature stamp had incorrect spacing: certificate hash too close to name, position text wrapping, no spacing before date. Fix was applied in the GdPicture14 PDF burning pipeline — not in the Blazor UI.
**When you need it:** If signature stamp formatting regresses, or when modifying the PDF stamping/burning pipeline in the API or infrastructure layer.
---
### Service Migration Docs
#### `EnvelopeGenerator.Service/MIGRATION_PLAN.md`
**What:** Migration plan (v3) for porting `EnvelopeGenerator.Service_legacy` (VB.NET Windows Service) to `EnvelopeGenerator.Service` (C# Worker Service, .NET Framework 4.6.2). Documents connection string handling, job scheduling (Quartz → IHostedService), and deep analysis of legacy code.
**Why it exists:** The Windows Service handles background jobs (email dispatch, envelope finalization). This plan ensures the port is complete and no job logic is silently dropped.
**When you need it:**
- Modifying background jobs (email sending, PDF finalization, access code dispatch)
- Debugging service startup or job execution issues
- Checking if a specific legacy VB.NET job has been ported
---
## Active Architecture (Post-Migration)
**Frontend:** Blazor Auto (Server+WASM hybrid)

View File

@@ -1,17 +0,0 @@
namespace EnvelopeGenerator.API;
/// <summary>
///
/// </summary>
public static class AuthScheme
{
/// <summary>
/// Scheme name used for per-envelope receiver JWT authentication.
/// </summary>
public const string Receiver = "EnvelopeGenerator.API.ReceiverJWT";
/// <summary>
/// Scheme name used for per-envelope sender JWT authentication.
/// </summary>
public const string Sender = "EnvelopeGenerator.API.SenderJWT";
}

View File

@@ -1,132 +0,0 @@
using DigitalData.Core.Abstraction.Application.DTO;
using DigitalData.Core.Exceptions;
using EnvelopeGenerator.API.Extensions;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Extensions;
using EnvelopeGenerator.Application.Common.Interfaces.Services;
using EnvelopeGenerator.Application.Common.Notifications.DocSigned;
using EnvelopeGenerator.Application.Common.Notifications.RemoveSignature;
using EnvelopeGenerator.Application.EnvelopeReceivers.Queries;
using EnvelopeGenerator.Application.Histories.Queries;
using EnvelopeGenerator.Domain.Constants;
using MediatR;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace EnvelopeGenerator.API.Controllers;
/// <summary>
/// Manages annotations and signature lifecycle for envelopes.
/// </summary>
[Authorize(Policy = AuthPolicy.Receiver)]
[ApiController]
[Route("api/[controller]")]
public class AnnotationController : ControllerBase
{
[Obsolete("Use MediatR")]
private readonly IEnvelopeHistoryService _historyService;
[Obsolete("Use MediatR")]
private readonly IEnvelopeReceiverService _envelopeReceiverService;
private readonly IMediator _mediator;
private readonly ILogger<AnnotationController> _logger;
/// <summary>
/// Initializes a new instance of <see cref="AnnotationController"/>.
/// </summary>
[Obsolete("Use MediatR")]
public AnnotationController(
ILogger<AnnotationController> logger,
IEnvelopeHistoryService envelopeHistoryService,
IEnvelopeReceiverService envelopeReceiverService,
IMediator mediator)
{
_historyService = envelopeHistoryService;
_envelopeReceiverService = envelopeReceiverService;
_mediator = mediator;
_logger = logger;
}
/// <summary>
/// Creates or updates annotations for the authenticated envelope receiver.
/// </summary>
/// <param name="psPdfKitAnnotation">Annotation payload.</param>
/// <param name="cancel">Cancellation token.</param>
[Authorize(Policy = AuthPolicy.Receiver)]
[HttpPost]
[Obsolete("PSPDF Kit will no longer be used.")]
public async Task<IActionResult> CreateOrUpdate([FromBody] PsPdfKitAnnotation? psPdfKitAnnotation = null, CancellationToken cancel = default)
{
var signature = User.ReceiverSignature();
var uuid = User.EnvelopeUuid();
var envelopeReceiver = await _mediator.ReadEnvelopeReceiverAsync(uuid, signature, cancel).ThrowIfNull(Exceptions.NotFound);
if (!envelopeReceiver.Envelope!.ReadOnly && psPdfKitAnnotation is null)
return BadRequest();
if (await _mediator.IsSignedAsync(uuid, signature, cancel))
return Problem(statusCode: StatusCodes.Status409Conflict);
else if (await _mediator.AnyHistoryAsync(uuid, new[] { EnvelopeStatus.EnvelopeRejected, EnvelopeStatus.DocumentRejected }, cancel))
return Problem(statusCode: StatusCodes.Status423Locked);
var envelopeReceiverDto = await _mediator.ReadEnvelopeReceiverAsync(uuid, signature, cancel);
var docSignedNotification = envelopeReceiverDto is not null
? new DocSignedNotification { EnvelopeReceiver = envelopeReceiverDto, PsPdfKitAnnotation = psPdfKitAnnotation }
: throw new NotFoundException("Envelope receiver is not found.");
try
{
await _mediator.Publish(docSignedNotification, cancel);
}
catch (Exception)
{
await _mediator.Publish(new RemoveSignatureNotification()
{
EnvelopeId = docSignedNotification.EnvelopeReceiver.EnvelopeId,
ReceiverId = docSignedNotification.EnvelopeReceiver.ReceiverId
}, cancel);
throw;
}
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return Ok();
}
/// <summary>
/// Rejects the document for the current receiver.
/// </summary>
/// <param name="reason">Optional rejection reason.</param>
[Authorize(Policy = AuthPolicy.Receiver)]
[HttpPost("reject")]
[Obsolete("Use MediatR")]
public async Task<IActionResult> Reject([FromBody] string? reason = null)
{
var signature = User.ReceiverSignature();
var uuid = User.EnvelopeUuid();
var mail = User.ReceiverMail();
var envRcvRes = await _envelopeReceiverService.ReadByUuidSignatureAsync(uuid: uuid, signature: signature);
if (envRcvRes.IsFailed)
{
_logger.LogNotice(envRcvRes.Notices);
return Unauthorized("you are not authorized");
}
var histRes = await _historyService.RecordAsync(envRcvRes.Data.EnvelopeId, userReference: mail, EnvelopeStatus.DocumentRejected, comment: reason);
if (histRes.IsSuccess)
{
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return NoContent();
}
_logger.LogEnvelopeError(uuid: uuid, signature: signature, message: "Unexpected error happened in api/envelope/reject");
_logger.LogNotice(histRes.Notices);
return StatusCode(500, histRes.Messages);
}
}

View File

@@ -1,117 +0,0 @@
using DigitalData.Auth.Claims;
using EnvelopeGenerator.API.Controllers.Interfaces;
using EnvelopeGenerator.API.Models;
using EnvelopeGenerator.Domain.Constants;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
namespace EnvelopeGenerator.API.Controllers;
/// <summary>
/// Controller verantwortlich für die Benutzer-Authentifizierung, einschließlich Anmelden, Abmelden und Überprüfung des Authentifizierungsstatus.
/// </summary>
[Route("api/[controller]")]
[ApiController]
public partial class AuthController(IOptions<AuthTokenKeys> authTokenKeyOptions, IAuthorizationService authService) : ControllerBase, IAuthController
{
private readonly AuthTokenKeys authTokenKeys = authTokenKeyOptions.Value;
/// <summary>
///
/// </summary>
public IAuthorizationService AuthService { get; } = authService;
/// <summary>
/// Entfernt das Authentifizierungs-Cookie des Benutzers (AuthCookie)
/// </summary>
/// <returns>
/// Gibt eine HTTP 200 oder 401.
/// </returns>
/// <remarks>
/// Sample request:
///
/// POST /api/auth/logout
///
/// </remarks>
/// <response code="200">Erfolgreich gelöscht, wenn der Benutzer ein berechtigtes Cookie hat.</response>
/// <response code="401">Wenn es kein zugelassenes Cookie gibt, wird „nicht zugelassen“ zurückgegeben.</response>
[ProducesResponseType(typeof(void), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(void), StatusCodes.Status401Unauthorized)]
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
[HttpPost("logout")]
public async Task<IActionResult> Logout()
{
if (await this.IsUserInPolicyAsync(AuthPolicy.Sender))
Response.Cookies.Delete(authTokenKeys.Cookie);
else if (await this.IsUserInPolicyAsync(AuthPolicy.ReceiverOrReceiverTFA))
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
else
return Unauthorized();
return Ok();
}
/// <summary>
/// Prüft, ob der Benutzer ein autorisiertes Token hat.
/// </summary>
/// <returns>Wenn ein autorisiertes Token vorhanden ist HTTP 200 asynchron 401</returns>
/// <remarks>
/// Sample request:
///
/// GET /api/auth
///
/// </remarks>
/// <response code="200">Wenn es einen autorisierten Cookie gibt.</response>
/// <response code="401">Wenn kein Cookie vorhanden ist oder nicht autorisierte.</response>
[ProducesResponseType(typeof(void), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(void), StatusCodes.Status401Unauthorized)]
[HttpGet("check")]
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
public IActionResult Check(string? role = null)
=> role is not null && !User.IsInRole(role)
? Unauthorized()
: Ok();
/// <summary>
/// Checks whether the caller holds a valid per-envelope receiver token for the given envelope key.
/// The request must carry a cookie named <c>AuthTokenSignFLOWReceiver.{envelopeKey}</c>.
/// </summary>
/// <param name="envelopeKey">The unique envelope key extracted from the route.</param>
/// <response code="200">Valid per-envelope token found.</response>
/// <response code="401">Token is missing, expired or invalid.</response>
[ProducesResponseType(typeof(void), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(void), StatusCodes.Status401Unauthorized)]
[Authorize(Policy = AuthPolicy.Receiver)]
[HttpGet("check/envelope/{envelopeKey}")]
public IActionResult CheckEnvelopeReceiver([FromRoute] string envelopeKey) => Ok();
/// <summary>
/// Removes the per-envelope receiver cookie for the given envelope key.
/// </summary>
/// <param name="envelopeKey">The unique envelope key whose cookie should be deleted.</param>
/// <response code="200">Cookie successfully deleted.</response>
[ProducesResponseType(typeof(void), StatusCodes.Status200OK)]
[HttpPost("logout/envelope/{envelopeKey}")]
public IActionResult LogoutEnvelopeReceiver([FromRoute] string envelopeKey)
{
var cookieName = CookieNames.GetEnvelopeReceiverCookieName(authTokenKeys.Cookie, envelopeKey);
Response.Cookies.Delete(cookieName);
return Ok();
}
/// <summary>
/// Removes all per-envelope receiver cookies from the current request.
/// </summary>
/// <response code="200">All envelope receiver cookies successfully deleted.</response>
[ProducesResponseType(typeof(void), StatusCodes.Status200OK)]
[HttpPost("logout/envelope")]
public IActionResult LogoutAllEnvelopeReceivers()
{
foreach (var cookieName in Request.Cookies.Keys.Where(k => CookieNames.IsEnvelopeReceiverCookie(k, authTokenKeys.Cookie)))
Response.Cookies.Delete(cookieName);
return Ok();
}
}

View File

@@ -1,84 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Options;
using System.Text.Json;
using EnvelopeGenerator.API.Options;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.API.Extensions;
namespace EnvelopeGenerator.API.Controllers;
/// <summary>
/// Manages cached data for receivers using distributed cache.
/// </summary>
[ApiController]
[Route("api/[controller]")]
[Authorize(Policy = AuthPolicy.Receiver)]
public class CacheController(
IDistributedCache cache,
IOptions<CacheOptions> cacheOptions) : ControllerBase
{
private const string SignatureCacheKeyPrefix = "envelope-generator.receiver-ui.signature:";
/// <summary>
/// Stores a receiver's signature in cache for the specified envelope.
/// </summary>
[Authorize(Policy = AuthPolicy.Receiver)]
[HttpPost("SignatureCapture/{envelopeKey}")]
public async Task<IActionResult> SaveSignature(
[FromRoute] string envelopeKey,
[FromBody] SignatureCacheRequest request,
CancellationToken cancel)
{
var cacheKey = $"{SignatureCacheKeyPrefix}{User.ReceiverSignature()}";
var json = JsonSerializer.Serialize(request);
var options = cacheOptions.Value.SignatureCacheExpiration.HasValue
? new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = cacheOptions.Value.SignatureCacheExpiration.Value }
: null;
await cache.SetStringAsync(cacheKey, json, options ?? new DistributedCacheEntryOptions(), cancel);
return Ok();
}
/// <summary>
/// Retrieves a cached signature for the specified envelope.
/// </summary>
[Authorize(Policy = AuthPolicy.Receiver)]
[HttpGet("SignatureCapture/{envelopeKey}")]
public async Task<IActionResult> GetSignature([FromRoute] string envelopeKey, CancellationToken cancel)
{
var cacheKey = $"{SignatureCacheKeyPrefix}{User.ReceiverSignature()}";
var json = await cache.GetStringAsync(cacheKey, cancel);
if (json is null)
return NotFound();
var signature = JsonSerializer.Deserialize<SignatureCacheRequest>(json);
return Ok(signature);
}
/// <summary>
/// Deletes a cached signature for the specified envelope.
/// </summary>
[Authorize(Policy = AuthPolicy.Receiver)]
[HttpDelete("SignatureCapture/{envelopeKey}")]
public async Task<IActionResult> DeleteSignature([FromRoute] string envelopeKey, CancellationToken cancel)
{
var cacheKey = $"{SignatureCacheKeyPrefix}{User.ReceiverSignature()}";
await cache.RemoveAsync(cacheKey, cancel);
return Ok();
}
}
/// <summary>
/// Request model for caching signature data.
/// </summary>
public sealed record SignatureCacheRequest(
string DataUrl,
string FullName,
string Place,
string? Position = null);

View File

@@ -1,31 +0,0 @@
using EnvelopeGenerator.API.Models.PsPdfKitAnnotation;
using EnvelopeGenerator.Domain.Constants;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
namespace EnvelopeGenerator.API.Controllers;
/// <summary>
/// Exposes configuration data required by the client applications.
/// </summary>
/// <remarks>
/// Initializes a new instance of <see cref="ConfigController"/>.
/// </remarks>
[Route("api/[controller]")]
[ApiController]
[Authorize(Policy = AuthPolicy.SenderOrReceiver)]
public class ConfigController(IOptionsMonitor<AnnotationParams> annotationParamsOptions) : ControllerBase
{
private readonly AnnotationParams _annotationParams = annotationParamsOptions.CurrentValue;
/// <summary>
/// Returns annotation configuration that was previously rendered by MVC.
/// </summary>
[HttpGet("Annotations")]
[Obsolete("PSPDF Kit will no longer be used.")]
public IActionResult GetAnnotationParams()
{
return Ok(_annotationParams.AnnotationJSObject);
}
}

View File

@@ -1,57 +0,0 @@
using EnvelopeGenerator.API.Extensions;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Extensions;
using EnvelopeGenerator.Application.Documents.Queries;
using EnvelopeGenerator.Domain.Constants;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace EnvelopeGenerator.API.Controllers;
/// <summary>
///
/// </summary>
[Authorize(Policy = AuthPolicy.Receiver)]
[ApiController]
[Route("api/[controller]")]
public class DocReceiverElementController : ControllerBase
{
private readonly IMediator _mediator;
/// <summary>
/// Initializes a new instance of <see cref="DocReceiverElementController"/>.
/// </summary>
public DocReceiverElementController(IMediator mediator)
{
_mediator = mediator;
}
//TODO: update to use signature query
/// <summary>
///
/// </summary>
/// <param name="envelopeKey"></param>
/// <param name="cancel"></param>
/// <returns></returns>
[Authorize(Policy = AuthPolicy.Receiver)]
[HttpGet("{envelopeKey}")]
public async Task<IActionResult> Get(string envelopeKey, CancellationToken cancel)
{
int envelopeId = User.EnvelopeId();
int receiverId = User.ReceiverId();
var doc = await _mediator.Send(new ReadDocumentQuery() { EnvelopeId = envelopeId }, cancel);
if (doc.Elements is not IEnumerable<DocReceiverElementDto> docSignatures)
return NotFound("Document is empty.");
var rcvSignatures = docSignatures.Where(s => s.ReceiverId == receiverId).ToList();
if (rcvSignatures is null)
return NotFound("No signatures found for the current receiver.");
else
return Ok(rcvSignatures);
}
}

View File

@@ -1,84 +0,0 @@
using DigitalData.Auth.Claims;
using EnvelopeGenerator.API.Controllers.Interfaces;
using EnvelopeGenerator.API.Extensions;
using EnvelopeGenerator.Application.Documents.Queries;
using EnvelopeGenerator.Domain.Constants;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace EnvelopeGenerator.API.Controllers;
/// <summary>
/// Provides access to envelope documents for authenticated receivers.
/// </summary>
/// <remarks>
/// Initializes a new instance of the <see cref="DocumentController"/> class.
/// </remarks>
[ApiController]
[Route("api/[controller]")]
public class DocumentController(IMediator mediator, IAuthorizationService authService, ILogger<DocumentController> logger) : ControllerBase, IAuthController
{
/// <summary>
///
/// </summary>
public IAuthorizationService AuthService => authService;
/// <summary>
/// Returns the document bytes receiver.
/// </summary>
/// <param name="query">Encoded envelope key.</param>
/// <param name="cancel">Cancellation token.</param>
[HttpGet]
[Authorize(Policy = AuthPolicy.SenderOrReceiver)]
public async Task<IActionResult> GetDocument(CancellationToken cancel, [FromQuery] ReadDocumentQuery? query = null)
{
// Sender: expects query with envelope key
if (await this.IsUserInPolicyAsync(AuthPolicy.Sender))
{
if (query is null)
return BadRequest("Missing document query.");
var senderDoc = await mediator.Send(query, cancel);
return senderDoc.ByteData is byte[] senderDocByte
? File(senderDocByte, "application/octet-stream")
: NotFound("Document is empty.");
}
// Receiver: resolve envelope id from claims
if (await this.IsUserInPolicyAsync(AuthPolicy.Receiver))
{
if (query is not null)
return BadRequest("Query parameters are not allowed for receiver role.");
var envelopeId = User.EnvelopeId();
var receiverDoc = await mediator.Send(new ReadDocumentQuery { EnvelopeId = envelopeId }, cancel);
return receiverDoc.ByteData is byte[] receiverDocByte
? File(receiverDocByte, "application/octet-stream")
: NotFound("Document is empty.");
}
return Unauthorized();
}
/// <summary>
/// Gets the document for the specified envelope key.
/// </summary>
/// <param name="envelopeKey"></param>
/// <param name="cancel"></param>
/// <returns></returns>
[Authorize(Policy = AuthPolicy.Receiver)]
[HttpGet("{envelopeKey}")]
public async Task<IActionResult> GetDocumentOfReceiver(string envelopeKey, CancellationToken cancel)
{
int envelopeId = User.EnvelopeId();
var senderDoc = await mediator.Send(new ReadDocumentQuery() { EnvelopeId = envelopeId }, cancel);
if (senderDoc.ByteData is not byte[] senderDocByte)
return NotFound("Document is empty.");
Response.Headers.ContentDisposition = $"inline; filename=\"{envelopeKey}.pdf\"";
return File(senderDocByte, "application/pdf");
}
}

View File

@@ -1,69 +0,0 @@
using AutoMapper;
using EnvelopeGenerator.Application.EmailTemplates.Commands;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MediatR;
using EnvelopeGenerator.Application.Common.Dto;
using DigitalData.Core.Abstraction.Application.Repository;
using EnvelopeGenerator.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Application.EmailTemplates.Queries;
namespace EnvelopeGenerator.API.Controllers;
/// <summary>
/// Controller for managing temp templates.
/// Steuerung zur Verwaltung von E-Mail-Vorlagen.
/// </summary>
/// <remarks>
/// Initialisiert eine neue Instanz der <see cref="EmailTemplateController"/>-Klasse.
/// </remarks>
/// <param name="mediator">
/// Die Mediator-Instanz, die zum Senden von Befehlen und Abfragen verwendet wird.
/// </param>
[Route("api/[controller]")]
[ApiController]
[Authorize(Policy = AuthPolicy.Sender)]
public class EmailTemplateController(IMediator mediator) : ControllerBase
{
/// <summary>
/// Ruft E-Mail-Vorlagen basierend auf der angegebenen Abfrage ab.
/// Gibt alles zurück, wenn keine Id- oder Typ-Informationen eingegeben wurden.
/// </summary>
/// <param name="emailTemplate">Die Abfrageparameter zum Abrufen von E-Mail-Vorlagen.</param>
/// <param name="cancel"></param>
/// <returns>Gibt HTTP-Antwort zurück</returns>
/// <remarks>
/// Sample request:
/// GET /api/EmailTemplate?emailTemplateId=123
/// </remarks>
/// <response code="200">Wenn die E-Mail-Vorlagen erfolgreich abgerufen werden.</response>
/// <response code="400">Wenn die Abfrageparameter ungültig sind.</response>
/// <response code="401">Wenn der Benutzer nicht authentifiziert ist.</response>
/// <response code="404">Wenn die gesuchte Abfrage nicht gefunden wird.</response>
[HttpGet]
public async Task<IActionResult> Get([FromQuery] ReadEmailTemplateQuery emailTemplate, CancellationToken cancel)
{
var result = await mediator.Send(emailTemplate, cancel);
return Ok(result);
}
/// <summary>
/// Updates an temp template or resets it if no update command is provided.
/// Aktualisiert eine E-Mail-Vorlage oder setzt sie zurück, wenn kein Aktualisierungsbefehl angegeben ist.
/// </summary>
/// <param name="update"></param>
/// <param name="cancel"></param>
/// <returns></returns>
/// <response code="200">Wenn die E-Mail-Vorlage erfolgreich aktualisiert oder zurückgesetzt wird.</response>
/// <response code="400">Wenn die Abfrage ohne einen String gesendet wird.</response>
/// <response code="401">Wenn der Benutzer nicht authentifiziert ist.</response>
/// <response code="404">Wenn die gesuchte Abfrage nicht gefunden wird.</response>
[HttpPut]
public async Task<IActionResult> Update([FromBody] UpdateEmailTemplateCommand update, CancellationToken cancel)
{
await mediator.Send(update, cancel);
return Ok();
}
}

View File

@@ -1,111 +0,0 @@
using EnvelopeGenerator.API.Extensions;
using EnvelopeGenerator.Application.Envelopes.Commands;
using EnvelopeGenerator.Application.Envelopes.Queries;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
namespace EnvelopeGenerator.API.Controllers;
/// <summary>
/// Dieser Controller stellt Endpunkte für die Verwaltung von Umschlägen bereit.
/// </summary>
/// <remarks>
/// Die API ermöglicht das Abrufen und Verwalten von Umschlägen basierend auf Benutzerinformationen und Statusfiltern.
///
/// Mögliche Antworten:
/// - 200 OK: Die Anfrage war erfolgreich, und die angeforderten Daten werden zurückgegeben.
/// - 400 Bad Request: Die Anfrage war fehlerhaft oder unvollständig.
/// - 401 Unauthorized: Der Benutzer ist nicht authentifiziert.
/// - 403 Forbidden: Der Benutzer hat keine Berechtigung, auf die Ressource zuzugreifen.
/// - 404 Not Found: Die angeforderte Ressource wurde nicht gefunden.
/// - 500 Internal Server Error: Ein unerwarteter Fehler ist aufgetreten.
/// </remarks>
[Route("api/[controller]")]
[ApiController]
[Authorize]
public class EnvelopeController : ControllerBase
{
private readonly ILogger<EnvelopeController> _logger;
private readonly IMediator _mediator;
/// <summary>
/// Erstellt eine neue Instanz des EnvelopeControllers.
/// </summary>
/// <param name="logger">Der Logger, der für das Protokollieren von Informationen verwendet wird.</param>
/// <param name="mediator"></param>
public EnvelopeController(ILogger<EnvelopeController> logger, IMediator mediator)
{
_logger = logger;
_mediator = mediator;
}
/// <summary>
/// Ruft eine Liste von Umschlägen basierend auf dem Benutzer und den angegebenen Statusfiltern ab.
/// </summary>
/// <param name="envelope"></param>
/// <returns>Eine IActionResult-Instanz, die die abgerufenen Umschläge oder einen Fehlerstatus enthält.</returns>
/// <response code="200">Die Anfrage war erfolgreich, und die Umschläge werden zurückgegeben.</response>
/// <response code="400">Die Anfrage war fehlerhaft oder unvollständig.</response>
/// <response code="401">Der Benutzer ist nicht authentifiziert.</response>
/// <response code="403">Der Benutzer hat keine Berechtigung, auf die Ressource zuzugreifen.</response>
/// <response code="500">Ein unerwarteter Fehler ist aufgetreten.</response>
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
[HttpGet]
public async Task<IActionResult> GetAsync([FromQuery] ReadEnvelopeQuery envelope)
{
var result = await _mediator.Send(envelope.Authorize(User.GetId()));
return result.Any() ? Ok(result) : NotFound();
}
/// <summary>
/// Ruft das Ergebnis eines Dokuments basierend auf der ID ab.
/// </summary>
/// <param name="query"></param>
/// <param name="view">Gibt an, ob das Dokument inline angezeigt werden soll (true) oder als Download bereitgestellt wird (false).</param>
/// <returns>Eine IActionResult-Instanz, die das Dokument oder einen Fehlerstatus enthält.</returns>
/// <response code="200">Das Dokument wurde erfolgreich abgerufen.</response>
/// <response code="404">Das Dokument wurde nicht gefunden oder ist nicht verfügbar.</response>
/// <response code="500">Ein unerwarteter Fehler ist aufgetreten.</response>
[HttpGet("doc-result")]
public async Task<IActionResult> GetDocResultAsync([FromQuery] ReadEnvelopeQuery query, [FromQuery] bool view = false)
{
var envelopes = await _mediator.Send(query.Authorize(User.GetId()));
var envelope = envelopes.FirstOrDefault();
if (envelope is null)
return NotFound("Envelope not available.");
if (envelope.DocResult is null)
return NotFound("The document has not been fully signed or the result has not yet been released.");
if (view)
{
Response.Headers.Append("Content-Disposition", "inline; filename=\"" + envelope.Uuid + ".pdf\"");
return File(envelope.DocResult, "application/pdf");
}
return File(envelope.DocResult, "application/pdf", $"{envelope.Uuid}.pdf");
}
/// <summary>
///
/// </summary>
/// <param name="command"></param>
/// <returns></returns>
[NonAction]
[Authorize]
[HttpPost]
public async Task<IActionResult> CreateAsync([FromBody] CreateEnvelopeCommand command)
{
var res = await _mediator.Send(command.WithAuth(User.GetId()));
if (res is null)
{
_logger.LogError("Failed to create envelope. Envelope details: {EnvelopeDetails}", JsonConvert.SerializeObject(command));
return StatusCode(StatusCodes.Status500InternalServerError);
}
else
return Ok(res);
}
}

View File

@@ -1,281 +0,0 @@
using AutoMapper;
using EnvelopeGenerator.Application.EnvelopeReceivers.Commands;
using EnvelopeGenerator.Application.EnvelopeReceivers.Queries;
using EnvelopeGenerator.Application.Envelopes.Queries;
using EnvelopeGenerator.Domain.Entities;
using EnvelopeGenerator.API.Models;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Options;
using System.Data;
using EnvelopeGenerator.Application.Common.SQL;
using EnvelopeGenerator.Application.Common.Dto.Receiver;
using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor;
using EnvelopeGenerator.API.Extensions;
using EnvelopeGenerator.Domain.Constants;
namespace EnvelopeGenerator.API.Controllers;
/// <summary>
/// Controller für die Verwaltung von Umschlagempfängern.
/// </summary>
/// <remarks>
/// Dieser Controller bietet Endpunkte für das Abrufen und Verwalten von Umschlagempfängerdaten.
/// </remarks>
[Route("api/[controller]")]
[Authorize]
[ApiController]
public class EnvelopeReceiverController : ControllerBase
{
private readonly ILogger<EnvelopeReceiverController> _logger;
private readonly IMediator _mediator;
private readonly IMapper _mapper;
private readonly IEnvelopeExecutor _envelopeExecutor;
private readonly IEnvelopeReceiverExecutor _erExecutor;
private readonly IDocumentExecutor _documentExecutor;
private readonly string _cnnStr;
/// <summary>
/// Konstruktor für den EnvelopeReceiverController.
/// </summary>
public EnvelopeReceiverController(ILogger<EnvelopeReceiverController> logger, IMediator mediator, IMapper mapper, IEnvelopeExecutor envelopeExecutor, IEnvelopeReceiverExecutor erExecutor, IDocumentExecutor documentExecutor, IOptions<ConnectionString> csOpt)
{
_logger = logger;
_mediator = mediator;
_mapper = mapper;
_envelopeExecutor = envelopeExecutor;
_erExecutor = erExecutor;
_documentExecutor = documentExecutor;
_cnnStr = csOpt.Value.Value;
}
/// <summary>
/// Ruft eine Liste von Umschlagempfängern basierend auf den angegebenen Abfrageparametern ab.
/// </summary>
/// <param name="envelopeReceiver">Die Abfrageparameter für die Filterung von Umschlagempfängern.</param>
/// <returns>Eine HTTP-Antwort mit der Liste der gefundenen Umschlagempfänger oder einem Fehlerstatus.</returns>
/// <remarks>
/// Dieser Endpunkt ermöglicht es, Umschlagempfänger basierend auf dem Benutzernamen und optionalen Statusfiltern abzurufen.
/// Wenn der Benutzername nicht ermittelt werden kann, wird ein Serverfehler zurückgegeben.
/// </remarks>
/// <response code="200">Die Liste der Umschlagempfänger wurde erfolgreich abgerufen.</response>
/// <response code="401">Wenn kein autorisierter Token vorhanden ist</response>
/// <response code="500">Ein unerwarteter Fehler ist aufgetreten.</response>
[Authorize]
[HttpGet]
public async Task<IActionResult> GetEnvelopeReceiver([FromQuery] ReadEnvelopeReceiverQuery envelopeReceiver)
{
envelopeReceiver = envelopeReceiver with { Username = User.GetUsername() };
var result = await _mediator.Send(envelopeReceiver);
return Ok(result);
}
/// <summary>
///
/// </summary>
/// <param name="envelopeKey"></param>
/// <param name="cancel"></param>
/// <returns></returns>
[Authorize(Policy = AuthPolicy.Receiver)]
[HttpGet("{envelopeKey}")]
public async Task<IActionResult> GetEnvelopeReceiverOfReceiver([FromRoute] string envelopeKey, CancellationToken cancel)
{
var er = await _mediator.Send(new ReadEnvelopeReceiverQuery()
{
Key = envelopeKey
}, cancel);
return Ok(er.SingleOrDefault());
}
/// <summary>
/// Ruft den Namen des zuletzt verwendeten Empfängers basierend auf der angegebenen E-Mail-Adresse ab.
/// </summary>
/// <param name="receiver">Abfrage, bei der nur eine der Angaben ID, Signatur oder E-Mail-Adresse des Empfängers eingegeben werden muss.</param>
/// <returns>Eine HTTP-Antwort mit dem Namen des Empfängers oder einem Fehlerstatus.</returns>
/// <remarks>
/// Dieser Endpunkt ermöglicht es, den Namen des zuletzt verwendeten Empfängers basierend auf der E-Mail-Adresse abzurufen.
/// </remarks>
/// <response code="200">Der Name des Empfängers wurde erfolgreich abgerufen.</response>
/// <response code="401">Wenn kein autorisierter Token vorhanden ist</response>
/// <response code="404">Kein Empfänger gefunden.</response>
/// <response code="500">Ein unerwarteter Fehler ist aufgetreten.</response>
[Authorize]
[HttpGet("salute")]
public async Task<IActionResult> GetReceiverName([FromQuery] ReadReceiverNameQuery receiver)
{
var name = await _mediator.Send(receiver);
return name is null ? NotFound() : Ok(name);
}
/// <summary>
/// Datenübertragungsobjekt mit Informationen zu Umschlägen, Empfängern und Unterschriften.
/// </summary>
/// <param name="request"></param>
/// <param name="cancel"></param>
/// <returns>HTTP-Antwort</returns>
/// <remarks>
/// Sample request:
///
/// POST /api/envelope
/// {
/// "title": "Vertragsdokument",
/// "message": "Bitte unterschreiben Sie dieses Dokument.",
/// "document": {
/// "dataAsBase64": "dGVzdC1iYXNlNjQtZGF0YQ=="
/// },
/// "receivers": [
/// {
/// "emailAddress": "example@example.com",
/// "signatures": [
/// {
/// "x": 100,
/// "y": 200,
/// "page": 1
/// }
/// ],
/// "name": "Max Mustermann",
/// "phoneNumber": "+49123456789"
/// }
/// ],
/// "tfaEnabled": false
/// }
///
/// </remarks>
/// <response code="202">Envelope-Erstellung und Sendeprozessbefehl erfolgreich</response>
/// <response code="400">Wenn ein Fehler im HTTP-Body auftritt</response>
/// <response code="401">Wenn kein autorisierter Token vorhanden ist</response>
/// <response code="500">Es handelt sich um einen unerwarteten Fehler. Die Protokolle sollten überprüft werden.</response>
[Authorize]
[HttpPost]
public async Task<IActionResult> CreateAsync([FromBody] CreateEnvelopeReceiverCommand request, CancellationToken cancel)
{
#region Create Envelope
var envelope = await _envelopeExecutor.CreateEnvelopeAsync(User.GetId(), request.Title, request.Message, request.TFAEnabled, cancel);
#endregion
#region Add receivers
List<EnvelopeReceiver> sentReceivers = new();
List<ReceiverGetOrCreateCommand> unsentReceivers = new();
foreach (var receiver in request.Receivers)
{
var envelopeReceiver = await _erExecutor.AddEnvelopeReceiverAsync(envelope.Uuid, receiver.EmailAddress, receiver.Salution, receiver.PhoneNumber, cancel);
if (envelopeReceiver is null)
unsentReceivers.Add(receiver);
else
sentReceivers.Add(envelopeReceiver);
}
var res = _mapper.Map<CreateEnvelopeReceiverResponse>(envelope);
res.UnsentReceivers = unsentReceivers;
res.SentReceiver = _mapper.Map<List<ReceiverDto>>(sentReceivers.Select(er => er.Receiver));
#endregion
#region Add document
var document = await _documentExecutor.CreateDocumentAsync(request.Document.DataAsBase64, envelope.Uuid, cancel);
if (document is null)
return StatusCode(StatusCodes.Status500InternalServerError, "Document creation is failed.");
#endregion
#region Add document element
// @DOC_ID, @RECEIVER_ID, @POSITION_X, @POSITION_Y, @PAGE
string sql = @"
DECLARE @OUT_SUCCESS bit;
EXEC [dbo].[PRSIG_API_ADD_DOC_RECEIVER_ELEM]
{0},
{1},
{2},
{3},
{4},
@OUT_SUCCESS OUTPUT;
SELECT @OUT_SUCCESS as [@OUT_SUCCESS];";
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>())
{
using SqlConnection conn = new(_cnnStr);
conn.Open();
var formattedSQL = string.Format(sql, document.Id.ToSqlParam(), rcv.Id.ToSqlParam(), sign.X.ToSqlParam(), sign.Y.ToSqlParam(), sign.Page.ToSqlParam());
using SqlCommand cmd = new(formattedSQL, conn);
cmd.CommandType = CommandType.Text;
using SqlDataReader reader = cmd.ExecuteReader();
if (reader.Read())
{
bool outSuccess = reader.GetBoolean(0);
if (!outSuccess)
_logger.LogWarning(
"PRSIG_API_ADD_DOC_RECEIVER_ELEM returned OUT_SUCCESS=false. DOC_ID={DocId}, RECEIVER_ID={ReceiverId}, Page={Page}",
document.Id, rcv.Id, sign.Page);
}
}
#endregion
#region Create history
// ENV_UID, STATUS_ID, USER_ID,
string sql_hist = @"
DECLARE @OUT_SUCCESS bit;
EXEC [dbo].[PRSIG_API_ADD_HISTORY_STATE]
{0},
{1},
{2},
@OUT_SUCCESS OUTPUT;
SELECT @OUT_SUCCESS as [@OUT_SUCCESS];";
using (SqlConnection conn = new(_cnnStr))
{
conn.Open();
var formattedSQL_hist = string.Format(sql_hist, envelope.Uuid.ToSqlParam(), 1003.ToSqlParam(), User.GetId().ToSqlParam());
using SqlCommand cmd = new(formattedSQL_hist, conn);
cmd.CommandType = CommandType.Text;
using SqlDataReader reader = cmd.ExecuteReader();
if (reader.Read())
{
bool outSuccess = reader.GetBoolean(0);
if (!outSuccess)
_logger.LogWarning(
"PRSIG_API_ADD_HISTORY_STATE returned OUT_SUCCESS=false. EnvelopeUuid={EnvelopeUuid}",
envelope.Uuid);
}
}
#endregion
return Ok(res);
}
/// <summary>
///
/// </summary>
/// <param name="input"></param>
/// <returns></returns>
public static bool IsBase64String(string input)
{
if (string.IsNullOrWhiteSpace(input))
return false;
try
{
Convert.FromBase64String(input);
return true;
}
catch (FormatException)
{
return false;
}
}
}

View File

@@ -1,39 +0,0 @@
using MediatR;
using EnvelopeGenerator.Application.EnvelopeTypes.Queries;
using Microsoft.AspNetCore.Mvc;
namespace EnvelopeGenerator.GeneratorAPI.Controllers;
/// <summary>
///
/// </summary>
[ApiExplorerSettings(IgnoreApi = true)]
[Route("api/[controller]")]
[ApiController]
public class EnvelopeTypeController : ControllerBase
{
private readonly ILogger<EnvelopeTypeController> _logger;
private readonly IMediator _mediator;
/// <summary>
///
/// </summary>
/// <param name="logger"></param>
/// <param name="mediator"></param>
public EnvelopeTypeController(ILogger<EnvelopeTypeController> logger, IMediator mediator)
{
_logger = logger;
_mediator = mediator;
}
/// <summary>
///
/// </summary>
/// <returns></returns>
[HttpGet]
public async Task<IActionResult> GetAllAsync()
{
var result = await _mediator.Send(new ReadEnvelopeTypesQuery());
return Ok(result);
}
}

View File

@@ -1,118 +0,0 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using EnvelopeGenerator.Application.Histories.Queries;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Application.Common.Extensions;
namespace EnvelopeGenerator.API.Controllers;
/// <summary>
/// Dieser Controller stellt Endpunkte für den Zugriff auf die Umschlaghistorie bereit.
/// </summary>
[Route("api/[controller]")]
[ApiController]
[Authorize]
public class HistoryController : ControllerBase
{
private readonly IMemoryCache _memoryCache;
private readonly IMediator _mediator;
/// <summary>
/// Konstruktor für den HistoryController.
/// </summary>
/// <param name="memoryCache"></param>
/// <param name="mediator"></param>
public HistoryController(IMemoryCache memoryCache, IMediator mediator)
{
_memoryCache = memoryCache;
_mediator = mediator;
}
/// <summary>
/// Gibt alle möglichen Verweise auf alle möglichen Include in einem Verlaufsdatensatz zurück. (z. B. DocumentSigned bezieht sich auf Receiver.)
/// Dies wird hinzugefügt, damit Client-Anwendungen sich selbst auf dem neuesten Stand halten können.
/// 1 - Sender:
/// Historische Datensätze über den Include der Empfänger. Diese haben Statuscodes, die mit 1* beginnen.
/// 2 - Receiver:
/// Historische Datensätze, die sich auf den Include des Absenders beziehen. Sie haben Statuscodes, die mit 2* beginnen.
/// 3 - System:
/// Historische Datensätze, die sich auf den allgemeinen Zustand des Umschlags beziehen. Diese haben Statuscodes, die mit 3* beginnen.
/// 4 - Unknown:
/// Ein unbekannter Datensatz weist auf einen möglichen Mangel oder eine Unstimmigkeit im Aktualisierungsprozess der Anwendung hin.
/// </summary>
/// <returns></returns>
/// <response code="200"></response>
[HttpGet("related")]
[Authorize]
public IActionResult GetReferenceTypes(ReferenceType? referenceType = null)
{
return referenceType is null
? Ok(_memoryCache.GetEnumAsDictionary<ReferenceType>("gen.api", ReferenceType.Unknown))
: Ok(referenceType.ToString());
}
/// <summary>
/// Gibt alle möglichen Include in einem Verlaufsdatensatz zurück.
/// Dies wird hinzugefügt, damit Client-Anwendungen sich selbst auf dem neuesten Stand halten können.
/// 1003: EnvelopeQueued
/// 1006: EnvelopeCompletelySigned
/// 1007: EnvelopeReportCreated
/// 1008: EnvelopeArchived
/// 1009: EnvelopeDeleted
/// 10007: EnvelopeRejected
/// 10009: EnvelopeWithdrawn
/// 2001: AccessCodeRequested
/// 2002: AccessCodeCorrect
/// 2003: AccessCodeIncorrect
/// 2004: DocumentOpened
/// 2005: DocumentSigned
/// 2006: DocumentForwarded
/// 2007: DocumentRejected
/// 2008: EnvelopeShared
/// 2009: EnvelopeViewed
/// 3001: MessageInvitationSent (Wird von Trigger verwendet)
/// 3002: MessageAccessCodeSent
/// 3003: MessageConfirmationSent
/// 3004: MessageDeletionSent
/// 3005: MessageCompletionSent
/// </summary>
/// <param name="status">
/// Abfrageparameter, der angibt, auf welche Referenz sich der Include bezieht.
/// 1 - Sender: Historische Datensätze, die sich auf den Include des Absenders beziehen. Sie haben Statuscodes, die mit 1* beginnen.
/// 2 - Receiver: Historische Datensätze über den Include der Empfänger. Diese haben Statuscodes, die mit 2* beginnen.
/// 3 - System: Diese werden durch Datenbank-Trigger aktualisiert und sind in den Tabellen EnvelopeHistory und EmailOut zu finden.Sie arbeiten
/// integriert mit der Anwendung EmailProfiler, um E-Mails zu versenden und haben die Codes, die mit 3* beginnen.
/// </param>
/// <returns>Gibt die HTTP-Antwort zurück.</returns>
/// <response code="200"></response>
[HttpGet("status")]
[Authorize]
public IActionResult GetEnvelopeStatus([FromQuery] EnvelopeStatus? status = null)
{
return status is null
? Ok(_memoryCache.GetEnumAsDictionary<EnvelopeStatus>("gen.api", Status.NonHist, Status.RelatedToFormApp))
: Ok(status.ToString());
}
/// <summary>
/// Ruft die gesamte Umschlaghistorie basierend auf den angegebenen Abfrageparametern ab.
/// </summary>
/// <param name="historyQuery">Die Abfrageparameter, die die Filterkriterien für die Umschlaghistorie definieren.</param>
/// <param name="cancel"></param>
/// <returns>Eine Liste von Historieneinträgen, die den angegebenen Kriterien entsprechen, oder nur der letzte Eintrag.</returns>
/// <response code="200">Die Anfrage war erfolgreich, und die Umschlaghistorie wird zurückgegeben.</response>
/// <response code="400">Die Anfrage war ungültig oder unvollständig.</response>
/// <response code="401">Der Benutzer ist nicht authentifiziert.</response>
/// <response code="403">Der Benutzer hat keine Berechtigung, auf die Ressource zuzugreifen.</response>
/// <response code="500">Ein unerwarteter Fehler ist aufgetreten.</response>
[HttpGet]
[Authorize]
public async Task<IActionResult> GetAllAsync([FromQuery] ReadHistoryQuery historyQuery, CancellationToken cancel)
{
var history = await _mediator.Send(historyQuery, cancel).ThrowIfEmpty(Exceptions.NotFound);
return Ok((historyQuery.OnlyLast) ? history.MaxBy(h => h.AddedWhen) : history);
}
}

View File

@@ -1,38 +0,0 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
namespace EnvelopeGenerator.API.Controllers.Interfaces;
/// <summary>
///
/// </summary>
public interface IAuthController
{
/// <summary>
///
/// </summary>
IAuthorizationService AuthService { get; }
/// <summary>
///
/// </summary>
ClaimsPrincipal User { get; }
}
/// <summary>
///
/// </summary>
public static class AuthControllerExtensions
{
/// <summary>
///
/// </summary>
/// <param name="controller"></param>
/// <param name="policyName"></param>
/// <returns></returns>
public static async Task<bool> IsUserInPolicyAsync(this IAuthController controller, string policyName)
{
var result = await controller.AuthService.AuthorizeAsync(controller.User, policyName);
return result.Succeeded;
}
}

View File

@@ -1,121 +0,0 @@
using DigitalData.Core.API;
using EnvelopeGenerator.Application.Resources;
using Microsoft.AspNetCore.Localization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Localization;
using EnvelopeGenerator.Application.Resources;
namespace EnvelopeGenerator.API.Controllers;
/// <summary>
/// Controller für die Verwaltung der Lokalisierung und Spracheinstellungen.
/// </summary>
[ApiExplorerSettings(IgnoreApi = true)]
[Route("api/[controller]")]
[ApiController]
public class LocalizationController : ControllerBase
{
private static readonly Guid L_KEY = Guid.NewGuid();
private readonly ILogger<LocalizationController> _logger;
private readonly IStringLocalizer<Resource> _mLocalizer;
private readonly IStringLocalizer<Resource> _localizer;
private readonly IMemoryCache _cache;
/// <summary>
/// Konstruktor für den <see cref="LocalizationController"/>.
/// </summary>
/// <param name="logger">Logger für die Protokollierung.</param>
/// <param name="localizer">Lokalisierungsdienst für Ressourcen.</param>
/// <param name="memoryCache">Speicher-Cache für die Zwischenspeicherung von Daten.</param>
/// <param name="_modelLocalizer">Lokalisierungsdienst für Modelle.</param>
public LocalizationController(
ILogger<LocalizationController> logger,
IStringLocalizer<Resource> localizer,
IMemoryCache memoryCache,
IStringLocalizer<Resource> _modelLocalizer)
{
_logger = logger;
_localizer = localizer;
_cache = memoryCache;
_mLocalizer = _modelLocalizer;
}
/// <summary>
/// Ruft alle lokalisierten Daten ab.
/// </summary>
/// <returns>Eine Liste aller lokalisierten Daten.</returns>
[HttpGet]
public IActionResult GetAll() => Ok(_cache.GetOrCreate(Language ?? string.Empty + L_KEY, _ => _mLocalizer.ToDictionary()));
/// <summary>
/// Ruft die aktuelle Sprache ab.
/// </summary>
/// <returns>Die aktuelle Sprache oder ein NotFound-Ergebnis, wenn keine Sprache gesetzt ist.</returns>
[HttpGet("lang")]
public IActionResult GetLanguage() => Language is null ? NotFound() : Ok(Language);
/// <summary>
/// Setzt die Sprache.
/// </summary>
/// <param name="language">Die zu setzende Sprache.</param>
/// <returns>Ein Ok-Ergebnis, wenn die Sprache erfolgreich gesetzt wurde, oder ein BadRequest-Ergebnis, wenn die Eingabe ungültig ist.</returns>
[HttpPost("lang")]
public IActionResult SetLanguage([FromQuery] string language)
{
if (string.IsNullOrEmpty(language))
return BadRequest();
Language = language;
return Ok();
}
/// <summary>
/// Löscht die aktuelle Sprache.
/// </summary>
/// <returns>Ein Ok-Ergebnis, wenn die Sprache erfolgreich gelöscht wurde.</returns>
[HttpDelete("lang")]
public IActionResult DeleteLanguage()
{
Language = null;
return Ok();
}
/// <summary>
/// Eigenschaft für die Verwaltung der aktuellen Sprache über Cookies.
/// </summary>
private string? Language
{
get
{
var cookieValue = Request.Cookies[CookieRequestCultureProvider.DefaultCookieName];
if (string.IsNullOrEmpty(cookieValue))
return null;
var culture = CookieRequestCultureProvider.ParseCookieValue(cookieValue)?.Cultures[0];
return culture?.Value ?? null;
}
set
{
if (value is null)
Response.Cookies.Delete(CookieRequestCultureProvider.DefaultCookieName);
else
{
var cookieOptions = new CookieOptions()
{
Expires = DateTimeOffset.UtcNow.AddYears(1),
Secure = false,
SameSite = SameSiteMode.Strict,
HttpOnly = true
};
Response.Cookies.Append(
CookieRequestCultureProvider.DefaultCookieName,
CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(value)),
cookieOptions);
}
}
}
}

View File

@@ -1,91 +0,0 @@
using DigitalData.Core.Abstraction.Application.DTO;
using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiverReadOnly;
using EnvelopeGenerator.Application.Common.Interfaces.Services;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.API.Extensions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
namespace EnvelopeGenerator.API.Controllers;
/// <summary>
/// Manages read-only envelope sharing flows.
/// </summary>
[Route("api/[controller]")]
[ApiController]
public class ReadOnlyController : ControllerBase
{
private readonly ILogger<ReadOnlyController> _logger;
private readonly IEnvelopeReceiverReadOnlyService _readOnlyService;
private readonly IEnvelopeMailService _mailService;
private readonly IEnvelopeHistoryService _historyService;
/// <summary>
/// Initializes a new instance of the <see cref="ReadOnlyController"/> class.
/// </summary>
public ReadOnlyController(ILogger<ReadOnlyController> logger, IEnvelopeReceiverReadOnlyService readOnlyService, IEnvelopeMailService mailService, IEnvelopeHistoryService historyService)
{
_logger = logger;
_readOnlyService = readOnlyService;
_mailService = mailService;
_historyService = historyService;
}
/// <summary>
/// Creates a new read-only receiver for the current envelope.
/// </summary>
/// <param name="createDto">Creation payload.</param>
[HttpPost]
[Authorize(Policy = AuthPolicy.Receiver)]
[Obsolete("Use MediatR")]
public async Task<IActionResult> CreateAsync([FromBody] EnvelopeReceiverReadOnlyCreateDto createDto)
{
var authReceiverMail = User.ReceiverMail();
if (authReceiverMail is null)
{
_logger.LogError("EmailAddress claim is not found in envelope-receiver-read-only creation process. Create DTO is:\n {dto}", JsonConvert.SerializeObject(createDto));
return Unauthorized();
}
var envelopeId = User.EnvelopeId();
createDto.AddedWho = authReceiverMail;
createDto.EnvelopeId = envelopeId;
var creationRes = await _readOnlyService.CreateAsync(createDto: createDto);
if (creationRes.IsFailed)
{
_logger.LogNotice(creationRes);
return StatusCode(StatusCodes.Status500InternalServerError);
}
var readRes = await _readOnlyService.ReadByIdAsync(creationRes.Data.Id);
if (readRes.IsFailed)
{
_logger.LogNotice(creationRes);
return StatusCode(StatusCodes.Status500InternalServerError);
}
var newReadOnly = readRes.Data;
return await _mailService.SendAsync(newReadOnly).ThenAsync<int, IActionResult>(SuccessAsync: async _ =>
{
var histRes = await _historyService.RecordAsync((int)createDto.EnvelopeId, createDto.AddedWho, EnvelopeStatus.EnvelopeShared);
if (histRes.IsFailed)
{
_logger.LogError("Although the envelope was sent as read-only, the EnvelopeShared history could not be saved. Create DTO:\n{createDto}", JsonConvert.SerializeObject(createDto));
_logger.LogNotice(histRes.Notices);
}
return Ok();
},
Fail: (msg, ntc) =>
{
_logger.LogNotice(ntc);
return StatusCode(StatusCodes.Status500InternalServerError);
});
}
}

View File

@@ -1,47 +0,0 @@
using MediatR;
using EnvelopeGenerator.Application.Receivers.Queries;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace EnvelopeGenerator.GeneratorAPI.Controllers;
/// <summary>
/// Controller für die Verwaltung von Empfängern.
/// </summary>
/// <remarks>
/// Dieser Controller bietet Endpunkte für das Abrufen von Empfängern basierend auf E-Mail-Adresse oder Signatur.
/// </remarks>
[Route("api/[controller]")]
[ApiController]
[Authorize]
public class ReceiverController : ControllerBase
{
private readonly IMediator _mediator;
/// <summary>
/// Initialisiert eine neue Instanz des <see cref="ReceiverController"/>-Controllers.
/// </summary>
/// <param name="mediator">Mediator für Anfragen.</param>
public ReceiverController(IMediator mediator)
{
_mediator = mediator;
}
/// <summary>
/// Ruft eine Liste von Empfängern ab, basierend auf den angegebenen Abfrageparametern.
/// </summary>
/// <param name="receiver">Die Abfrageparameter, einschließlich E-Mail-Adresse und Signatur.</param>
/// <returns>Eine Liste von Empfängern oder ein Fehlerstatus.</returns>
[HttpGet]
public async Task<IActionResult> Get([FromQuery] ReadReceiverQuery receiver)
{
if (!receiver.HasAnyCriteria)
{
var all = await _mediator.Send(new ReadReceiverQuery());
return Ok(all);
}
var result = await _mediator.Send(receiver);
return result is null ? NotFound() : Ok(result);
}
}

View File

@@ -1,129 +0,0 @@
using DigitalData.Core.Abstraction.Application.DTO;
using EnvelopeGenerator.Application.Common.Extensions;
using EnvelopeGenerator.Application.Common.Interfaces.Services;
using EnvelopeGenerator.Application.Resources;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.API.Models;
using Ganss.Xss;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Options;
namespace EnvelopeGenerator.API.Controllers;
/// <summary>
/// Exposes endpoints for registering and managing two-factor authentication for envelope receivers.
/// </summary>
[ApiController]
[Route("api/tfa")]
public class TfaRegistrationController : ControllerBase
{
private readonly ILogger<TfaRegistrationController> _logger;
private readonly IEnvelopeReceiverService _envelopeReceiverService;
private readonly IAuthenticator _authenticator;
private readonly IReceiverService _receiverService;
private readonly TFARegParams _parameters;
private readonly IStringLocalizer<Resource> _localizer;
/// <summary>
/// Initializes a new instance of the <see cref="TfaRegistrationController"/> class.
/// </summary>
public TfaRegistrationController(
ILogger<TfaRegistrationController> logger,
IEnvelopeReceiverService envelopeReceiverService,
IAuthenticator authenticator,
IReceiverService receiverService,
IOptions<TFARegParams> tfaRegParamsOptions,
IStringLocalizer<Resource> localizer)
{
_logger = logger;
_envelopeReceiverService = envelopeReceiverService;
_authenticator = authenticator;
_receiverService = receiverService;
_parameters = tfaRegParamsOptions.Value;
_localizer = localizer;
}
/// <summary>
/// Generates registration metadata (QR code and deadline) for a receiver.
/// </summary>
/// <param name="envelopeReceiverId">Encoded envelope receiver id.</param>
[Authorize]
[HttpGet("{envelopeReceiverId}")]
public async Task<IActionResult> RegisterAsync(string envelopeReceiverId)
{
try
{
var (uuid, signature) = envelopeReceiverId.DecodeEnvelopeReceiverId();
if (uuid is null || signature is null)
{
_logger.LogEnvelopeError(uuid: uuid, signature: signature, message: _localizer.WrongEnvelopeReceiverId());
return Unauthorized(new { message = _localizer.WrongEnvelopeReceiverId() });
}
var secretResult = await _envelopeReceiverService.ReadWithSecretByUuidSignatureAsync(uuid: uuid, signature: signature);
if (secretResult.IsFailed)
{
_logger.LogNotice(secretResult.Notices);
return NotFound(new { message = _localizer.WrongEnvelopeReceiverId() });
}
var envelopeReceiver = secretResult.Data;
if (!envelopeReceiver.Envelope!.TFAEnabled)
return Unauthorized(new { message = _localizer.WrongAccessCode() });
var receiver = envelopeReceiver.Receiver;
receiver!.TotpSecretkey = _authenticator.GenerateTotpSecretKey();
await _receiverService.UpdateAsync(receiver);
var totpQr64 = _authenticator.GenerateTotpQrCode(userEmail: receiver.EmailAddress, secretKey: receiver.TotpSecretkey).ToBase64String();
if (receiver.TfaRegDeadline is null)
{
receiver.TfaRegDeadline = _parameters.Deadline;
await _receiverService.UpdateAsync(receiver);
}
else if (receiver.TfaRegDeadline <= DateTime.Now)
{
return StatusCode(StatusCodes.Status410Gone, new { message = _localizer.WrongAccessCode() });
}
return Ok(new
{
envelopeReceiver.EnvelopeId,
envelopeReceiver.Envelope!.Uuid,
envelopeReceiver.Receiver!.Signature,
receiver.TfaRegDeadline,
TotpQR64 = totpQr64
});
}
catch (Exception ex)
{
_logger.LogEnvelopeError(envelopeReceiverId: envelopeReceiverId, exception: ex, message: _localizer.WrongEnvelopeReceiverId());
return StatusCode(StatusCodes.Status500InternalServerError, new { message = _localizer.UnexpectedError() });
}
}
/// <summary>
/// Logs out the envelope receiver from cookie authentication.
/// </summary>
[Authorize(Policy = AuthPolicy.Receiver)]
[HttpPost("auth/logout")]
public async Task<IActionResult> LogOutAsync()
{
try
{
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return Ok();
}
catch (Exception ex)
{
_logger.LogError(ex, "{message}", ex.Message);
return StatusCode(StatusCodes.Status500InternalServerError, new { message = _localizer.UnexpectedError() });
}
}
}

View File

@@ -1,123 +0,0 @@
using EnvelopeGenerator.API.Models;
using Microsoft.OpenApi.Any;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace EnvelopeGenerator.API.Documentation;
/// <summary>
///
/// </summary>
public sealed class AuthProxyDocumentFilter : IDocumentFilter
{
/// <summary>
///
/// </summary>
/// <param name="swaggerDoc"></param>
/// <param name="context"></param>
public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
{
AddLoginOperation(swaggerDoc, context);
AddEnvelopeReceiverLoginOperation(swaggerDoc, context);
}
private static void AddLoginOperation(OpenApiDocument swaggerDoc, DocumentFilterContext context)
{
const string path = "/api/auth";
var loginSchema = context.SchemaGenerator.GenerateSchema(typeof(Login), context.SchemaRepository);
var loginExample = new OpenApiObject
{
["password"] = new OpenApiString(""),
["username"] = new OpenApiString("")
};
var operation = new OpenApiOperation
{
Summary = "Proxy login (auth-hub)",
Description = "Proxies the request to the auth service. Add query parameter `cookie=true|false`.",
Tags = [new() { Name = "Auth" }],
Parameters =
{
new OpenApiParameter
{
Name = "cookie",
In = ParameterLocation.Query,
Required = false,
Schema = new OpenApiSchema { Type = "boolean", Default = new OpenApiBoolean(true) },
Example = new OpenApiBoolean(true),
Description = "If true, auth service sets the auth cookie."
}
},
RequestBody = new OpenApiRequestBody
{
Required = true,
Content =
{
["application/json"] = new OpenApiMediaType { Schema = loginSchema, Example = loginExample },
["multipart/form-data"] = new OpenApiMediaType { Schema = loginSchema, Example = loginExample }
}
},
Responses =
{
["200"] = new OpenApiResponse { Description = "OK (proxied response)" },
["401"] = new OpenApiResponse { Description = "Unauthorized" }
}
};
swaggerDoc.Paths[path] = new OpenApiPathItem
{
Operations =
{
[OperationType.Post] = operation
}
};
}
private static void AddEnvelopeReceiverLoginOperation(OpenApiDocument swaggerDoc, DocumentFilterContext context)
{
const string path = "/api/Auth/envelope-receiver/{key}";
var bodySchema = context.SchemaGenerator.GenerateSchema(typeof(EnvelopeReceiverLogin), context.SchemaRepository);
var operation = new OpenApiOperation
{
Summary = "Envelope receiver login (auth-hub proxy)",
Description = "Proxies the envelope receiver login to the auth service. " +
"The `cookie` query parameter is always forwarded as `true` so the auth service sets the per-envelope cookie automatically.",
Tags = [new() { Name = "Auth" }],
Parameters =
{
new OpenApiParameter
{
Name = "key",
In = ParameterLocation.Path,
Required = true,
Schema = new OpenApiSchema { Type = "string" },
Description = "The unique envelope receiver key."
}
},
RequestBody = new OpenApiRequestBody
{
Required = false,
Content =
{
["multipart/form-data"] = new OpenApiMediaType { Schema = bodySchema }
}
},
Responses =
{
["200"] = new OpenApiResponse { Description = "OK – per-envelope cookie set by auth service." },
["401"] = new OpenApiResponse { Description = "Unauthorized – invalid or missing access code." }
}
};
swaggerDoc.Paths[path] = new OpenApiPathItem
{
Operations =
{
[OperationType.Post] = operation
}
};
}
}

View File

@@ -1,84 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFrameworks>net8.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<PackageId>EnvelopeGenerator.GeneratorAPI</PackageId>
<Title></Title>
<Authors>Digital Data GmbH</Authors>
<Company>Digital Data GmbH</Company>
<Product>EnvelopeGenerator.GeneratorAPI</Product>
<Version>1.4.0</Version>
<FileVersion>1.4.0</FileVersion>
<AssemblyVersion>1.4.0</AssemblyVersion>
<PackageOutputPath>Copyright © 2025 Digital Data GmbH. All rights reserved.</PackageOutputPath>
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
</PropertyGroup>
<ItemGroup>
<Compile Remove="ClientApp\**" />
<Content Remove="ClientApp\**" />
<EmbeddedResource Remove="ClientApp\**" />
<None Remove="ClientApp\**" />
</ItemGroup>
<ItemGroup>
<None Include="yarp.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="AspNetCore.Scalar" Version="1.1.8" />
<PackageReference Include="DigitalData.Auth.Claims" Version="1.0.3" />
<PackageReference Include="DigitalData.Auth.Client" Version="1.3.7" />
<PackageReference Include="DigitalData.Core.API" Version="2.2.1" />
<PackageReference Include="HtmlSanitizer" Version="9.0.892" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.28" />
<PackageReference Include="Microsoft.Extensions.Caching.SqlServer" Version="8.0.11" Condition="'$(TargetFramework)' == 'net8.0'" />
<PackageReference Include="itext" Version="8.0.5" />
<PackageReference Include="itext.bouncy-castle-adapter" Version="8.0.5" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.4" Condition="'$(TargetFramework)' == 'net9.0'" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.11" Condition="'$(TargetFramework)' == 'net8.0'" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.6" Condition="'$(TargetFramework)' == 'net9.0'" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.17" Condition="'$(TargetFramework)' == 'net8.0'" />
<PackageReference Include="Microsoft.Identity.Client" Version="4.82.1" />
<PackageReference Include="NLog" Version="5.2.5" />
<PackageReference Include="NLog.Web.AspNetCore" Version="5.3.0" />
<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="DigitalData.EmailProfilerDispatcher.Abstraction" Version="3.2.0" />
<PackageReference Include="Yarp.ReverseProxy" Version="2.1.0" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net7.0'">
<PackageReference Include="System.DirectoryServices" Version="7.0.1" />
<PackageReference Include="System.DirectoryServices.AccountManagement" Version="7.0.1" />
<PackageReference Include="System.DirectoryServices.Protocols" Version="7.0.1" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
<PackageReference Include="System.DirectoryServices" Version="8.0.0" />
<PackageReference Include="System.DirectoryServices.AccountManagement" Version="8.0.1" />
<PackageReference Include="System.DirectoryServices.Protocols" Version="8.0.1" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net9.0'">
<PackageReference Include="System.DirectoryServices" Version="9.0.4" />
<PackageReference Include="System.DirectoryServices.AccountManagement" Version="9.0.4" />
<PackageReference Include="System.DirectoryServices.Protocols" Version="9.0.4" />
</ItemGroup>
<ItemGroup>
<Folder Include="wwwroot\" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\EnvelopeGenerator.Application\EnvelopeGenerator.Application.csproj" />
<ProjectReference Include="..\EnvelopeGenerator.Domain\EnvelopeGenerator.Domain.csproj" />
<ProjectReference Include="..\EnvelopeGenerator.Infrastructure\EnvelopeGenerator.Infrastructure.csproj" />
<ProjectReference Include="..\EnvelopeGenerator.PdfEditor\EnvelopeGenerator.PdfEditor.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,96 +0,0 @@
using DigitalData.Auth.Claims;
using Microsoft.IdentityModel.JsonWebTokens;
using System.Security.Claims;
namespace EnvelopeGenerator.API.Extensions;
/// <summary>
/// Provides helper methods for working with envelope-specific authentication claims.
/// </summary>
public static class ReceiverClaimExtensions
{
/// <summary>
///
/// </summary>
/// <param name="user"></param>
/// <param name="claimType"></param>
/// <returns></returns>
/// <exception cref="InvalidOperationException"></exception>
private static string GetRequiredClaimValue(this ClaimsPrincipal user, string claimType)
{
var value = user.FindFirstValue(claimType);
if (value is not null)
{
return value;
}
var identity = user.Identity;
var principalName = identity?.Name ?? "(anonymous)";
var authType = identity?.AuthenticationType ?? "(none)";
var availableClaims = string.Join(", ", user.Claims.Select(c => $"{c.Type}={c.Value}"));
var message = $"Required claim '{claimType}' is missing for user '{principalName}' (auth: {authType}). Available claims: [{availableClaims}].";
throw new InvalidOperationException(message);
}
private static string GetRequiredClaimValue(this ClaimsPrincipal user, params string[] claimTypes)
{
foreach (var claimType in claimTypes.Where(t => !string.IsNullOrWhiteSpace(t)).Distinct())
{
var value = user.FindFirstValue(claimType);
if (!string.IsNullOrWhiteSpace(value))
return value;
}
var identity = user.Identity;
var principalName = identity?.Name ?? "(anonymous)";
var authType = identity?.AuthenticationType ?? "(none)";
var availableClaims = string.Join(", ", user.Claims.Select(c => $"{c.Type}={c.Value}"));
var message = $"Required claim(s) '{string.Join("', '", claimTypes)}' are missing for user '{principalName}' (auth: {authType}). Available claims: [{availableClaims}].";
throw new InvalidOperationException(message);
}
/// <summary>
/// Gets the authenticated envelope UUID from the claims.
/// </summary>
public static string EnvelopeUuid(this ClaimsPrincipal user)
=> user.GetRequiredClaimValue(EnvelopeClaimNames.EnvelopeUuid);
/// <summary>
/// Gets the authenticated receiver signature from the claims.
/// </summary>
public static string ReceiverSignature(this ClaimsPrincipal user)
=> user.GetRequiredClaimValue(EnvelopeClaimNames.ReceiverSignature);
/// <summary>
/// Gets the authenticated receiver email address from the claims.
/// </summary>
public static string ReceiverMail(this ClaimsPrincipal user)
=> user.GetRequiredClaimValue(JwtRegisteredClaimNames.Email);
/// <summary>
/// Gets the authenticated envelope identifier from the claims.
/// </summary>
public static int EnvelopeId(this ClaimsPrincipal user)
{
var envIdStr = user.GetRequiredClaimValue(EnvelopeClaimNames.EnvelopeId);
if (int.TryParse(envIdStr, out var envId))
return envId;
else
throw new InvalidOperationException($"Claim '{EnvelopeClaimNames.EnvelopeId}' is not a valid integer.");
}
/// <summary>
/// Gets the authenticated receiver identifier from the claims.
/// </summary>
/// <param name="user"></param>
/// <returns></returns>
/// <exception cref="InvalidOperationException"></exception>
public static int ReceiverId(this ClaimsPrincipal user)
{
var rcvIdStr = user.GetRequiredClaimValue(EnvelopeClaimNames.ReceiverId);
if (int.TryParse(rcvIdStr, out var rcvId))
return rcvId;
else
throw new InvalidOperationException($"Claim '{EnvelopeClaimNames.ReceiverId}' is not a valid integer.");
}
}

View File

@@ -1,95 +0,0 @@
using System.Security.Claims;
namespace EnvelopeGenerator.API.Extensions
{
/// <summary>
/// Provides extension methods for extracting user information from a <see cref="ClaimsPrincipal"/>.
/// </summary>
public static class SenderClaimExtensions
{
private static string GetRequiredClaimOfSender(this ClaimsPrincipal user, string claimType)
{
var value = user.FindFirstValue(claimType);
if (value is not null)
{
return value;
}
var identity = user.Identity;
var principalName = identity?.Name ?? "(anonymous)";
var authType = identity?.AuthenticationType ?? "(none)";
var availableClaims = string.Join(", ", user.Claims.Select(c => $"{c.Type}={c.Value}"));
var message = $"Required claim '{claimType}' is missing for user '{principalName}' (auth: {authType}). Available claims: [{availableClaims}].";
throw new InvalidOperationException(message);
}
private static string GetRequiredClaimOfSender(this ClaimsPrincipal user, params string[] claimTypes)
{
string? value = null;
foreach (var claimType in claimTypes)
{
value = user.FindFirstValue(claimType);
if (value is not null)
return value;
}
var identity = user.Identity;
var principalName = identity?.Name ?? "(anonymous)";
var authType = identity?.AuthenticationType ?? "(none)";
var availableClaims = string.Join(", ", user.Claims.Select(c => $"{c.Type}={c.Value}"));
var message = $"Required claim among [{string.Join(", ", claimTypes)}] is missing for user '{principalName}' (auth: {authType}). Available claims: [{availableClaims}].";
throw new InvalidOperationException(message);
}
/// <summary>
/// Retrieves the user's ID from the claims. Throws an exception if the ID is missing or invalid.
/// </summary>
/// <param name="user">The <see cref="ClaimsPrincipal"/> representing the user.</param>
/// <returns>The user's ID as an integer.</returns>
/// <exception cref="InvalidOperationException">Thrown if the user ID claim is missing or invalid.</exception>
public static int GetId(this ClaimsPrincipal user)
{
var idValue = user.GetRequiredClaimOfSender(ClaimTypes.NameIdentifier, "sub");
if (!int.TryParse(idValue, out var result))
{
throw new InvalidOperationException("User ID claim is missing or invalid. This may indicate a misconfigured or forged JWT token.");
}
return result;
}
/// <summary>
/// Retrieves the username from the claims.
/// </summary>
/// <param name="user">The <see cref="ClaimsPrincipal"/> representing the user.</param>
/// <returns>The username as a string.</returns>
public static string GetUsername(this ClaimsPrincipal user)
=> user.GetRequiredClaimOfSender(ClaimTypes.Name);
/// <summary>
/// Retrieves the user's surname (last name) from the claims.
/// </summary>
/// <param name="user">The <see cref="ClaimsPrincipal"/> representing the user.</param>
/// <returns>The surname as a string.</returns>
public static string GetName(this ClaimsPrincipal user)
=> user.GetRequiredClaimOfSender(ClaimTypes.Surname);
/// <summary>
/// Retrieves the user's given name (first name) from the claims.
/// </summary>
/// <param name="user">The <see cref="ClaimsPrincipal"/> representing the user.</param>
/// <returns>The given name as a string.</returns>
public static string GetPrename(this ClaimsPrincipal user)
=> user.GetRequiredClaimOfSender(ClaimTypes.GivenName);
/// <summary>
/// Retrieves the user's email address from the claims.
/// </summary>
/// <param name="user">The <see cref="ClaimsPrincipal"/> representing the user.</param>
/// <returns>The email address as a string.</returns>
public static string GetEmail(this ClaimsPrincipal user)
=> user.GetRequiredClaimOfSender(ClaimTypes.Email);
}
}

View File

@@ -1,10 +0,0 @@
pipeline {
agent any
stages {
stage('Build') {
steps {
sh 'dotnet build'
}
}
}
}

View File

@@ -1,84 +0,0 @@
namespace EnvelopeGenerator.API.Middleware;
using DigitalData.Core.Exceptions;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using System.Net;
using System.Text.Json;
/// <summary>
/// Middleware for handling exceptions globally in the application.
/// Captures exceptions thrown during the request pipeline execution,
/// logs them, and returns an appropriate HTTP response with a JSON error message.
/// </summary>
public class ExceptionHandlingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionHandlingMiddleware> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="ExceptionHandlingMiddleware"/> class.
/// </summary>
/// <param name="next">The next middleware in the request pipeline.</param>
/// <param name="logger">The logger instance for logging exceptions.</param>
public ExceptionHandlingMiddleware(RequestDelegate next, ILogger<ExceptionHandlingMiddleware> logger)
{
_next = next;
_logger = logger;
}
/// <summary>
/// Invokes the middleware to handle the HTTP request.
/// </summary>
/// <param name="context">The HTTP context of the current request.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context); // Continue down the pipeline
}
catch (Exception ex)
{
await HandleExceptionAsync(context, ex, _logger);
}
}
/// <summary>
/// Handles exceptions by logging them and writing an appropriate JSON response.
/// </summary>
/// <param name="context">The HTTP context of the current request.</param>
/// <param name="exception">The exception that occurred.</param>
/// <param name="logger">The logger instance for logging the exception.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
private static async Task HandleExceptionAsync(HttpContext context, Exception exception, ILogger logger)
{
context.Response.ContentType = "application/json";
string message;
switch (exception)
{
case BadRequestException badRequestEx:
context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
message = badRequestEx.Message;
break;
case NotFoundException notFoundEx:
context.Response.StatusCode = (int)HttpStatusCode.NotFound;
message = notFoundEx.Message;
break;
default:
logger.LogError(exception, "Unhandled exception occurred.");
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
message = "An unexpected error occurred.";
break;
}
await context.Response.WriteAsync(JsonSerializer.Serialize(new
{
message
}));
}
}

View File

@@ -1,14 +0,0 @@
namespace EnvelopeGenerator.API.Models;
public record Auth(string? AccessCode = null, string? SmsCode = null, string? AuthenticatorCode = null, bool UserSelectSMS = default)
{
public bool HasAccessCode => AccessCode is not null;
public bool HasSmsCode => SmsCode is not null;
public bool HasAuthenticatorCode => AuthenticatorCode is not null;
public bool HasMulti => new[] { HasAccessCode, HasSmsCode, HasAuthenticatorCode }.Count(state => state) > 1;
public bool HasNone => !(HasAccessCode || HasSmsCode || HasAuthenticatorCode);
}

View File

@@ -1,28 +0,0 @@
namespace EnvelopeGenerator.API.Models;
/// <summary>
/// Represents the keys and default values used for authentication token handling
/// within the Envelope Generator API.
/// </summary>
public class AuthTokenKeys
{
/// <summary>
/// Gets the name of the cookie used to store the authentication token.
/// </summary>
public string Cookie { get; init; } = "AuthToken";
/// <summary>
/// Gets the name of the query string parameter used to pass the authentication token.
/// </summary>
public string QueryString { get; init; } = "AuthToken";
/// <summary>
/// Gets the expected issuer value for the authentication token.
/// </summary>
public string Issuer { get; init; } = "auth.digitaldata.works";
/// <summary>
/// Gets the expected audience value for the authentication token.
/// </summary>
public string Audience { get; init; } = "sign-flow.digitaldata.works";
}

View File

@@ -1,12 +0,0 @@
namespace EnvelopeGenerator.API.Models;
/// <summary>
///
/// </summary>
public class ConnectionString
{
/// <summary>
///
/// </summary>
public string Value { get; set; } = string.Empty;
}

View File

@@ -1,60 +0,0 @@
namespace EnvelopeGenerator.API.Models
{
/// <summary>
/// Represents a hyperlink for contact purposes with various HTML attributes.
/// </summary>
public class ContactLink
{
/// <summary>
/// Gets or sets the label of the hyperlink.
/// </summary>
public string Label { get; init; } = "Contact";
/// <summary>
/// Gets or sets the URL that the hyperlink points to.
/// </summary>
public string Href { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the target where the hyperlink should open.
/// Commonly used values are "_blank", "_self", "_parent", "_top".
/// </summary>
public string Target { get; set; } = "_blank";
/// <summary>
/// Gets or sets the relationship of the linked URL as space-separated link types.
/// Examples include "nofollow", "noopener", "noreferrer".
/// </summary>
public string Rel { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the filename that should be downloaded when clicking the hyperlink.
/// This attribute will only have an effect if the href attribute is set.
/// </summary>
public string Download { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the language of the linked resource. Useful when linking to
/// content in another language.
/// </summary>
public string HrefLang { get; set; } = "en";
/// <summary>
/// Gets or sets the MIME type of the linked URL. Helps browsers to handle
/// the type correctly when the link is clicked.
/// </summary>
public string Type { get; set; } = string.Empty;
/// <summary>
/// Gets or sets additional information about the hyperlink, typically viewed
/// as a tooltip when the mouse hovers over the link.
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// Gets or sets an identifier for the hyperlink, unique within the HTML document.
/// </summary>
public string Id { get; set; } = string.Empty;
}
}

View File

@@ -1,17 +0,0 @@
using System.Globalization;
namespace EnvelopeGenerator.API.Models;
public class Culture
{
private string _language = string.Empty;
public string Language { get => _language;
init {
_language = value;
Info = new(value);
}
}
public string FIClass { get; init; } = string.Empty;
public CultureInfo? Info { get; init; }
}

View File

@@ -1,12 +0,0 @@
namespace EnvelopeGenerator.API.Models;
public class Cultures : List<Culture>
{
public IEnumerable<string> Languages => this.Select(c => c.Language);
public IEnumerable<string> FIClasses => this.Select(c => c.FIClass);
public Culture Default => this.First();
public Culture? this[string? language] => language is null ? null : this.Where(c => c.Language == language).FirstOrDefault();
}

View File

@@ -1,6 +0,0 @@
namespace EnvelopeGenerator.API.Models;
public class CustomImages : Dictionary<string, Image>
{
public new Image this[string key] => TryGetValue(key, out var img) && img is not null ? img : new();
}

View File

@@ -1,7 +0,0 @@
namespace EnvelopeGenerator.API.Models;
/// <summary>
/// Request body for the envelope-receiver login endpoint.
/// </summary>
/// <param name="AccessCode">The access code sent to the receiver.</param>
public record EnvelopeReceiverLogin(string? AccessCode = null);

View File

@@ -1,10 +0,0 @@
namespace EnvelopeGenerator.API.Models;
public class ErrorViewModel
{
public string Title { get; init; } = "404";
public string Subtitle { get; init; } = "Hmmm...";
public string Body { get; init; } = "It looks like one of the developers fell asleep";
}

View File

@@ -1,10 +0,0 @@
namespace EnvelopeGenerator.API.Models;
public class Image
{
public string Src { get; init; } = string.Empty;
public Dictionary<string, string> Classes { get; init; } = new();
public string GetClassIn(string page) => Classes.TryGetValue(page, out var cls) && cls is not null ? cls : string.Empty;
}

View File

@@ -1,13 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace EnvelopeGenerator.API.Models;
/// <summary>
/// Repräsentiert ein Login-Modell mit erforderlichem Passwort und optionaler ID und Benutzername.
/// </summary>
/// <param name="Password">Das erforderliche Passwort für das Login.</param>
/// <param name="UserId">Die optionale ID des Benutzers.</param>
/// <param name="Username">Der optionale Benutzername.</param>
public record Login([Required] string Password, int? UserId = null, string? Username = null)
{
}

View File

@@ -1,6 +0,0 @@
namespace EnvelopeGenerator.API.Models;
public class MainViewModel
{
public string? Title { get; init; }
}

View File

@@ -1,93 +0,0 @@
using EnvelopeGenerator.API.Models.PsPdfKitAnnotation;
using System.Text.Json.Serialization;
namespace EnvelopeGenerator.API.Models.PsPdfKitAnnotation;
public record Annotation : IAnnotation
{
public required string Name { get; init; }
#region Bound Annotation
[JsonIgnore]
public string? HorBoundAnnotName { get; init; }
[JsonIgnore]
public string? VerBoundAnnotName { get; init; }
#endregion
#region Layout
[JsonIgnore]
public double? MarginLeft { get; set; }
[JsonIgnore]
public double MarginLeftRatio { get; init; } = 1;
[JsonIgnore]
public double? MarginTop { get; set; }
[JsonIgnore]
public double MarginTopRatio { get; init; } = 1;
public double? Width { get; set; }
[JsonIgnore]
public double WidthRatio { get; init; } = 1;
public double? Height { get; set; }
[JsonIgnore]
public double HeightRatio { get; init; } = 1;
#endregion
#region Position
public double Left => (MarginLeft ?? 0) + (HorBoundAnnot?.HorBoundary ?? 0);
public double Top => (MarginTop ?? 0) + (VerBoundAnnot?.VerBoundary ?? 0);
#endregion
#region Boundary
[JsonIgnore]
public double HorBoundary => Left + (Width ?? 0);
[JsonIgnore]
public double VerBoundary => Top + (Height ?? 0);
#endregion
#region BoundAnnot
[JsonIgnore]
public Annotation? HorBoundAnnot { get; set; }
[JsonIgnore]
public Annotation? VerBoundAnnot { get; set; }
#endregion
public Color? BackgroundColor { get; init; }
#region Border
public Color? BorderColor { get; init; }
public string? BorderStyle { get; init; }
public int? BorderWidth { get; set; }
#endregion
[JsonIgnore]
internal Annotation Default
{
set
{
// To set null value, annotation must have null (0) value but null must has non-null value
if (MarginLeft == null && value.MarginLeft != null)
MarginLeft = value.MarginLeft * MarginLeftRatio;
if (MarginTop == null && value.MarginTop != null)
MarginTop = value.MarginTop * MarginTopRatio;
if (Width == null && value.Width != null)
Width = value.Width * WidthRatio;
if (Height == null && value.Height != null)
Height = value.Height * HeightRatio;
}
}
};

View File

@@ -1,80 +0,0 @@
using EnvelopeGenerator.API.Models.PsPdfKitAnnotation;
using System.Text.Json.Serialization;
namespace EnvelopeGenerator.API.Models.PsPdfKitAnnotation;
public class AnnotationParams
{
public AnnotationParams()
{
_AnnotationJSObjectInitor = new(CreateAnnotationJSObject);
}
public Background? Background { get; init; }
#region Annotation
[JsonIgnore]
public Annotation? DefaultAnnotation { get; init; }
private readonly List<Annotation> _annots = new List<Annotation>();
public bool TryGet(string name, out Annotation annotation)
{
#pragma warning disable CS8601 // Possible null reference assignment.
annotation = _annots.FirstOrDefault(a => a.Name == name);
#pragma warning restore CS8601 // Possible null reference assignment.
return annotation is not null;
}
public required IEnumerable<Annotation> Annotations
{
get => _annots;
init
{
_annots = value.ToList();
if (DefaultAnnotation is not null)
foreach (var annot in _annots)
annot.Default = DefaultAnnotation;
for (int i = 0; i < _annots.Count; i++)
{
#region set bound annotations
// horizontal
if (_annots[i].HorBoundAnnotName is string horBoundAnnotName)
if (TryGet(horBoundAnnotName, out var horBoundAnnot))
_annots[i].HorBoundAnnot = horBoundAnnot;
else
throw new InvalidOperationException($"{horBoundAnnotName} added as bound anotation. However, it is not defined.");
// vertical
if (_annots[i].VerBoundAnnotName is string verBoundAnnotName)
if (TryGet(verBoundAnnotName, out var verBoundAnnot))
_annots[i].VerBoundAnnot = verBoundAnnot;
else
throw new InvalidOperationException($"{verBoundAnnotName} added as bound anotation. However, it is not defined.");
#endregion
}
}
}
#endregion
#region AnnotationJSObject
private Dictionary<string, IAnnotation> CreateAnnotationJSObject()
{
var dict = _annots.ToDictionary(a => a.Name.ToLower(), a => a as IAnnotation);
if (Background is not null)
{
Background.Locate(_annots);
dict.Add(Background.Name.ToLower(), Background);
}
return dict;
}
private readonly Lazy<Dictionary<string, IAnnotation>> _AnnotationJSObjectInitor;
public Dictionary<string, IAnnotation> AnnotationJSObject => _AnnotationJSObjectInitor.Value;
#endregion
}

View File

@@ -1,58 +0,0 @@
using System.Text.Json.Serialization;
namespace EnvelopeGenerator.API.Models.PsPdfKitAnnotation;
/// <summary>
/// The Background is an annotation for the PSPDF Kit. However, it has no function.
/// It is only the first annotation as a background for other annotations.
/// </summary>
public record Background : IAnnotation
{
[JsonIgnore]
public double Margin { get; init; }
public string Name { get; } = "Background";
public double? Width { get; set; }
public double? Height { get; set; }
public double Left { get; set; }
public double Top { get; set; }
public Color? BackgroundColor { get; init; }
#region Border
public Color? BorderColor { get; init; }
public string? BorderStyle { get; init; }
public int? BorderWidth { get; set; }
#endregion
public void Locate(IEnumerable<IAnnotation> annotations)
{
// set Top
if (annotations.MinBy(a => a.Top)?.Top is double minTop)
Top = minTop;
// set Left
if (annotations.MinBy(a => a.Left)?.Left is double minLeft)
Left = minLeft;
// set Width
if(annotations.MaxBy(a => a.GetRight())?.GetRight() is double maxRight)
Width = maxRight - Left;
// set Height
if (annotations.MaxBy(a => a.GetBottom())?.GetBottom() is double maxBottom)
Height = maxBottom - Top;
// add margins
Top -= Margin;
Left -= Margin;
Width += Margin * 2;
Height += Margin * 2;
}
}

View File

@@ -1,10 +0,0 @@
namespace EnvelopeGenerator.API.Models.PsPdfKitAnnotation;
public record Color
{
public int R { get; init; } = 0;
public int G { get; init; } = 0;
public int B { get; init; } = 0;
}

View File

@@ -1,8 +0,0 @@
namespace EnvelopeGenerator.API.Models.PsPdfKitAnnotation;
public static class Extensions
{
public static double GetRight(this IAnnotation annotation) => annotation.Left + annotation?.Width ?? 0;
public static double GetBottom(this IAnnotation annotation) => annotation.Top + annotation?.Height ?? 0;
}

View File

@@ -1,22 +0,0 @@
namespace EnvelopeGenerator.API.Models.PsPdfKitAnnotation;
public interface IAnnotation
{
string Name { get; }
double? Width { get; }
double? Height { get; }
double Left { get; }
double Top { get; }
Color? BackgroundColor { get; }
Color? BorderColor { get; }
string? BorderStyle { get; }
int? BorderWidth { get; }
}

View File

@@ -1,17 +0,0 @@
namespace EnvelopeGenerator.API.Models;
/// <summary>
/// Represents the parameters for two-factor authentication (2FA) registration.
/// </summary>
public class TFARegParams
{
/// <summary>
/// The maximum allowed time for completing the registration process.
/// </summary>
public TimeSpan TimeLimit { get; init; } = new(0, 30, 0);
/// <summary>
/// The deadline for registration, calculated as the current time plus the <see cref="TimeLimit"/>.
/// </summary>
public DateTime Deadline => DateTime.Now.AddTicks(TimeLimit.Ticks);
}

View File

@@ -1,18 +0,0 @@
namespace EnvelopeGenerator.API.Options;
/// <summary>
/// Configuration options for distributed caching.
/// </summary>
public sealed class CacheOptions
{
/// <summary>
/// Configuration section name in appsettings.json.
/// </summary>
public const string SectionName = "Cache";
/// <summary>
/// Signature cache expiration time.
/// If null, signatures will not expire automatically.
/// </summary>
public TimeSpan? SignatureCacheExpiration { get; set; }
}

View File

@@ -1,355 +0,0 @@
using DigitalData.Core.API;
using DigitalData.Core.Application;
using EnvelopeGenerator.Infrastructure;
using EnvelopeGenerator.Domain.Constants;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Localization;
using Microsoft.EntityFrameworkCore;
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 EnvelopeGenerator.API.Models;
using Microsoft.IdentityModel.Tokens;
using DigitalData.Core.Abstractions.Security.Extensions;
using EnvelopeGenerator.API.Middleware;
using EnvelopeGenerator.API.Options;
using NLog.Web;
using NLog;
using DigitalData.Auth.Claims;
using EnvelopeGenerator.API;
using Microsoft.AspNetCore.Authentication.JwtBearer;
var logger = LogManager.Setup().LoadConfigurationFromAppSettings().GetCurrentClassLogger();
logger.Info("Logging initialized!");
try
{
var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddJsonFile("yarp.json", optional: true, reloadOnChange: true);
builder.Logging.SetMinimumLevel(Microsoft.Extensions.Logging.LogLevel.Trace);
if (!builder.Environment.IsDevelopment())
{
builder.Logging.ClearProviders();
builder.Host.UseNLog();
}
var config = builder.Configuration;
var deferredProvider = new DeferredServiceProvider();
builder.Services.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles;
});
builder.Services.AddHttpClient();
builder.Services.AddReverseProxy().LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
// CORS Policy
var allowedOrigins = config.GetSection("AllowedOrigins").Get<string[]>() ??
throw new InvalidOperationException("AllowedOrigins section is missing in the configuration.");
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowSpecificOriginsPolicy", builder =>
{
builder.WithOrigins(allowedOrigins)
.SetIsOriginAllowedToAllowWildcardSubdomains()
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials();
});
});
// Swagger
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo
{
Version = "v1",
Title = "signFLOW Absender-API",
Description = "Eine API zur Verwaltung der Erstellung, des Versands und der Nachverfolgung von Umschlägen in der signFLOW-Anwendung.",
Contact = new OpenApiContact
{
Name = "Digital Data GmbH",
Url = new Uri("https://digitaldata.works/digitale-signatur#kontakt"),
Email = "info-flow@digitaldata.works"
},
});
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Name = "Authorization",
Type = SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "JWT",
In = ParameterLocation.Header,
Description = "JWT-Autorisierungs-Header unter Verwendung des Bearer-Schemas.",
});
options.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = Microsoft.OpenApi.Models.ReferenceType.SecurityScheme,
Id = "Bearer"
}
},
Array.Empty<string>()
}
});
var xmlFiles = Directory.GetFiles(AppContext.BaseDirectory, "*.xml");
foreach (var xmlFile in xmlFiles)
{
options.IncludeXmlComments(xmlFile);
}
options.DocumentFilter<EnvelopeGenerator.API.Documentation.AuthProxyDocumentFilter>();
});
#if NET9_0_OR_GREATER
builder.Services.AddOpenApi();
#endif
//Add EF Core dbcontext
var useDbMigration = Environment.GetEnvironmentVariable("MIGRATION_TEST_MODE") == true.ToString() || config.GetValue<bool>("UseDbMigration");
var cnnStrName = useDbMigration ? "DbMigrationTest" : "Default";
var connStr = config.GetConnectionString(cnnStrName)
?? throw new InvalidOperationException($"Connection string '{cnnStrName}' is missing in the application configuration.");
builder.Services.Configure<ConnectionString>(cs => cs.Value = connStr);
builder.Services.AddDbContext<EGDbContext>(options => options.UseSqlServer(connStr));
builder.Services.AddAuthHubClient(config.GetSection("AuthClientParams"));
var authTokenKeys = config.GetOrDefault<AuthTokenKeys>();
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(AuthScheme.Sender, opt =>
{
opt.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKeyResolver = (token, securityToken, identifier, parameters) =>
{
var clientParams = deferredProvider.GetOptions<ClientParams>();
var publicKey = clientParams!.PublicKeys.Get(authTokenKeys.Issuer, authTokenKeys.Audience);
return [publicKey.SecurityKey];
},
ValidateIssuer = true,
ValidIssuer = authTokenKeys.Issuer,
ValidateAudience = true,
ValidAudience = authTokenKeys.Audience,
};
opt.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
// if there is no token read related cookie or query string
if (context.Token is null) // if there is no token
{
if (context.Request.Cookies.TryGetValue(authTokenKeys.Cookie, out var cookieToken) && cookieToken is not null)
context.Token = cookieToken;
else if (context.Request.Query.TryGetValue(authTokenKeys.QueryString, out var queryStrToken))
context.Token = queryStrToken;
}
return Task.CompletedTask;
}
};
})
// Per-envelope receiver scheme: reads the JWT from the cookie named
// AuthTokenSignFLOWReceiver.{envelope_key} where envelope_key is the
// last path segment of the request URL.
// This enables simultaneous authentication for multiple envelopes
// within the same browser session.
.AddJwtBearer(AuthScheme.Receiver, opt =>
{
opt.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKeyResolver = (token, securityToken, identifier, parameters) =>
{
var clientParams = deferredProvider.GetOptions<ClientParams>();
var publicKey = clientParams!.PublicKeys.Get(authTokenKeys.Issuer, authTokenKeys.Audience);
return [publicKey.SecurityKey];
},
ValidateIssuer = true,
ValidIssuer = authTokenKeys.Issuer,
ValidateAudience = true,
ValidAudience = authTokenKeys.Audience,
};
opt.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var paths = context.Request.Path.Value?.Split('/', StringSplitOptions.RemoveEmptyEntries);
// Derive the envelope key from the last route segment: /{envelope_key}
var envelopeKey = paths?.LastOrDefault();
if (envelopeKey is not null)
{
var cookieName = CookieNames.GetEnvelopeReceiverCookieName(authTokenKeys.Cookie, envelopeKey);
if (context.Request.Cookies.TryGetValue(cookieName, out var cookieToken) && cookieToken is not null)
context.Token = cookieToken;
}
return Task.CompletedTask;
},
OnTokenValidated = context =>
{
var paths = context.Request.Path.Value?.Split('/', StringSplitOptions.RemoveEmptyEntries);
var envelopeKey = paths?.LastOrDefault();
var sub = context.Principal?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value
?? context.Principal?.FindFirst("sub")?.Value;
if (envelopeKey is null || sub != envelopeKey)
context.Fail("Envelope key in the path does not match the token subject.");
return Task.CompletedTask;
}
};
});
// Authentication
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.Cookie.HttpOnly = true; // Makes the cookie inaccessible to client-side scripts for security
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; // Ensures cookies are sent over HTTPS only
options.Cookie.SameSite = SameSiteMode.Strict; // Protects against CSRF attacks by restricting how cookies are sent with requests from external sites
options.LoginPath = "/api/auth/login";
options.LogoutPath = "/api/auth/logout";
options.SlidingExpiration = true;
});
builder.Services.AddAuthorizationBuilder()
.AddPolicy(AuthPolicy.SenderOrReceiver, policy => policy
.RequireRole(Role.Sender, Role.Receiver.Full)
.AddAuthenticationSchemes(AuthScheme.Sender, AuthScheme.Receiver))
.AddPolicy(AuthPolicy.Sender, policy => policy
.RequireRole(Role.Sender)
.AddAuthenticationSchemes(AuthScheme.Sender))
.AddPolicy(AuthPolicy.Receiver, policy => policy
.AddAuthenticationSchemes(AuthScheme.Receiver)
.RequireAuthenticatedUser()
.RequireRole(Role.Receiver.Full, "receiver"))
.AddPolicy(AuthPolicy.ReceiverTFA, policy => policy.RequireRole(Role.Receiver.TFA));
// User manager
#pragma warning disable CS0618 // Type or member is obsolete
builder.Services.AddUserManager<EGDbContext>();
#pragma warning restore CS0618 // Type or member is obsolete
// LDAP
builder.ConfigureBySection<DirectorySearchOptions>();
builder.Services.AddDirectorySearchService(config.GetSection("DirectorySearchOptions"));
// Localizer
builder.Services.AddCookieBasedLocalizer();
// Cache options
builder.Services.Configure<CacheOptions>(config.GetSection(CacheOptions.SectionName));
// Distributed Cache - SQL Server
builder.Services.AddDistributedSqlServerCache(options =>
{
config.GetSection("Cache:SqlServer").Bind(options);
if (string.IsNullOrWhiteSpace(options.ConnectionString))
{
options.ConnectionString = connStr;
}
});
// Envelope generator serives
#pragma warning disable CS0618 // Type or member is obsolete
builder.Services
.AddEnvelopeGeneratorInfrastructureServices(opt =>
{
opt.AddDbTriggerParams(config);
opt.AddDbContext((provider, options) =>
{
var logger = provider.GetRequiredService<ILogger<EGDbContext>>();
options.UseSqlServer(connStr)
.LogTo(log => logger.LogInformation("{log}", log), Microsoft.Extensions.Logging.LogLevel.Trace)
.EnableSensitiveDataLogging()
.EnableDetailedErrors();
});
opt.AddSQLExecutor(executor => executor.ConnectionString = connStr);
})
.AddEnvelopeGeneratorServices(config);
#pragma warning restore CS0618 // Type or member is obsolete
var app = builder.Build();
deferredProvider.Factory = () => app.Services;
app.UseMiddleware<ExceptionHandlingMiddleware>();
#if NET9_0_OR_GREATER
app.MapOpenApi();
#endif
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment() || (app.IsDevOrDiP() && config.GetValue<bool>("UseSwagger")))
{
app.UseSwagger();
app.UseSwaggerUI();
app.MapScalarApiReference();
}
// Set CORS policy
app.UseCors("AllowSpecificOriginsPolicy");
// Localizer
string[] supportedCultureNames = ["de-DE", "en-US"];
IList<CultureInfo> list = [.. supportedCultureNames.Select(cn => new CultureInfo(cn))];
var cultureInfo = list.FirstOrDefault() ?? throw new InvalidOperationException("There is no supported culture.");
var requestLocalizationOptions = new RequestLocalizationOptions
{
SupportedCultures = list,
SupportedUICultures = list
};
requestLocalizationOptions.RequestCultureProviders.Add(new QueryStringRequestCultureProvider());
app.UseRequestLocalization(requestLocalizationOptions);
app.UseHttpsRedirection();
app.UseDefaultFiles();
app.UseStaticFiles();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
// Catch-all YARP proxy — only forward requests that are not swagger/scalar/openapi paths.
app.MapReverseProxy();
app.Run();
}
catch (Exception ex)
{
logger.Error(ex, "Stopped program because of exception");
throw;
}

View File

@@ -1,20 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
https://go.microsoft.com/fwlink/?LinkID=208121.
-->
<Project>
<PropertyGroup>
<WebPublishMethod>Package</WebPublishMethod>
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
<LastUsedPlatform>Any CPU</LastUsedPlatform>
<SiteUrlToLaunchAfterPublish />
<LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
<ExcludeApp_Data>false</ExcludeApp_Data>
<ProjectGuid>5e0e17c0-ff5a-4246-bf87-1add85376a27</ProjectGuid>
<DesktopBuildPackageLocation>M:\App&amp;Service\0 DD - Smart UP\signFLOW\PreRelease\API\net8\$(Version)\EnvelopeGenerator.API.zip</DesktopBuildPackageLocation>
<PackageAsSingleFile>true</PackageAsSingleFile>
<DeployIisAppPath>EnvelopeGenerator</DeployIisAppPath>
<_TargetId>IISWebDeployPackage</_TargetId>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
</Project>

View File

@@ -1,51 +0,0 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:4176",
"sslPort": 44300
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "http://localhost:5131",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "sender",
"applicationUrl": "https://localhost:8088;http://localhost:5131",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"angular": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "",
"applicationUrl": "https://localhost:7174;http://localhost:5131",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -1,29 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ReverseProxy": {
"Clusters": {
"receiver-ui": {
"Destinations": {
"primary": {
"Address": "https://localhost:52936"
}
}
}
}
},
"AuthClientParams": {
"Url": "http://172.24.12.39:9090/auth-hub",
"PublicKeys": [
{
"Issuer": "auth.digitaldata.works",
"Audience": "sign-flow.digitaldata.works"
}
],
"RetryDelay": "00:00:05"
}
}

View File

@@ -1,256 +0,0 @@
{
"UseSwagger": true,
"UseDbMigration": false,
"DiPMode": true,
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"AllowedOrigins": [ "http://localhost:4200", "http://172.24.12.39:9090", "https://localhost:8088", "http://localhost:5131", "http://localhost:7192" ],
"ConnectionStrings": {
"Default": "Server=SDD-VMP04-SQL17\\DD_DEVELOP01;Database=DD_ECM;User Id=sa;Password=dd;Encrypt=false;TrustServerCertificate=True;",
"DbMigrationTest": "Server=SDD-VMP04-SQL17\\DD_DEVELOP01;Database=DD_ECM_DATA_MIGR_TEST;User Id=sa;Password=dd;Encrypt=false;TrustServerCertificate=True;"
},
"DirectorySearchOptions": {
"ServerName": "DD-VMP01-DC01",
"Root": "DC=dd-gan,DC=local,DC=digitaldata,DC=works",
"UserCacheExpirationDays": 1,
"CustomSearchFilters": {
"User": "(&(objectClass=user)(sAMAccountName=*))",
"Group": "(&(objectClass=group)(samAccountName=*))"
}
},
"AuthClientParams": {
"Url": "http://172.24.12.39:9090/auth-hub",
"PublicKeys": [
{
"Issuer": "auth.digitaldata.works",
"Audience": "sign-flow.digitaldata.works"
}
],
"RetryDelay": "00:00:05"
},
"AuthTokenKeys": {
"Cookie": "AuthToken",
"QueryString": "AuthToken",
"Issuer": "auth.digitaldata.works",
"Audience": "sign-flow.digitaldata.works"
},
"PSPDFKitLicenseKey": "SXCtGGY9XA-31OGUXQK-r7c6AkdLGPm2ljuyDr1qu0kkhLvydg-Do-fxpNUF4Rq3fS_xAnZRNFRHbXpE6sQ2BMcCSVTcXVJO6tPviexjpiT-HnrDEySlUERJnnvh-tmeOWprxS6BySPnSILkmaVQtUfOIUS-cUbvvEYHTvQBKbSF8di4XHQFyfv49ihr51axm3NVV3AXwh2EiKL5C5XdqBZ4sQ4O7vXBjM2zvxdPxlxdcNYmiU83uAzw7B83O_jubPzya4CdUHh_YH7Nlp2gP56MeG1Sw2JhMtfG3Rj14Sg4ctaeL9p6AEWca5dDjJ2li5tFIV2fQSsw6A_cowLu0gtMm5i8IfJXeIcQbMC2-0wGv1oe9hZYJvFMdzhTM_FiejM0agemxt3lJyzuyP8zbBSOgp7Si6A85krLWPZptyZBTG7pp7IHboUHfPMxCXqi-zMsqewOJtQBE2mjntU-lPryKnssOpMPfswwQX7QSkJYV5EMqNmEhQX6mEkp2wcqFzMC7bJQew1aO4pOpvChUaMvb1vgRek0HxLag0nwQYX2YrYGh7F_xXJs-8HNwJe8H0-eW4x4faayCgM5rB5772CCCsD9ThZcvXFrjNHHLGJ8WuBUFm6LArvSfFQdii_7j-_sqHMpeKZt26NFgivj1A==",
"Content-Security-Policy": [ // The first format parameter {0} will be replaced by the nonce value.
"default-src 'self'",
"script-src 'self' 'nonce-{0}' 'unsafe-eval'",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com:*",
"img-src 'self' data: https: blob:",
"font-src 'self' https://fonts.gstatic.com:*",
"connect-src 'self' https://nominatim.openstreetmap.org:* http://localhost:* https://localhost:* ws://localhost:* wss://localhost:* blob:",
"frame-src 'self'",
"media-src 'self'",
"object-src 'self'"
],
"NLog": {
"throwConfigExceptions": true,
"variables": {
"logDirectory": "E:\\LogFiles\\Digital Data\\signFlow",
"logFileNamePrefix": "${shortdate}-ECM.EnvelopeGenerator.Web"
},
"targets": {
"infoLogs": {
"type": "File",
"fileName": "${logDirectory}\\${logFileNamePrefix}-Info.log",
"maxArchiveDays": 30
},
"errorLogs": {
"type": "File",
"fileName": "${logDirectory}\\${logFileNamePrefix}-Error.log",
"maxArchiveDays": 30
},
"criticalLogs": {
"type": "File",
"fileName": "${logDirectory}\\${logFileNamePrefix}-Critical.log",
"maxArchiveDays": 30
}
},
// Trace, Debug, Info, Warn, Error and *Fatal*
"rules": [
{
"logger": "*",
"minLevel": "Info",
"maxLevel": "Warn",
"writeTo": "infoLogs"
},
{
"logger": "*",
"level": "Error",
"writeTo": "errorLogs"
},
{
"logger": "*",
"level": "Fatal",
"writeTo": "criticalLogs"
}
]
},
"ContactLink": {
"Label": "Kontakt",
"Href": "https://digitaldata.works/",
"HrefLang": "de",
"Target": "_blank",
"Title": "Digital Data GmbH"
},
/* Resx naming format is -> Resource.language.resx (eg: Resource.de_DE.resx).
To add a new language, first you should write the required resx file.
first is the default culture name. */
"Cultures": [
{
"Language": "de-DE",
"FIClass": "fi-de"
},
{
"Language": "en-US",
"FIClass": "fi-us"
}
],
"DisableMultiLanguage": false,
"Regexes": [
{
"Pattern": "/^\\p{L}+(?:([\\ \\-\\']|(\\.\\ ))\\p{L}+)*$/u",
"Name": "City",
"Platforms": [ ".NET" ]
},
{
"Pattern": "/^[a-zA-Z\\u0080-\\u024F]+(?:([\\ \\-\\']|(\\.\\ ))[a-zA-Z\\u0080-\\u024F]+)*$/",
"Name": "City",
"Platforms": [ "javascript" ]
}
],
"CustomImages": {
"App": {
"Src": "/img/DD_signFLOW_LOGO.png",
"Classes": {
"Main": "signFlow-logo"
}
},
"Company": {
"Src": "/img/digital_data.svg",
"Classes": {
"Show": "dd-show-logo",
"Locked": "dd-locked-logo"
}
}
},
"DispatcherParams": {
"SendingProfile": 1,
"AddedWho": "DDEnvelopGenerator",
"ReminderTypeId": 202377,
"EmailAttmt1": ""
},
"MailParams": {
"Placeholders": {
"[NAME_PORTAL]": "signFlow",
"[SIGNATURE_TYPE]": "signieren",
"[REASON]": ""
}
},
"GtxMessagingParams": {
"Uri": "https://rest.gtx-messaging.net",
"Path": "smsc/sendsms/f566f7e5-bdf2-4a9a-bf52-ed88215a432e/json",
"Headers": {},
"QueryParams": {
"from": "signFlow"
}
},
"TFARegParams": {
"TimeLimit": "00:30:00"
},
"DbTriggerParams": {
"Envelope": [ "TBSIG_ENVELOPE_HISTORY_AFT_INS" ],
"EnvelopeHistory": [ "TBSIG_ENVELOPE_HISTORY_AFT_INS" ],
"EmailOut": [ "TBEMLP_EMAIL_OUT_AFT_INS", "TBEMLP_EMAIL_OUT_AFT_UPD" ],
"EnvelopeReceiverReadOnly": [ "TBSIG_ENVELOPE_RECEIVER_READ_ONLY_UPD" ],
"Receiver": [],
"EmailTemplate": [ "TBSIG_EMAIL_TEMPLATE_AFT_UPD" ]
},
"Cache": {
"SignatureCacheExpiration": null,
"SqlServer": {
"ConnectionString": null,
"SchemaName": "dbo",
"TableName": "TBDD_CACHE"
}
},
"MainPageTitle": null,
"AnnotationParams": {
"Background": {
"Margin": 0.20,
"BackgroundColor": {
"R": 222,
"G": 220,
"B": 215
},
"BorderColor": {
"R": 204,
"G": 202,
"B": 198
},
"BorderStyle": "underline",
"BorderWidth": 4
},
"DefaultAnnotation": {
"Width": 1,
"Height": 0.5,
"MarginTop": 1
},
"Annotations": [
{
"Name": "Signature",
"MarginTop": 0
},
{
"Name": "PositionLabel",
"VerBoundAnnotName": "Signature",
"WidthRatio": 1.2,
"HeightRatio": 0.5,
"MarginTopRatio": 0.22
},
{
"Name": "Position",
"VerBoundAnnotName": "PositionLabel",
"WidthRatio": 1.2,
"HeightRatio": 0.5,
"MarginTopRatio": -0.05
},
{
"Name": "CityLabel",
"VerBoundAnnotName": "Position",
"WidthRatio": 1.2,
"HeightRatio": 0.5,
"MarginTopRatio": 0.05
},
{
"Name": "City",
"VerBoundAnnotName": "CityLabel",
"WidthRatio": 1.2,
"HeightRatio": 0.5,
"MarginTopRatio": -0.05
},
{
"Name": "DateLabel",
"VerBoundAnnotName": "City",
"WidthRatio": 1.55,
"HeightRatio": 0.5,
"MarginTopRatio": 0.05
},
{
"Name": "Date",
"VerBoundAnnotName": "DateLabel",
"WidthRatio": 1.55,
"HeightRatio": 0.5,
"MarginTopRatio": -0.1
}
]
}
}

View File

@@ -1,185 +0,0 @@
{
"ReverseProxy": {
"Routes": {
"receiver-ui-root": {
"ClusterId": "receiver-ui",
"Order": 300,
"Match": {
"Path": "/",
"Methods": [ "GET", "HEAD" ]
},
"Transforms": [
{ "PathSet": "/index.html" }
]
},
"receiver-ui-sender": {
"ClusterId": "receiver-ui",
"Order": 100,
"Match": {
"Path": "/sender/{**catch-all}",
"Methods": [ "GET", "HEAD" ]
},
"Transforms": [
{ "PathSet": "/index.html" }
]
},
"receiver-ui-envelope": {
"ClusterId": "receiver-ui",
"Order": 100,
"Match": {
"Path": "/envelope/{EnvelopeKey}",
"Methods": [ "GET", "HEAD" ]
},
"Transforms": [
{ "PathSet": "/index.html" }
]
},
"receiver-ui-envelope-dxreportviewer": {
"ClusterId": "receiver-ui",
"Order": 90,
"Match": {
"Path": "/envelope/{EnvelopeKey}/DxReportViewer",
"Methods": [ "GET", "HEAD" ]
},
"Transforms": [
{ "PathSet": "/index.html" }
]
},
"receiver-ui-blazor-framework": {
"ClusterId": "receiver-ui",
"Order": 50,
"Match": {
"Path": "/_framework/{**catch-all}",
"Methods": [ "GET", "HEAD" ]
}
},
"receiver-ui-blazor-content": {
"ClusterId": "receiver-ui",
"Order": 50,
"Match": {
"Path": "/_content/{**catch-all}",
"Methods": [ "GET", "HEAD" ]
}
},
"receiver-ui-static-css": {
"ClusterId": "receiver-ui",
"Order": 200,
"Match": {
"Path": "/css/{**catch-all}",
"Methods": [ "GET", "HEAD" ]
},
"Transforms": [
{
"ResponseHeader": "Cache-Control",
"Set": "no-cache, no-store, must-revalidate",
"When": "Always"
}
]
},
"receiver-ui-static-js": {
"ClusterId": "receiver-ui",
"Order": 200,
"Match": {
"Path": "/js/{**catch-all}",
"Methods": [ "GET", "HEAD" ]
},
"Transforms": [
{
"ResponseHeader": "Cache-Control",
"Set": "no-cache, no-store, must-revalidate",
"When": "Always"
}
]
},
"receiver-ui-fake-data": {
"ClusterId": "receiver-ui",
"Order": 200,
"Match": {
"Path": "/fake-data/{**catch-all}",
"Methods": [ "GET", "HEAD" ]
}
},
"receiver-ui-appsettings": {
"ClusterId": "receiver-ui",
"Order": 50,
"Match": {
"Path": "/appsettings.json",
"Methods": [ "GET", "HEAD" ]
}
},
"receiver-ui-appsettings-dev": {
"ClusterId": "receiver-ui",
"Order": 50,
"Match": {
"Path": "/appsettings.Development.json",
"Methods": [ "GET", "HEAD" ]
}
},
"receiver-ui-styles": {
"ClusterId": "receiver-ui",
"Order": 50,
"Match": {
"Path": "/EnvelopeGenerator.ReceiverUI.styles.css",
"Methods": [ "GET", "HEAD" ]
}
},
"receiver-ui-fonts": {
"ClusterId": "receiver-ui",
"Order": 200,
"Match": {
"Path": "/fonts/{**catch-all}",
"Methods": [ "GET", "HEAD" ]
}
},
"receiver-ui-images": {
"ClusterId": "receiver-ui",
"Order": 200,
"Match": {
"Path": "/images/{**catch-all}",
"Methods": [ "GET", "HEAD" ]
}
},
"auth-login": {
"ClusterId": "auth-hub",
"Match": {
"Path": "/api/auth",
"Methods": [ "POST" ]
},
"Transforms": [
{ "PathSet": "/api/auth/sign-flow" }
]
},
"auth-envelope-receiver-login": {
"ClusterId": "auth-hub",
"Match": {
"Path": "/api/Auth/envelope-receiver/{key}",
"Methods": [ "POST" ]
},
"Transforms": [
{ "PathPattern": "/api/auth/envelope-receiver/{key}" },
{
"QueryValueParameter": "cookie",
"Set": "true"
}
]
}
},
"Clusters": {
"receiver-ui": {
"Destinations": {
"primary": {
"Address": "https://localhost:52936"
}
}
},
"auth-hub": {
"Destinations": {
"primary": {
"Address": "http://172.24.12.39:9090"
}
}
}
}
}
}

View File

@@ -49,7 +49,7 @@ public sealed record Signature
public string? Position
{
get => _position;
init => _position = string.IsNullOrWhiteSpace(value) ? value : null;
init => _position = string.IsNullOrWhiteSpace(value) ? null : value;
}
/// <summary>
@@ -62,4 +62,4 @@ public sealed record Signature
/// <b>Example:</b> "Berlin" ? rendered as "Berlin, 26.01.2025"
/// </summary>
public required string Place { get; init; }
}
}

View File

@@ -65,18 +65,21 @@ public static class DependencyInjection
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, EnvelopeReceiverResolutionBehavior>();
// 1. AnnotationBehavior - Saves annotations (executes second)
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, SaveSignatureBehavior>();
// 2. AnnotationBehavior - Saves annotations for legacy web flow
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, AnnotationBehavior>();
// 2. DocStatusBehavior - Creates document status (executes third)
// 3. DocStatusBehavior - Creates document status
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, DocStatusBehavior>();
// 3. HistoryBehavior - Records history (executes fourth)
// 4. HistoryBehavior - Records history
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, HistoryBehavior>();
// 4. SendSignedMailBehavior - Sends notification email (executes LAST, only if all previous succeed)
// 5. SendSignedMailBehavior - Sends notification email (executes LAST)
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, SendSignedMailBehavior>();
});
return services;
}
}
}

View File

@@ -43,7 +43,11 @@ public class EnvelopeReceiverResolutionBehavior : IPipelineBehavior<SigningComma
// If EnvelopeReceiver is not provided, query it from database
if (request.EnvelopeReceiver is null)
{
var er = await _erRepo.Query.Where(request, notnull: true).SingleOrDefaultAsync(cancellationToken)
var er = await _erRepo.Query
.Where(request, notnull: true)
.Include(x => x.Envelope)
.Include(x => x.Receiver)
.SingleOrDefaultAsync(cancellationToken)
?? throw new NotFoundException("EnvelopeReceiver not found");
request.SetEnvelopeReceiver(_mapper.Map<EnvelopeReceiverDto>(er));

View File

@@ -50,8 +50,8 @@ public class SaveSignatureBehavior : IPipelineBehavior<SigningCommand, Unit>
throw new BadRequestException($"Signatures are required for saving signature behavior.");
var elements = await _elementRepo
.Where(e => e.Document.EnvelopeId == request.Envelope.Id)
.Where(e => e.ReceiverId == request.Receiver.Id)
.Where(e => e.Document.EnvelopeId == request.EnvelopeReceiver.EnvelopeId)
.Where(e => e.ReceiverId == request.EnvelopeReceiver.ReceiverId)
.ToListAsync(cancel);
foreach (var element in elements)

View File

@@ -204,16 +204,24 @@ public abstract class EGDbContextBase : DbContext
#region Trigger
// Configure entities to handle database triggers
void AddTrigger<T>() where T : class => _triggers
.Where(t => t.Key == typeof(T).Name)
.SelectMany(t => t.Value)
.ForEach(tName =>
void AddTrigger<T>() where T : class
{
var entityName = typeof(T).Name;
var configuredTriggerNames = _triggers
.Where(t => t.Key == entityName)
.SelectMany(t => t.Value)
.Distinct()
.ToList();
foreach (var triggerName in configuredTriggerNames)
{
#if NET
modelBuilder.Entity<T>().ToTable(tb => tb.HasTrigger(tName));
modelBuilder.Entity<T>().ToTable(tb => tb.HasTrigger(triggerName));
#endif
_logger?.LogInformation("Trigger '{triggerName}' has been added to the '{entityName}' entity.", tName, typeof(T).Name);
});
_logger?.LogInformation("Trigger '{triggerName}' has been added to the '{entityName}' entity.", triggerName, entityName);
}
}
// TODO: call add trigger methods with attributes and reflection
AddTrigger<Config>();
@@ -243,4 +251,4 @@ public abstract class EGDbContextBase : DbContext
#if NETFRAMEWORK
}
#endif
#endif

View File

@@ -0,0 +1,10 @@
namespace EnvelopeGenerator.Server.Client.Models;
public sealed record SignatureSubmitDto
{
public required int Id { get; init; }
public required string DataUrl { get; init; }
public required string FullName { get; init; }
public string? Position { get; init; }
public required string Place { get; init; }
}

View File

@@ -1,6 +1,7 @@
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Server.Client.Models;
namespace EnvelopeGenerator.Server.Client.Services;
@@ -21,4 +22,32 @@ public class SignatureService(IHttpClientFactory httpClientFactory)
var result = await response.Content.ReadFromJsonAsync<List<SignatureDto>>(_jsonOptions, cancel);
return result ?? [];
}
public async Task SubmitAsync(string envelopeKey, IEnumerable<SignatureSubmitDto> signatures, CancellationToken cancel = default)
{
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
var payload = signatures
.Select(signature => new Signature
{
Id = signature.Id,
DataUrl = signature.DataUrl,
FullName = signature.FullName,
Position = signature.Position,
Place = signature.Place
})
.ToList();
var response = await http.PostAsJsonAsync(
$"/api/Signature/submit/{Uri.EscapeDataString(envelopeKey)}",
payload,
cancel);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(cancel);
throw new HttpRequestException(
$"Failed to submit signature for envelope {envelopeKey}: {(int)response.StatusCode} {response.StatusCode} - {body}");
}
}
}

View File

@@ -43,7 +43,10 @@
</svg>
}
</div>
<div class="sender-title">Umschlag-Übersicht</div>
<div class="sender-title" aria-label="signFLOW Portal - Übersicht">
<span class="sender-title-main">signFLOW Portal</span>
<span class="sender-title-sub">Übersicht</span>
</div>
</div>
@* ── Kunden-Logo (Mitte) ───────────────────────────────────────── *@
@@ -51,7 +54,7 @@
@if (_companyLogo is not null && !string.IsNullOrEmpty(_companyLogo.Src))
{
<img src="@_companyLogo.Src"
style="height: @_companyLogo.Height"
style="--sf-company-logo-height: @_companyLogo.Height"
class="@_companyLogo.GetClass("Show")"
alt="Kunden-Logo" />
}
@@ -104,6 +107,40 @@
Vorschau
</button>
<div class="sender-export-group" title="Export der aktuellen Tabellenansicht">
<select id="sender-export-format" class="sender-export-format" @bind="_selectedExportFormat" aria-label="Exportformat auswählen">
<option value="xlsx">XLSX</option>
<option value="csv">CSV</option>
<option value="pdf">PDF</option>
</select>
<label class="sender-export-selected">
<input type="checkbox" @bind="_exportSelectedRowsOnly" />
<span class="sender-export-selected-text">
<span>Nur</span>
<span>ausgewählte</span>
</span>
</label>
<button class="sender-btn sender-btn--export"
@onclick="ExportSelectedFormatAsync"
disabled="@IsExportDisabled()"
title="Aktuelle Ansicht exportieren">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M.5 9.9a.5.5 0 0 1 .5.5V13a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.6a.5.5 0 0 1 1 0V13a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.6a.5.5 0 0 1 .5-.5z" />
<path d="M5.354 6.146a.5.5 0 0 1 .707 0L7.5 7.586V.5a.5.5 0 0 1 1 0v7.086l1.439-1.44a.5.5 0 0 1 .707.708l-2.293 2.293a.5.5 0 0 1-.707 0L5.354 6.854a.5.5 0 0 1 0-.708z" />
</svg>
@if (_isExporting)
{
<span class="spinner-border spinner-border-sm" style="width: 14px; height: 14px;"></span>
}
else
{
<span>Exportieren</span>
}
</button>
</div>
<button class="sender-btn sender-btn--logout" @onclick="LogoutAsync" disabled="@_isLoggingOut" title="Abmelden">
@if (_isLoggingOut)
{
@@ -171,19 +208,11 @@
</div>
<div class="sender-grid-wrapper">
@* Grid persistence hint — informs users their layout is auto-saved *@
<div class="sf-grid-persistence-hint">
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" fill="currentColor" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
<path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533L8.93 6.588zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0z" />
</svg>
Die Größe und Position der Spalten wird automatisch für Sie gespeichert.
</div>
@if (_activeTab == "active")
{
<DxGrid Data="@_activeEnvelopes"
@ref="_gridActive"
KeyFieldName="Id"
ShowFilterRow="true"
ShowSearchBox="true"
AllowColumnReorder="true"
@@ -213,7 +242,7 @@
<strong>@((cellContext.DataItem as EnvelopeDto)?.Title)</strong>
</CellDisplayTemplate>
</DxGridDataColumn>
<DxGridDataColumn FieldName="Status" Caption="Status">
<DxGridDataColumn FieldName="Status" Caption="Status" ExportEnabled="false">
<CellDisplayTemplate Context="cellContext">
@{
var envelope = cellContext.DataItem as EnvelopeDto;
@@ -228,7 +257,7 @@
}
</CellDisplayTemplate>
</DxGridDataColumn>
<DxGridDataColumn FieldName="EnvelopeReceivers" Caption="Empfänger">
<DxGridDataColumn FieldName="EnvelopeReceivers" Caption="Empfänger" ExportEnabled="false">
<CellDisplayTemplate Context="cellContext">
@{
var envelope = cellContext.DataItem as EnvelopeDto;
@@ -308,6 +337,7 @@
{
<DxGrid Data="@_completedEnvelopes"
@ref="_gridCompleted"
KeyFieldName="Id"
ShowFilterRow="true"
ShowSearchBox="true"
PageSize="20"
@@ -334,7 +364,7 @@
<strong>@((cellContext.DataItem as EnvelopeDto)?.Title)</strong>
</CellDisplayTemplate>
</DxGridDataColumn>
<DxGridDataColumn FieldName="Status" Caption="Status">
<DxGridDataColumn FieldName="Status" Caption="Status" ExportEnabled="false">
<CellDisplayTemplate Context="cellContext">
@{
var envelope = cellContext.DataItem as EnvelopeDto;
@@ -349,7 +379,7 @@
}
</CellDisplayTemplate>
</DxGridDataColumn>
<DxGridDataColumn FieldName="EnvelopeReceivers" Caption="Empfänger">
<DxGridDataColumn FieldName="EnvelopeReceivers" Caption="Empfänger" ExportEnabled="false">
<CellDisplayTemplate Context="cellContext">
@{
var envelope = cellContext.DataItem as EnvelopeDto;
@@ -514,6 +544,11 @@
private byte[]? _previewPdfBytes = null;
private string? _previewErrorMessage = null;
// Phase 6e (step 1): UI state only. Export logic is added in step 2.
private string _selectedExportFormat = "xlsx";
private bool _exportSelectedRowsOnly = false;
private bool _isExporting = false;
// Stable keys used to store/restore each grid's layout in UserPreferences.GridLayouts
private const string GridKeyActive = "sender.active-envelopes";
private const string GridKeyCompleted = "sender.completed-envelopes";
@@ -572,6 +607,66 @@
await LoadEnvelopesAsync();
}
bool IsExportDisabled()
{
if (_isLoading || _isExporting)
return true;
return _exportSelectedRowsOnly && _selectedEnvelope is null;
}
async Task ExportSelectedFormatAsync()
{
var grid = _activeTab == "active" ? _gridActive : _gridCompleted;
if (grid is null)
return;
_isExporting = true;
await InvokeAsync(StateHasChanged);
try
{
var tabToken = _activeTab == "completed" ? "abgeschlossen" : "aktiv";
var stamp = DateTime.Now.ToString("yyyy-MM-dd");
var exportBaseName = $"Umschlaege_{tabToken}_{stamp}";
switch (_selectedExportFormat)
{
case "csv":
await grid.ExportToCsvAsync(
exportBaseName,
new GridCsvExportOptions
{
ExportSelectedRowsOnly = _exportSelectedRowsOnly
});
break;
case "pdf":
await grid.ExportToPdfAsync(
exportBaseName,
new GridPdfExportOptions
{
ExportSelectedRowsOnly = _exportSelectedRowsOnly,
SelectedRowsExportMode = GridSelectedRowsExportMode.KeepGrouping
});
break;
default:
await grid.ExportToXlsxAsync(
exportBaseName,
new GridXlExportOptions
{
ExportSelectedRowsOnly = _exportSelectedRowsOnly,
SelectedRowsExportMode = GridSelectedRowsExportMode.KeepGrouping
});
break;
}
}
finally
{
_isExporting = false;
await InvokeAsync(StateHasChanged);
}
}
void CreateEnvelope()
{
Navigation.NavigateTo("/sender/editor");
@@ -753,4 +848,4 @@
var json = JsonSerializer.Serialize(e.Layout);
await UserPreferencesService.SaveGridLayoutAsync(GridKeyCompleted, json);
}
}
}

View File

@@ -12,6 +12,7 @@
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverAuthorizationService ReceiverAuthorizationService
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverPageDataService PageDataService
@inject SignatureService SignatureService
@inject AppVersionService AppVersion
@inject IMemoryCache MemoryCache
@inject ILogger<ReceiverSignedPage> Logger
@@ -122,6 +123,12 @@
</p>
</div>
</div>
@if (!string.IsNullOrWhiteSpace(_submitErrorMessage))
{
<div class="alert alert-danger mb-0" role="alert" style="font-size: 0.85rem;">
@_submitErrorMessage
</div>
}
</BodyContentTemplate>
<FooterContentTemplate>
<div class="d-flex gap-2 justify-content-end w-100" style="padding: 0.5rem 0;">
@@ -161,17 +168,48 @@
// ----- Submit / logout state -----
bool _isLoggingOut = false;
bool _submitConfirmVisible = false;
string? _submitErrorMessage;
void OpenSubmitConfirmPopup() => _submitConfirmVisible = true;
async Task SubmitAndLogoutAsync()
{
if (_isLoggingOut) return;
if (string.IsNullOrWhiteSpace(EnvelopeKey) || _sig is null)
return;
_isLoggingOut = true;
_submitConfirmVisible = false;
_submitErrorMessage = null;
await InvokeAsync(StateHasChanged);
await AuthService.LogoutEnvelopeReceiverAsync(EnvelopeKey!);
Navigation.NavigateTo("/", forceLoad: true);
try
{
if (_signatures.Count == 0)
throw new InvalidOperationException("No signature fields found for receiver.");
var payload = _signatures.Select(field => new SignatureSubmitDto
{
Id = field.Id,
DataUrl = _sig.DataUrl,
FullName = _sig.FullName,
Position = string.IsNullOrWhiteSpace(_sig.Position) ? null : _sig.Position,
Place = _sig.Place
}).ToList();
await SignatureService.SubmitAsync(EnvelopeKey, payload);
await AuthService.LogoutEnvelopeReceiverAsync(EnvelopeKey);
Navigation.NavigateTo("/", forceLoad: true);
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to finalize signature for {EnvelopeKey}", EnvelopeKey);
_submitErrorMessage = "Die Unterschrift konnte nicht abgeschlossen werden. Bitte versuchen Sie es erneut.";
_isLoggingOut = false;
await InvokeAsync(StateHasChanged);
}
}
protected override async Task OnInitializedAsync()

View File

@@ -1,7 +1,10 @@
using EnvelopeGenerator.Server.Extensions;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Extensions;
using EnvelopeGenerator.Application.DocReceiverElements.Commands;
using EnvelopeGenerator.Application.Documents.Queries;
using EnvelopeGenerator.Application.EnvelopeReceivers.Queries;
using EnvelopeGenerator.Application.Histories.Queries;
using EnvelopeGenerator.Domain.Constants;
using MediatR;
using Microsoft.AspNetCore.Authorization;
@@ -18,13 +21,15 @@ namespace EnvelopeGenerator.Server.Controllers;
public class SignatureController : ControllerBase
{
private readonly IMediator _mediator;
private readonly ILogger<SignatureController> _logger;
/// <summary>
/// Initializes a new instance of <see cref="SignatureController"/>.
/// </summary>
public SignatureController(IMediator mediator)
public SignatureController(IMediator mediator, ILogger<SignatureController> logger)
{
_mediator = mediator;
_logger = logger;
}
//TODO: update to use signature query
@@ -54,4 +59,46 @@ public class SignatureController : ControllerBase
else
return Ok(rcvSignatures);
}
/// <summary>
/// Finalizes the receiver signing flow and persists signature data to TBSIG_DOCUMENT_RECEIVER_ELEMENT.
/// Also triggers status/history/mail side effects through SigningCommand pipeline.
/// </summary>
[Authorize(Policy = AuthPolicy.Receiver)]
[HttpPost("submit/{envelopeKey}")]
public async Task<IActionResult> Submit(
[FromRoute] string envelopeKey,
[FromBody] IEnumerable<Signature>? signatures,
CancellationToken cancel)
{
if (signatures is null)
return BadRequest("Signature payload is required.");
var uuid = User.EnvelopeUuid();
var receiverSignature = User.ReceiverSignature();
if (await _mediator.IsSignedAsync(uuid, receiverSignature, cancel))
return Problem(statusCode: StatusCodes.Status409Conflict);
if (await _mediator.AnyHistoryAsync(uuid, new[] { EnvelopeStatus.EnvelopeRejected, EnvelopeStatus.DocumentRejected }, cancel))
return Problem(statusCode: StatusCodes.Status423Locked);
try
{
await _mediator.Send(new SigningCommand
{
Envelope = new() { Uuid = uuid },
Receiver = new() { Signature = receiverSignature },
ReceiverAppType = ReceiverAppType.ReceiverUI,
Signatures = signatures.ToList()
}, cancel);
return Ok();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to submit signatures for envelope {EnvelopeKey}", envelopeKey);
throw;
}
}
}

View File

@@ -13,7 +13,7 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:8088;http://localhost:5131",
"applicationUrl": "https://localhost:8089;http://localhost:5132",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},

View File

@@ -185,8 +185,10 @@
"TimeLimit": "00:30:00"
},
"DbTriggerParams": {
"DocReceiverElement": [ "TBSIG_DOCUMENT_RECEIVER_ELEMENT_AFT_INS", "TBSIG_DOCUMENT_RECEIVER_ELEMENT_AFT_UPD" ],
"Envelope": [ "TBSIG_ENVELOPE_HISTORY_AFT_INS" ],
"EnvelopeHistory": [ "TBSIG_ENVELOPE_HISTORY_AFT_INS" ],
"History": [ "TBSIG_ENVELOPE_HISTORY_AFT_INS" ],
"EmailOut": [ "TBEMLP_EMAIL_OUT_AFT_INS", "TBEMLP_EMAIL_OUT_AFT_UPD" ],
"EnvelopeReceiverReadOnly": [ "TBSIG_ENVELOPE_RECEIVER_READ_ONLY_UPD" ],
"Receiver": [],

View File

@@ -22,13 +22,29 @@
align-items: center;
justify-content: space-between;
gap: 1.5rem;
overflow: hidden;
}
.sender-title-section {
display: flex;
align-items: center;
gap: 1rem;
gap: 0.75rem;
flex-shrink: 0; /* don't shrink — logo area in the middle gets the space */
min-width: 0;
position: relative;
z-index: 2;
}
.sender-logo {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
}
.sender-logo .signflow-app-logo {
height: clamp(20px, 1.6vw, 24px) !important;
width: auto;
}
/* ── Customer logo area (middle of action bar) ──────────────────────────── */
@@ -40,6 +56,13 @@
min-width: 0; /* allow flex child to shrink below content size */
}
.sender-customer-logo-area .signflow-company-logo {
height: clamp(13px, 1.45vw, 18px);
max-width: min(24vw, 190px);
width: auto;
object-fit: contain;
}
/* Placeholder shown when no customer logo is configured */
.sf-customer-logo-placeholder {
display: flex;
@@ -64,10 +87,29 @@
}
.sender-title {
font-size: 1.25rem;
font-weight: 700;
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
gap: 0.08rem;
line-height: 1.12;
color: #1e293b;
letter-spacing: -0.025em;
min-width: 0;
}
.sender-title-main {
font-size: clamp(1rem, 1.25vw, 1.12rem);
font-weight: 700;
letter-spacing: -0.02em;
white-space: normal;
}
.sender-title-sub {
color: #475569;
font-size: clamp(0.72rem, 0.95vw, 0.78rem);
font-weight: 600;
letter-spacing: 0.01em;
white-space: normal;
}
.sender-toolbar {
@@ -136,6 +178,58 @@
min-width: 38px;
}
.sender-btn--export {
padding-inline: 0.875rem;
}
.sender-export-group {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.35rem 0.5rem;
border: 1px solid rgba(126, 34, 206, 0.18);
border-radius: 10px;
background: rgba(126, 34, 206, 0.04);
}
.sender-export-format {
height: 32px;
min-width: 84px;
padding: 0 0.5rem;
border: 1px solid rgba(126, 34, 206, 0.25);
border-radius: 8px;
background: #fff;
color: #1e293b;
font-size: 0.8rem;
font-weight: 600;
outline: none;
}
.sender-export-format:focus {
border-color: rgba(126, 34, 206, 0.55);
box-shadow: 0 0 0 2px rgba(126, 34, 206, 0.12);
}
.sender-export-selected {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.74rem;
color: #4b5563;
white-space: nowrap;
}
.sender-export-selected-text {
display: inline-flex;
flex-direction: column;
justify-content: center;
line-height: 1.05;
}
.sender-export-selected input[type="checkbox"] {
margin: 0;
}
.sender-content {
flex: 1;
min-height: 0;
@@ -199,20 +293,7 @@
}
.sender-grid-wrapper {
padding: 0 2rem 2rem; /* top padding removed — hint banner provides top spacing */
}
/* ── Grid persistence hint banner ─────────────────────────────────────────── */
.sf-grid-persistence-hint {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 2rem;
font-size: 0.72rem;
color: rgba(126, 34, 206, 0.65);
background: rgba(126, 34, 206, 0.04);
border-bottom: 1px solid rgba(126, 34, 206, 0.08);
user-select: none;
padding: 1rem 2rem 2rem;
}
/* Hide DevExpress empty cells */
@@ -300,13 +381,62 @@
color: #c62828;
}
@@media (max-width: 768px) {
@media (max-width: 1320px) {
.sender-action-bar__inner {
flex-wrap: wrap;
row-gap: 0.75rem;
}
.sender-customer-logo-area {
order: 2;
flex: 1 1 100%;
justify-content: flex-start;
padding-left: 2.4rem;
margin-top: 0;
}
.sender-toolbar {
order: 3;
width: 100%;
}
.sender-title-main {
font-size: 1.02rem;
}
.sender-title-sub {
font-size: 0.72rem;
}
}
@media (max-width: 768px) {
.sender-action-bar {
padding: 1rem 1.25rem;
}
.sender-action-bar__inner {
flex-wrap: wrap;
gap: 0.9rem;
}
.sender-title-section {
flex: 1 1 auto;
min-width: 220px;
}
.sender-logo .signflow-app-logo {
height: 20px !important;
}
.sender-customer-logo-area .signflow-company-logo {
height: 13px;
max-width: 150px;
}
.sender-customer-logo-area {
padding-left: 0;
justify-content: center;
margin-top: 0;
}
.sender-toolbar {
@@ -314,8 +444,25 @@
justify-content: flex-start;
}
.sender-export-group {
order: 98;
}
.sender-btn--logout {
order: 99;
}
.sender-title {
font-size: 1.125rem;
margin-top: 0;
gap: 0;
}
.sender-title-main {
font-size: 0.94rem;
}
.sender-title-sub {
font-size: 0.68rem;
}
.sender-content {
@@ -337,6 +484,18 @@
}
}
@media (max-width: 560px) {
.sender-title-section {
flex-direction: column;
align-items: flex-start;
gap: 0.4rem;
}
.sender-title {
padding-left: 0.1rem;
}
}
/* ── Theme overrides ─────────────────────────────────────────────────────── */
/* Selector: [data-sf-theme="<name>"] set on <html> by signflow-theme.js */
@@ -373,6 +532,20 @@
box-shadow: 0 4px 12px rgba(147, 51, 234, 0.22);
}
[data-sf-theme="purple"] .sender-export-group {
border-color: rgba(147, 51, 234, 0.28);
background: rgba(147, 51, 234, 0.08);
}
[data-sf-theme="purple"] .sender-export-format {
border-color: rgba(147, 51, 234, 0.36);
}
[data-sf-theme="purple"] .sender-export-format:focus {
border-color: rgba(147, 51, 234, 0.6);
box-shadow: 0 0 0 2px rgba(147, 51, 234, 0.14);
}
[data-sf-theme="purple"] .sender-tabs {
border-bottom-color: rgba(147, 51, 234, 0.12);
}
@@ -382,6 +555,10 @@
border-bottom-color: #9333ea;
}
[data-sf-theme="purple"] .sender-title-sub {
color: #6b21a8;
}
/* ── Blazing Dark theme ────────────────────────────────────────────────────
Dark navy background, frosted glass surfaces turn semi-dark */
[data-sf-theme="blazing-dark"] .sender-dashboard-layout {
@@ -398,6 +575,10 @@
color: rgba(255, 255, 255, 0.92);
}
[data-sf-theme="blazing-dark"] .sender-title-sub {
color: rgba(224, 231, 255, 0.82);
}
[data-sf-theme="blazing-dark"] .sender-logo svg {
color: #818cf8;
filter: drop-shadow(0 2px 4px rgba(129, 140, 248, 0.3));
@@ -445,6 +626,27 @@
border-color: transparent;
}
[data-sf-theme="blazing-dark"] .sender-export-group {
border-color: rgba(129, 140, 248, 0.26);
background: rgba(99, 102, 241, 0.08);
}
[data-sf-theme="blazing-dark"] .sender-export-label,
[data-sf-theme="blazing-dark"] .sender-export-selected {
color: rgba(255, 255, 255, 0.72);
}
[data-sf-theme="blazing-dark"] .sender-export-format {
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.9);
border-color: rgba(129, 140, 248, 0.35);
}
[data-sf-theme="blazing-dark"] .sender-export-format:focus {
border-color: rgba(129, 140, 248, 0.58);
box-shadow: 0 0 0 2px rgba(129, 140, 248, 0.16);
}
[data-sf-theme="blazing-dark"] .sender-grid-container {
background: rgba(15, 20, 40, 0.95);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.06);

View File

@@ -1,418 +0,0 @@
# SF-74 — signFLOW: Theme, Dark Mode, Grid Persistence, Logo & Dashboard
**Ticket:** SF-74 | **Bearbeiter:** Hakan Tek | **Status:** In Bearbeitung
**Letzte Aktualisierung:** 2026-09-03
**Erstellt von:** Marlon Schreiber | **Verknüpft mit:** SF-71
---
## Originale Anforderungen
### Marlon Schreiber (Ticket-Text):
1. **signFLOW-Logo** + **Kunden-Logo-Bereich** auf der Startseite
2. **DevExpress Skin-Auswahl** — mindestens 3 Themes, vom User wählbar + gespeichert
3. **Grid-Layout-Persistenz** — Spaltenbreite/Position pro User gespeichert
### Screenshot-Annotationen (`/sender`):
```
[signFLOW Logo] Umschlag-Übersicht [Kunden-Logo] [+Neuer Umschlag][Bearb.][Lösch.][↺][→|]
signFLOW Portal-Übersicht [Zurückrufen/löschen][Dok.anzeigen][Excel]
[Info-Banner: "Die Größe und Position der Spalten muss verändert und für den User gespeichert..."]
ID ↓ Titel Status [Typ] Empfänger [Erstellt] [Geändert am]
```
---
## Projektstruktur — Clean Architecture
```
EnvelopeGenerator.Domain/ → Entities, Domain-Interfaces
EnvelopeGenerator.Application/ → Service-Interfaces, DTOs, Use-Cases
EnvelopeGenerator.Infrastructure/ → Cache, DB, Implementierungen
EnvelopeGenerator.Server/ → Blazor SSR Host, Controller, Middleware
EnvelopeGenerator.Server.Client/ → Blazor WASM Client
```
**Abhängigkeitsregel:** `Domain ← Application ← Infrastructure ← Server / Server.Client`
---
## ✅ Abgeschlossene Phasen (1–5, 6a–6c)
| Phase | Inhalt | Status |
|-------|--------|--------|
| 1 | Server: UserPreferences API (TBDD_CACHE, `IUserPreferencesService`) | ✅ |
| 2 | WASM: `ThemeService`, `UserPreferencesService` (1h In-Memory-Cache) | ✅ |
| 3 | Footer Theme Slider (🌙 blazing-dark ── blazing-berry ── purple ☀️) | ✅ |
| 4 | Grid Layout Persistence (`LayoutAutoSaving`/`LayoutAutoLoading`) | ✅ |
| 5 | Theme-CSS-Overrides für alle Seiten (`[data-sf-theme]`) | ✅ |
| 6a | signFLOW-Logo (CustomImages Options, Controller, Service, Dateien) | ✅ |
| 6b | Kunden-Logo Placeholder (sender-customer-logo-area, CSS) | ✅ |
| 6c | Grid-Spalten (EnvelopeTypeTitle, AddedWhen, ChangedWhen) + Persistence Hint | ✅ |
**Key decisions:** TBDD_CACHE statt eigener DB-Tabelle · `SenderOrReceiver` Policy · Slider statt Toggle · `signflow.theme` in localStorage · DevExpress 25.2.3 hat keine `.dark.`-Suffix-Dateien
---
## ✅ Phase 6a — signFLOW-Logo
### Ziel
signFLOW-App-Logo im Sender Dashboard (`/sender`) Action Bar links neben dem Titel, sowie auf der Startseite (`/`) im Hero-Header.
### Technischer Ansatz
**Options Pattern** — `appsettings.json` → `CustomImagesOptions` → DI → Controller → WASM Service.
```
appsettings.json
└── CustomImagesOptions (Options Pattern, IOptions<>)
└── GET /api/CustomImages (neuer Controller, auth: SenderOrReceiver)
└── CustomImagesService (WASM, in-memory cache, kein TTL)
└── EnvelopeSenderPage + IndexPage
```
**Warum kein `wwwroot/appsettings.json`?**
Statische Assets sind public (keine Auth). Logo-Pfade sind deployment-spezifisch.
Options Pattern erlaubt Produktionswechsel ohne Code-Änderung.
### Neue/geänderte Dateien
#### `Server/Models/CustomImages.cs` — NEU
```csharp
public sealed class CustomImageEntry
{
public string Src { get; set; } = string.Empty;
/// <summary>CSS height, e.g. "28px" or "2rem". Change per deployment in appsettings.</summary>
public string Height { get; set; } = "28px";
public Dictionary<string, string> Classes { get; set; } = new();
public string GetClass(string key) =>
Classes.TryGetValue(key, out var cls) ? cls ?? string.Empty : string.Empty;
}
public sealed class CustomImagesOptions
{
public const string SectionName = "CustomImages";
public CustomImageEntry App { get; set; } = new();
public CustomImageEntry Company { get; set; } = new();
}
```
#### `appsettings.json` — Änderung (Zeile 145)
```json
"CustomImages": {
"App": { "Src": "/img/DD_signFLOW_LOGO.png", "Height": "28px",
"Classes": { "Main": "signflow-app-logo" } },
"Company": { "Src": "/img/digital_data.svg", "Height": "22px",
"Classes": { "Show": "signflow-company-logo" } }
}
```
> **Deployment:** Nur `Src` + `Height` in `appsettings.json` ändern — keine Code-Anpassung nötig.
#### `Server/Controllers/CustomImagesController.cs` — NEU
```
GET /api/CustomImages
Auth: [Authorize(Policy = AuthPolicy.SenderOrReceiver)]
Returns: CustomImagesOptions
```
#### `Server/Program.cs` — DI
```csharp
builder.Services.Configure<CustomImagesOptions>(
config.GetSection(CustomImagesOptions.SectionName));
```
#### `Server/wwwroot/img/` — Neue Dateien
Kopiert aus `EnvelopeGenerator.Web/wwwroot/img/`:
- `DD_signFLOW_LOGO.png`
- `digital_data.svg`
#### `Client/Services/CustomImagesService.cs` — NEU
```csharp
// GET /api/CustomImages → einmalig laden, in-memory gecacht (Config ändert sich kaum)
public class CustomImagesService(IHttpClientFactory factory)
{
private CustomImagesDto? _cached;
public async Task<CustomImagesDto> GetAsync(CancellationToken ct = default) { ... }
}
```
#### `app.css` — Neue Klassen
```css
.signflow-app-logo { height: var(--sf-app-height, 28px); width: auto; object-fit: contain; }
.signflow-company-logo { height: var(--sf-company-height, 22px); width: auto; object-fit: contain; opacity: .88; }
```
#### `IndexPage.razor` — Änderung
```razor
<!-- Alt: <svg class="home-hero-header__icon"> -->
<!-- Neu: -->
<img src="@_appLogo.Src" style="height: @_appLogo.Height"
class="home-hero-header__icon" alt="signFLOW" />
```
#### `EnvelopeSenderPage.razor` — signFLOW Logo im Action Bar
```razor
<div class="sender-branding">
<img src="@_appLogo.Src" style="height: @_appLogo.Height"
class="@_appLogo.GetClass("Main")" alt="signFLOW" />
<div class="sender-title-block">
<div class="sender-title">Umschlag-Übersicht</div>
<div class="sender-subtitle">signFLOW Portal - Übersicht</div>
</div>
</div>
```
---
## ✅ Phase 6b — Kunden-Logo Placeholder
### Ziel
Mittlerer Bereich im Action Bar zeigt das Kunden-Logo (aus Config) oder einen Platzhalter.
### Implementierung
**Config-Quelle:** `CustomImagesOptions.Company` (aus Phase 6a bereits geladen)
```razor
<!-- Mitte des Action Bars -->
<div class="sender-customer-logo-area">
@if (!string.IsNullOrEmpty(_companyLogo?.Src))
{
<img src="@_companyLogo.Src" style="height: @_companyLogo.Height"
class="@_companyLogo.GetClass("Show")" alt="Kunden-Logo" />
}
else
{
<div class="sf-customer-logo-placeholder">Kunden-Logo</div>
}
</div>
```
#### `app.css` — Neue Klasse
```css
.sf-customer-logo-placeholder {
height: 24px; min-width: 90px;
border: 1px dashed rgba(126, 34, 206, 0.3); border-radius: 4px;
display: flex; align-items: center; padding: 0 10px;
font-size: 0.62rem; color: rgba(126, 34, 206, 0.4); white-space: nowrap;
}
```
#### `sender-page.css` — Neues Layout (3-Bereich-Action-Bar)
```css
.sender-branding { display: flex; align-items: center; gap: .75rem; flex-shrink: 0; }
.sender-subtitle { font-size: .72rem; color: #6b7280; font-weight: 400; }
.sender-customer-logo-area { flex: 1; display: flex; justify-content: center; }
```
---
## ✅ Phase 6c — Grid: Fehlende Spalten + Persistence Hint
### Fehlende Spalten (aus Screenshot-Annotationen)
**Verfügbarkeit in `EnvelopeDto`:**
- `EnvelopeTypeTitle` (string?) → `spalte für "Typ"` ✅
- `AddedWhen` (DateTime) → `spalte für "erstellt"` ✅
- `ChangedWhen` (DateTime?) → `Spalte "zuletzt geändert am"` ✅
> ⚠️ **Vor Implementierung prüfen:** `EnvelopeTypeTitle` wird aus dem Navigation Property `EnvelopeType` gemappt. Mapping-Konfiguration in `Application/...MappingProfile.cs` verifizieren.
#### `EnvelopeSenderPage.razor` — Neue Spalten (beide Grids)
```razor
<DxGridDataColumn FieldName="EnvelopeTypeTitle" Caption="Typ"
Width="120px" AllowSort="true" />
<DxGridDataColumn FieldName="AddedWhen" Caption="Erstellt"
DisplayFormat="{0:dd.MM.yyyy}" Width="110px" AllowSort="true" />
<DxGridDataColumn FieldName="ChangedWhen" Caption="Geändert am"
DisplayFormat="{0:dd.MM.yyyy}" Width="110px" AllowSort="true" />
```
### Grid Persistence Hint
Info-Banner über dem Grid:
```razor
<div class="sf-grid-persistence-hint">
<svg><!-- info icon --></svg>
Die Größe und Position der Spalten wird automatisch für Sie gespeichert.
</div>
```
```css
.sf-grid-persistence-hint {
display: flex; align-items: center; gap: 6px;
padding: 5px 16px; font-size: 0.72rem;
color: rgba(126, 34, 206, 0.7);
background: rgba(126, 34, 206, 0.04);
border-bottom: 1px solid rgba(126, 34, 206, 0.08);
}
```
---
## 🔲 Phase 6d — Dokument anzeigen (PDF Preview)
### Anforderung
Ausgewähltes Envelope-Dokument im Popup anzeigen. Envelope ist nach dem Senden **read-only** (Mails wurden gesendet — nachträgliche Änderungen würden Konsistenz brechen).
### Technischer Ansatz
`DxPdfViewer` erfordert **zwingend `@rendermode InteractiveServer`**.
`EnvelopeSenderPage` ist WASM (`@rendermode InteractiveWebAssembly`).
**Lösung:** `DxPopup` im WASM-Parent mit einem Server-Component darin:
```
EnvelopeSenderPage.razor (@rendermode WASM)
└── <DxPopup>
└── <EnvelopePdfPreviewComponent @rendermode InteractiveServer>
└── <DxPdfViewer> (SSR-only)
```
#### `Server/Components/Shared/EnvelopePdfPreviewComponent.razor` — NEU
```razor
@rendermode InteractiveServer
@inject IDocumentService DocumentService <!-- oder HTTP client -->
<DxPdfViewer DocumentContent="@_pdfBytes" />
@code {
[Parameter] public int EnvelopeId { get; set; }
byte[]? _pdfBytes;
protected override async Task OnParametersSetAsync()
{
if (EnvelopeId > 0)
_pdfBytes = await DocumentService.GetPdfBytesAsync(EnvelopeId);
}
}
```
Button: nur aktiv wenn Envelope ausgewählt UND Dokument vorhanden (`envelope.Documents?.Any() == true`).
---
## 🔲 Phase 6e — Export nach Excel
### Technischer Ansatz
`DxGrid.ExportToXlsxAsync(fileName, options)` — direkt im Razor-Code, keine Backend-Implementierung nötig.
Download geht direkt an den Browser.
```csharp
async Task ExportToExcelAsync()
{
var grid = _activeTab == "active" ? _gridActive : _gridCompleted;
if (grid is null) return;
var fileName = $"Umschlaege_{DateTime.Now:yyyy-MM-dd}.xlsx";
await grid.ExportToXlsxAsync(fileName, new GridXlExportOptions());
}
```
> **Hinweis:** `CellDisplayTemplate`-Inhalte werden nicht exportiert (DevExpress-Limitierung).
> Die Spalten `Status`, `EnvelopeReceivers` nutzen Templates → Wert im Export ist leer.
> **Future Task:** Für vollständigen Export mit formatierten Zellen `CustomizeCell`-Event nutzen.
Button in Toolbar:
```razor
<button class="sender-btn" @onclick="ExportToExcelAsync"
title="Aktuelle Ansicht als Excel exportieren">
<!-- Excel-Icon SVG -->
Export nach Excel
</button>
```
---
## 🔲 Phase 6f — Action Bar Redesign
### Ziel-Layout (aus Screenshot)
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ [Logo] Umschlag-Übersicht │ [Kunden-Logo] │ [+Neu] [Bearb.] [Lösch.] │
│ signFLOW Portal-Übers│ │ [↺] [→|] │
│ │ │ [Zurückrufen?] [Dok.] [XLS] │
└──────────────────────────────────────────────────────────────────────────────┘
```
### Neue CSS-Klassen in `sender-page.css`
```css
.sender-toolbar-right { display: flex; flex-direction: column; gap: .35rem; align-items: flex-end; }
.sender-toolbar-row { display: flex; align-items: center; gap: .5rem; }
.sender-toolbar-row--primary { /* Hauptbuttons */ }
.sender-toolbar-row--secondary { /* Dokument anzeigen, Export */ }
```
### `Zurückrufen/löschen` — Offene Frage ❓
> **Ticket-Kommentar-Entwurf (Deutsch, für Marlon Schreiber):**
>
> Hallo @Marlon Schreiber,
>
> im Screenshot sehe ich einen Button „Zurückrufen/löschen". Könntest du den
> Unterschied zum bereits vorhandenen „Löschen"-Button erläutern?
>
> Meine Vermutung: „Zurückrufen" bedeutet das Widerrufen eines **bereits gesendeten**
> Umschlags (Status → `EnvelopeWithdrawn`), während „Löschen" einen noch nicht
> gesendeten Umschlag entfernt. Ist das korrekt? Und falls ja:
> - Sollen Empfänger bei einem Widerruf automatisch benachrichtigt werden?
> - Soll der Status auf `Zurückgerufen` gesetzt werden und der Umschlag
> weiterhin in der Liste erscheinen (aber inaktiv)?
>
> Bitte kurz antworten, damit ich dies korrekt implementieren kann.
---
## Implementierungsreihenfolge Phase 6
```
6a (Logo) → 6b (Kunden-Logo) → 6c (Grid-Spalten) → 6d (PDF Preview) → 6e (Excel) → 6f (Action Bar)
```
Jede Teilphase ist unabhängig deploybar. `6f` fasst alle Layout-Änderungen zusammen
und sollte zuletzt implementiert werden, wenn alle Komponenten fertig sind.
---
## Bekannte Einschränkungen & Offene Punkte
| # | Thema | Status |
|---|-------|--------|
| 1 | `DxPdfViewer` → InteractiveServer zwingend | Architektonisch gelöst (6d) |
| 2 | Excel-Export: Template-Spalten leer im Export | Akzeptiert, Future Task (CustomizeCell) |
| 3 | `Zurückrufen/löschen` — Bedeutung unklar | Kommentar-Frage vorbereitet (6f) |
| 4 | `EnvelopeTypeTitle` Mapping prüfen | Vor 6c verifizieren |
| 5 | Logo-Pfade produktionsspezifisch | Via `appsettings.json` konfigurierbar |
| 6 | Server-seitige Pages — inline Farben bleiben | Out-of-scope |
---
## Nicht in diesem Ticket
- `Einstellungen`-Seite (separates Ticket)
- Mobile Responsive (separates Ticket)
- Excel CustomizeCell vollständige Implementierung (Future Task)
- Widerruf-Backend (wartet auf Klärung)
---
*Erstellt: 2026-09-01 | Aktualisiert: 2026-09-03 | Ticket: SF-74 | Projekt: signFLOW*

View File

@@ -1,315 +0,0 @@
# Migration Plan: EnvelopeGenerator.Service_legacy ? EnvelopeGenerator.Service
**Revision v3** — Full deep analysis complete
## Overview
Migrate the legacy VB.NET Windows Service (`EnvelopeGenerator.Service_legacy`) to a modern
C# Worker Service (`EnvelopeGenerator.Service`) targeting **.NET Framework 4.6.2**, using
`Microsoft.Extensions.Hosting`, `IConfiguration`, `ILogger<T>` and **NLog**.
---
## Deep Analysis Findings (vs. v1/v2)
### Finding 1 — Connection String: Plain Text, No Encryption Required
`FinalizeDocumentJob` and `APIEnvelopeJob` call `MSSQLServer.DecryptConnectionString()` on the
`JobDataMap[Value.DATABASE]` value. However, `MSSQLServer.DecryptConnectionString()` is a pass-through
for plain-text strings — no wrapping/encryption step is needed.
**Resolution**: The connection string is read from `IConfiguration` via the standard
`ConnectionStrings:Default` key (same key as `appsettings.Database.json` in `EnvelopeGenerator.Web`).
It is passed **as-is** to both the Worker-level `MSSQLServer` and the `JobDataMap`. No
`EncryptConnectionString()` call is made anywhere in the new service.
### Finding 2 — Dual TempFiles Architecture
There are **two independent** `TempFiles` classes:
- `EnvelopeGenerator.CommonServices.TempFiles` — used **inside each job** via `New TempFiles(LogConfig)`.
This is called on every job execution and manages the job's own temp lifecycle. It expects `LogConfig`.
**We do not touch this.**
- `EnvelopeGenerator.Service_legacy.TempFiles` — used at the **service/host level** (startup cleanup,
shutdown cleanup). This is the one we rewrite in C# in the Service project using `ILogger<TempFiles>`.
### Finding 3 — LogConfig is a Hard Dependency of All Jobs
`FinalizeDocumentJob.Execute()` and `APIEnvelopeJob.Execute()` both do:
```vb
LogConfig = pContext.MergedJobDataMap.Item(Value.LOGCONFIG)
Logger = LogConfig.GetLogger()
myTempFiles = New TempFiles(LogConfig) ' ? CommonServices.TempFiles
```
`LogConfig` is **not optional**. The schedulers must construct a valid `LogConfig` and place it in the
`JobDataMap`. It must carry the correct log path, debug flag, and application identity.
**Resolution**: A `LogConfigFactory` static helper class will be created in the Service project to
build a `LogConfig` from `ServiceConfig` values:
- `LogPath` = `Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Log")` (mirrors legacy `My.Application.Info.DirectoryPath + "\Log"`)
- `Debug` = `ServiceConfig.Debug`
- Application name = `"EnvelopeGenerator.Service"`
### Finding 4 — ProjectInstaller is Obsolete
`ProjectInstaller.vb` was the old `installutil.exe` mechanism for Windows Service registration.
With `AddWindowsService()` + `sc create` / PowerShell `New-Service`, this is **completely replaced**.
No equivalent is needed in the new project.
### Finding 5 — GDPicture License SQL
The legacy service queries: `SELECT LICENSE FROM TBDD_3RD_PARTY_MODULES WHERE NAME = 'GDPICTURE' and ACTIVE = 1`
This must be replicated exactly in the Worker startup sequence. If the result is null/empty, startup fails
with a descriptive exception (matching legacy behavior).
### Finding 6 — Quartz Version Mismatch
`CommonServices` uses `Quartz 3.8.0`. `Service_legacy` uses `Quartz 3.15.0`.
The new `EnvelopeGenerator.Service` must use **Quartz 3.8.0** to match `CommonServices` (same AppDomain,
same version must be loaded). The legacy version bump in `Service_legacy` was inconsistent.
---
## 1. Project File (`EnvelopeGenerator.Service.csproj`)
- Add **NuGet packages**:
- `NLog` (5.x, matching CommonServices' `NLog.5.0.5`)
- `NLog.Extensions.Logging` (latest net462-compatible)
- `Quartz` (**3.8.0** — must match CommonServices)
- `Microsoft.Extensions.Hosting.WindowsServices` (for `AddWindowsService()`)
- Add **project reference** to `EnvelopeGenerator.CommonServices` (vbproj)
- Add **project reference** to `EnvelopeGenerator.Domain` (csproj)
---
## 2. Configuration (`ServiceConfig.cs`)
```csharp
public class ServiceConfig
{
public string ConnectionString { get; set; } = string.Empty;
public bool Debug { get; set; } = false;
public int IntervalInMin { get; set; } = 1;
public PDFBurnerParams PDFBurnerParams { get; set; } = new PDFBurnerParams();
}
```
Connection string is read via the standard `ConnectionStrings:Default` key — consistent with
`appsettings.Database.json` used by `EnvelopeGenerator.Web`:
`appsettings.json`:
```json
{
"ConnectionStrings": {
"Default": ""
},
"ServiceConfig": {
"Debug": false,
"IntervalInMin": 1,
"PDFBurnerParams": {
"IgnoredLabels": [ "Date", "Datum", "ZIP", "PLZ", "Place", "Ort", "Position", "Stellung" ],
"TopMargin": 0.1,
"YOffset": -0.3,
"FontName": "Arial",
"FontSize": 8,
"FontStyle": 2,
"TruncationSuffix": "..",
"TextMaxWidths": {
"position": 1.41,
"city": 1.41,
"date": 2.0
}
}
}
}
```
> `FontStyle` integer: Regular=0, Bold=1, **Italic=2**, Underline=4, Strikeout=8.
> All values mirror `PDFBurnerParams.vb` defaults exactly.
---
## 3. NLog Setup
**`nlog.config`**: File target ? `${basedir}/Log/service-${shortdate}.log`, async wrapper,
layout with timestamp + level + logger + message + exception.
**`Program.cs`**: Wire via `NLog.Extensions.Logging`:
```csharp
builder.Logging.ClearProviders();
builder.Logging.AddNLog("nlog.config");
```
---
## 4. LogConfigFactory (`LogConfigFactory.cs`)
New static helper — bridges `ServiceConfig` ? `LogConfig` (required by CommonServices jobs):
```csharp
internal static class LogConfigFactory
{
public static LogConfig Create(ServiceConfig config)
{
var logPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Log");
var logConfig = new LogConfig(
LogConfig.PathType.CustomPath, logPath, null,
"Digital Data", "EnvelopeGenerator.Service");
logConfig.Debug = config.Debug;
return logConfig;
}
}
```
This `LogConfig` instance is what gets placed into every `JobDataMap[Value.LOGCONFIG]`.
---
## 5. Quartz LogProvider (`QuartzLogProvider.cs`)
C# rewrite of `LogProvider.vb` — bridges Quartz `ILogProvider` ? `ILogger<T>`:
```csharp
internal class QuartzLogProvider : ILogProvider
{
private readonly ILogger _logger;
public QuartzLogProvider(ILogger logger) => _logger = logger;
public Logger GetLogger(string name) => (level, func, ex, parameters) => { ... };
// OpenNestedContext / OpenMappedContext ? NotImplementedException (same as legacy)
}
```
---
## 6. TempFiles (`TempFiles.cs`) — Service-level only
Service-level rewrite of `Service_legacy/TempFiles.vb` using `ILogger<TempFiles>`:
- `Create()` ? create `%TEMP%\EnvelopeGenerator`, or clean existing files
- `CleanUp()` ? delete the directory on service stop
> **Does NOT replace** `CommonServices.TempFiles` — that one continues to be used
> inside each job via `LogConfig`.
---
## 7. Schedulers
### `Schedulers/SchedulerFinishEnvelope.cs`
```csharp
public class SchedulerFinishEnvelope
{
// Constructor: ILogger<SchedulerFinishEnvelope>, IOptions<ServiceConfig>
// Start(licenseKey, logConfig):
// - Build JobDataMap:
// [GDPICTURE] = licenseKey
// [LOGCONFIG] = logConfig ? LogConfigFactory output
// [DATABASE] = MSSQLServer.EncryptConnectionString(connectionString)
// [PDF_BURNER_PARAMS] = config.PDFBurnerParams
// - Schedule FinalizeDocumentJob with Quartz (interval from config)
// - Register QuartzLogProvider
// Stop(): Scheduler.Shutdown()
}
```
### `Schedulers/SchedulerEnvelopeTaskApi.cs`
```csharp
public class SchedulerEnvelopeTaskApi
{
// Constructor: ILogger<SchedulerEnvelopeTaskApi>, IOptions<ServiceConfig>
// Start(logConfig):
// - Build JobDataMap:
// [LOGCONFIG] = logConfig
// [DATABASE] = MSSQLServer.EncryptConnectionString(connectionString)
// - Schedule APIEnvelopeJob with Quartz (interval from config)
// - Register QuartzLogProvider
// Stop(): Scheduler.Shutdown()
}
```
---
## 8. Worker (`Worker.cs`)
Full lifecycle replacing the placeholder:
```
ExecuteAsync:
1. Read ServiceConfig from IOptions<ServiceConfig>
2. Validate ConnectionString ? throw if empty
3. Build LogConfig via LogConfigFactory
4. Connect to DB (plain connection string — Worker-level only)
5. Query: SELECT LICENSE FROM TBDD_3RD_PARTY_MODULES WHERE NAME='GDPICTURE' AND ACTIVE=1
? throw if null/empty (matches legacy behavior)
6. TempFiles.Create()
7. await SchedulerFinishEnvelope.Start(licenseKey, logConfig)
8. await Task.Delay(2500, stoppingToken) ? preserved from legacy
9. await SchedulerEnvelopeTaskApi.Start(logConfig)
10. await Task.Delay(Timeout.Infinite, stoppingToken) ? wait for cancellation
StopAsync override:
11. await SchedulerFinishEnvelope.Stop()
12. await SchedulerEnvelopeTaskApi.Stop()
13. TempFiles.CleanUp()
```
---
## 9. Program.cs
```csharp
var builder = Host.CreateApplicationBuilder(args);
builder.Logging.ClearProviders();
builder.Logging.AddNLog("nlog.config");
builder.Services.Configure<ServiceConfig>(
builder.Configuration.GetSection("ServiceConfig"));
builder.Services.AddSingleton<TempFiles>();
builder.Services.AddSingleton<SchedulerFinishEnvelope>();
builder.Services.AddSingleton<SchedulerEnvelopeTaskApi>();
builder.Services.AddHostedService<Worker>();
builder.Services.AddWindowsService(o => o.ServiceName = "EnvelopeGenerator.Service");
var host = builder.Build();
host.Run();
```
---
## 10. Files to Create / Modify
| File | Action | Notes |
|------|--------|-------|
| `EnvelopeGenerator.Service.csproj` | **Modify** | Add packages, project refs |
| `Program.cs` | **Modify** | Full DI/NLog/WindowsService setup |
| `Worker.cs` | **Modify** | Full service lifecycle |
| `ServiceConfig.cs` | **Create** | |
| `LogConfigFactory.cs` | **Create** | Critical bridge: ServiceConfig ? LogConfig |
| `TempFiles.cs` | **Create** | Service-level only; CommonServices.TempFiles untouched |
| `QuartzLogProvider.cs` | **Create** | |
| `Schedulers/SchedulerFinishEnvelope.cs` | **Create** | |
| `Schedulers/SchedulerEnvelopeTaskApi.cs` | **Create** | |
| `appsettings.json` | **Create** | All PDFBurnerParams explicit |
| `nlog.config` | **Create** | |
**Not created** (obsolete in new architecture):
- ~~`ProjectInstaller`~~ ? replaced by `sc create` / `New-Service`
- ~~`Service.Designer.vb`~~ ? BackgroundService handles this
- ~~`Service.resx`~~ ? not applicable
---
## 11. Key Design Decisions
| Decision | Rationale |
|----------|-----------|
| **Quartz 3.8.0** (not 3.15.0) | Must match CommonServices; same AppDomain, one version |
| **Connection string plain-text, no encryption** | `MSSQLServer.DecryptConnectionString()` is a pass-through for plain strings; `ConnectionStrings:Default` key used (same convention as Web project) |
| **Connection string in `ConnectionStrings:Default`** | Consistent with rest of the solution (`appsettings.Database.json`); `ServiceConfig` holds only service-specific settings |
| **LogConfigFactory** | Jobs have hard dependency on `LogConfig` in JobDataMap; this is the clean bridge without leaking LogConfig into the DI container |
| **2500ms delay preserved** | Legacy behavior; ensures Scheduler1 is fully initialized before Scheduler2 starts |
| **`AddWindowsService()`** | Native SCM integration; replaces ProjectInstaller/installutil entirely |
| **NLog log path = BaseDirectory/Log** | Mirrors `My.Application.Info.DirectoryPath + "\Log"` from legacy |
| **CommonServices.TempFiles untouched** | Jobs create their own TempFiles internally via LogConfig; service-level TempFiles is separate concern |
---
## Approval Required
> Please review this plan and confirm before implementation begins.

View File

@@ -20,8 +20,6 @@ EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{134D4164-B291-4E19-99B9-E4FA3AFAB62C}"
ProjectSection(SolutionItems) = preProject
COPILOT_CONTEXT.md = COPILOT_CONTEXT.md
fix-report-label-read-and-confirmed.md = fix-report-label-read-and-confirmed.md
fix-signature-field-formatting.md = fix-signature-field-formatting.md
FORM_APPLICATION_CONTEXT.md = FORM_APPLICATION_CONTEXT.md
RECEIVER_PDF_VIEWER_CONTEXT.md = RECEIVER_PDF_VIEWER_CONTEXT.md
EndProjectSection
@@ -38,14 +36,11 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.PdfEditor
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.Tests", "EnvelopeGenerator.Tests\EnvelopeGenerator.Tests.csproj", "{224C4845-1CDE-22B7-F3A9-1FF9297F70E8}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.API", "EnvelopeGenerator.API\EnvelopeGenerator.API.csproj", "{EC768913-6270-14F4-1DD3-69C87A659462}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.DependencyInjection", "EnvelopeGenerator.DependencyInjection\EnvelopeGenerator.DependencyInjection.csproj", "{5DCCF9A1-C03F-90E6-87D3-E96DB25250C2}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "EnvelopeGenerator.Server", "EnvelopeGenerator.Server", "{BF1700D5-592E-4FFA-84E8-5480E289A1F0}"
ProjectSection(SolutionItems) = preProject
EnvelopeGenerator.Server\publish.bat = EnvelopeGenerator.Server\publish.bat
EnvelopeGenerator.Server\SF-74-PLAN.md = EnvelopeGenerator.Server\SF-74-PLAN.md
EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.Server", "EnvelopeGenerator.Server\EnvelopeGenerator.Server\EnvelopeGenerator.Server.csproj", "{4E6C54DA-576D-0955-2564-9EC890BB8279}"
@@ -98,10 +93,6 @@ Global
{224C4845-1CDE-22B7-F3A9-1FF9297F70E8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{224C4845-1CDE-22B7-F3A9-1FF9297F70E8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{224C4845-1CDE-22B7-F3A9-1FF9297F70E8}.Release|Any CPU.Build.0 = Release|Any CPU
{EC768913-6270-14F4-1DD3-69C87A659462}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{EC768913-6270-14F4-1DD3-69C87A659462}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EC768913-6270-14F4-1DD3-69C87A659462}.Release|Any CPU.ActiveCfg = Release|Any CPU
{EC768913-6270-14F4-1DD3-69C87A659462}.Release|Any CPU.Build.0 = Release|Any CPU
{5DCCF9A1-C03F-90E6-87D3-E96DB25250C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5DCCF9A1-C03F-90E6-87D3-E96DB25250C2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5DCCF9A1-C03F-90E6-87D3-E96DB25250C2}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -139,7 +130,6 @@ Global
{6D56C01F-D6CB-4D8A-BD3D-4FD34326998C} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB}
{211619F5-AE25-4BA5-A552-BACAFE0632D3} = {9943209E-1744-4944-B1BA-4F87FC1A0EEB}
{224C4845-1CDE-22B7-F3A9-1FF9297F70E8} = {0CBC2432-A561-4440-89BC-671B66A24146}
{EC768913-6270-14F4-1DD3-69C87A659462} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB}
{5DCCF9A1-C03F-90E6-87D3-E96DB25250C2} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB}
{BF1700D5-592E-4FFA-84E8-5480E289A1F0} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB}
{4E6C54DA-576D-0955-2564-9EC890BB8279} = {BF1700D5-592E-4FFA-84E8-5480E289A1F0}

View File

@@ -1,135 +0,0 @@
# Fix: Report Label "Read and confirmed" for Read and Sign Envelopes
## Status
✅ **COMPLETED**
## Git Branch
```
fix/report-label-read-and-confirmed
```
---
## Problem
In the **Signature Certificate report** (`Signierungszertifikat`), the history event
list always showed **"Document signed"** for status `DocumentSigned` (code `2005`) —
even when the envelope type was **"Read and Sign"** (`EnvelopeTypeId = 2`).
For "Read and Sign" envelopes the correct label must be **"Read and confirmed"**
(DE: **"Gelesen und bestätigt"**).
### Screenshot reference
The report shows a table with columns *Ereignis | Benutzer | Zeitstempel*.
The row with status 2005 displayed "Document signed" where "Read and confirmed"
was expected.
---
## Root Cause
`ReportItem.ItemStatusTranslated` in
`EnvelopeGenerator.CommonServices\Jobs\FinalizeDocument\ReportItem.vb`
resolved the display label by calling:
```vb
My.Resources.Model.ResourceManager.GetString(ItemStatus.ToString())
```
There was **no branching logic** to handle the Read and Sign envelope type.
The same `DocumentSigned` key was always used regardless of envelope type.
---
## Solution
### Convention followed
The project already uses a `RaC` (**R**ead **a**nd **C**onfirm) suffix for
Read-and-Sign label variants across all resource files:
| Existing RaC keys |
|---|
| `DocumentRejectedRaC` |
| `EnvelopeReportCreatedRaC` |
| `MessageConfirmationSentRaC` |
### New resource key added: `DocumentSignedRaC`
| Resource file | Value |
|---|---|
| `Model.resx` (DE, default) | `Gelesen und bestätigt` |
| `Model.en.resx` (EN) | `Read and confirmed` |
### Logic change in `ReportItem.vb`
```vb
Public ReadOnly Property ItemStatusTranslated As String
Get
Dim oStatusKey = ItemStatus.ToString()
If ItemStatus = EnvelopeStatus.DocumentSigned AndAlso
Envelope IsNot Nothing AndAlso
Envelope.IsReadAndConfirm() Then
oStatusKey = "DocumentSignedRaC"
End If
Return My.Resources.Model.ResourceManager.GetString(oStatusKey)
End Get
End Property
```
`Envelope.IsReadAndConfirm()` is defined in
`EnvelopeGenerator.Domain\Interfaces\IEnvelope.cs` and returns `true`
when `EnvelopeTypeId == 2`.
---
## Files Changed
| # | File | Change | Status |
|---|---|---|---|
| 1 | `EnvelopeGenerator.CommonServices\Jobs\FinalizeDocument\ReportItem.vb` | Added `RaC` branching logic to `ItemStatusTranslated` | ? Done |
| 2 | `EnvelopeGenerator.Domain\Resources\Model.resx` | Added `DocumentSignedRaC` = `Gelesen und bestätigt` | ✅ Done |
| 3 | `EnvelopeGenerator.CommonServices\Strings\Model.resx` | Added `DocumentSignedRaC` = `Gelesen und bestätigt` | ✅ Done |
| 4 | `EnvelopeGenerator.CommonServices\Strings\Model.en.resx` | Added `DocumentSignedRaC` = `Read and confirmed` | ✅ Done |
| 5 | `EnvelopeGenerator.Domain\Resources\Model.Designer.cs` | Added `DocumentSignedRaC` strongly-typed property | ✅ Done |
---
## How to resume (for next agent)
All pending items are **resource file additions only**.
Each file needs one new `<data>` block inserted **directly after** the existing
`DocumentSigned` entry:
```xml
<data name="DocumentSignedRaC" xml:space="preserve">
<value>Gelesen und bestätigt</value> <!-- DE files -->
<!-- OR -->
<value>Read and confirmed</value> <!-- EN file -->
</data>
```
`Model.Designer.cs` also needs a new strongly-typed property added after
`DocumentSigned`:
```csharp
/// <summary>
/// Looks up a localized string similar to Gelesen und bestätigt.
/// </summary>
public static string DocumentSignedRaC {
get {
return ResourceManager.GetString("DocumentSignedRaC", resourceCulture);
}
}
```
---
## Testing
After changes, generate a Signature Certificate report for a **Read and Sign**
envelope and verify:
- History row with status 2005 shows **"Read and confirmed"** (EN)
- History row with status 2005 shows **"Gelesen und bestätigt"** (DE)
- Regular (non-RaC) envelopes still show **"Document signed"** / **"Dokument unterzeichnet"**

View File

@@ -1,111 +0,0 @@
# Signature Field Formatting — WISAG Feedback
## Context
Customer feedback received from the new WISAG signFLOW project lead (email dated 3/7/2026).
The signature stamp rendered inside the finalized PDF has layout issues that need to be addressed.
### Current State (screenshot from customer email)
```
Signiert von ___________
[ signature image ]
Armin Schneider
C6B85E96A520C60... ← certificate hash, too close to name
Bereichsleiter Kern- ← position text wraps, no spacing to date
Anwendungen
Frankfurt am Main
23.06.2026, 17:13:42 GMT+2
```
### Issues Reported
1. **Spacing between name and certificate number** is too small — needs more vertical gap.
2. **Spacing between position/title and date/time** is too small — should be dynamic (expand with content).
3. *(Secondary, out of scope here)* Multiple signature fields side-by-side scale individually, causing uneven heights.
---
## Agreed Solution
> **"Height stays fixed. Text stretches horizontally. Beyond a certain character limit, truncate with '...'"**
> *(Original: "Höhe bleibt fix. Text dehnt sich horizontal aus. Ab einem bestimmten Limit wird mit '...' abgekürzt.")*
### Rationale
- The signature field dimensions are **fixed** (`1.77" × 1.96"`). Allowing text to wrap causes position and date rows to collide vertically.
- Truncating long strings with ellipsis (`...`) keeps the layout deterministic and prevents overflow.
- Per-field max-length limits allow tuning each text row independently (name, certificate, position, city, date).
---
## Implementation Plan
### 1. `PDFBurnerParams.vb` — Add per-field max-length dictionary
Add a new property `TextMaxLengths` that maps each field name (matching `EGName` keys) to a maximum character count.
```vbnet
' EnvelopeGenerator.CommonServices/Jobs/FinalizeDocument/PDFBurnerParams.vb
Public Property TextMaxLengths As New Dictionary(Of String, Integer) From {
{"name", 28},
{"position", 32},
{"city", 24},
{"date", 28}
}
```
Default values are starting points and should be tuned against real customer data.
### 2. `PDFBurner.vb` — Add truncation helper and apply in `AddFormFieldValue`
#### 2a. Add a private helper function
```vbnet
Private Function TruncateText(value As String, fieldName As String) As String
If String.IsNullOrEmpty(value) Then Return value
Dim maxLen As Integer = 0
If _pdfBurnerParams.TextMaxLengths IsNot Nothing AndAlso
_pdfBurnerParams.TextMaxLengths.TryGetValue(fieldName, maxLen) AndAlso
maxLen > 0 AndAlso value.Length > maxLen Then
Return value.Substring(0, maxLen - 3) & "..."
End If
Return value
End Function
```
#### 2b. Apply truncation in the `egName`-aware overload
In `AddFormFieldValue(pAnnotation As Annotation, formFieldValue As FormFieldValue)`:
```vbnet
' Before passing value to AddTextAnnot, truncate:
Dim displayValue As String = TruncateText(formFieldValue.value, pAnnotation.egName)
Dim ant = Manager.AddTextAnnot(oX, oY, oWidth, oHeight, displayValue)
```
#### 2c. Apply truncation in the element-based overload (optional)
In `AddFormFieldValue(x, y, width, height, page, value)` — if a field name is available in calling context, pass it through; otherwise this path does not use `egName` and truncation can be skipped or applied with a generic fallback key.
---
## Affected Files
| File | Change |
|------|--------|
| `EnvelopeGenerator.CommonServices/Jobs/FinalizeDocument/PDFBurnerParams.vb` | Add `TextMaxLengths As Dictionary(Of String, Integer)` |
| `EnvelopeGenerator.CommonServices/Jobs/FinalizeDocument/PDFBurner.vb` | Add `TruncateText()` helper; apply in `AddFormFieldValue` overloads |
---
## Notes
- Field names (`"name"`, `"position"`, `"city"`, `"date"`) must match the keys used in `EGName.Index` dictionary (`PDFBurner.vb:443–450`).
- The certificate hash field (e.g. `C6B85E96A520C60...`) is already truncated upstream — verify its `egName` mapping if it needs its own limit.
- The `YOffset` / `TopMargin` values in `PDFBurnerParams` may also need adjustment to increase vertical spacing between rows. That is a separate (simpler) change.
- This issue is **not** related to the Blazor WebUI rendering — it is purely in the GdPicture14 PDF burning pipeline.