Compare commits

...

31 Commits

Author SHA1 Message Date
d0c3d79fbc Refactor DI, PDF rendering, and improve security/UX
Centralized dependency injection registrations into dedicated
extension methods/modules to reduce startup composition sprawl
and improve maintainability.

Refactored `PdfRenderController.cs` and client service contract
to replace JSON/base64 `byte[]` transport with binary streaming
(`application/octet-stream` or multipart) for improved memory
and latency performance with large PDFs.

Introduced a DevExpress toast-based notification system using
`DxToastProvider` and `IToastNotificationService` for consistent,
actionable feedback across sender/receiver flows.

Standardized request-to-domain mapping with AutoMapper/profile
conventions or reflection/source-generator-based mappers to
eliminate manual mapping drift and enforce consistency across
services.

Addressed SQL injection vulnerabilities by replacing dynamic SQL
string composition with parameterized queries. Conducted a repo-
wide audit and added CI guardrails to prevent reintroduction.

Refined UX for `EnvelopeSenderPage.razor` to enforce deterministic
row double-click behavior (preview OR edit) and aligned row action
buttons/tooltips to eliminate ambiguous navigation.
2026-10-02 13:32:21 +02:00
e81c7896a1 Refactor services, DI, and improve security
- Removed unnecessary request/payload remapping in `Services/` and ensured direct request DTO usage where safe.
- Centralized DI registrations into extension methods/modules to reduce startup sprawl and improve maintainability.
- Refactored `PdfRenderController.cs` to replace JSON/base64 `byte[]` transport with binary streaming for better memory/latency performance.
- Implemented a DevExpress toast-based notification system with `DxToastProvider` and `IToastNotificationService` for consistent feedback flows.
- Standardized request-to-domain mapping using AutoMapper/profile conventions or source-generator-based mappers to eliminate drift.
- Addressed SQL injection risks by replacing dynamic SQL string composition with parameterized queries, conducted a repo-wide audit, and added CI guardrails.
2026-10-02 13:27:09 +02:00
127e2b253e Standardize request-to-domain mapping policies
Introduced a standardized mapping policy to eliminate manual
property-by-property mapping drift (e.g., in
`CreateEnvelopeCommandHandler`). This policy may leverage
AutoMapper with profile conventions or a reflection/source-
generator-based approach and will be enforced across envelope
workflows. The implementation will occur at the Core library
level to ensure consistent guardrails for all services.

Removed the instruction to manually audit redundant mappings
in `EnvelopeGenerator.Server.Client/Services/`, aligning with
the shift toward automated mapping standardization.

Updated the manual testing workflow to include the new mapping
policy requirement, emphasizing its role in improving
consistency and maintainability.
2026-10-02 13:05:02 +02:00
6d0b78072e Refactor error handling in DocumentService
Centralized `HttpRequestException` creation with a new
`CreateHttpException` method to improve code reuse and
consistency. Updated methods to handle `HttpStatusCode.NotFound`
gracefully, returning `null` where appropriate instead of
throwing exceptions. Introduced `TryGetSenderDocumentBytesByEnvelopeIdAsync`
to encapsulate fallback logic for fetching document bytes.
Removed redundant inline exception handling and standardized
error handling across methods.
2026-10-02 13:04:29 +02:00
3721ea9a1f Set envelope status and update title/message fields
Updated `UpdateAsync` to include `e.Title`, `e.Message`, and `e.TfaEnabled` assignments, ensuring database updates reflect request values.
Set `e.Status` to `EnvelopeStatus.EnvelopeSaved` during the update process.
Updated in-memory `envelope` object to reflect changes to `Title`, `Message`, `TfaEnabled`, and `Status` fields.
2026-10-02 13:02:51 +02:00
f86588551c Refactor PDF editor UI and improve PDF loading logic
Simplify and restructure conditional logic in `SenderPdfImageEditor.razor` for better readability and maintainability. Merge redundant `else` blocks and reorganize rendering logic for page previews and fields.

Enhance PDF loading and page preview generation in `SenderEditorPage.razor`. Add error handling for failed page rendering, log warnings, and ensure consistent `_pageCount` initialization based on available data.

These changes improve code clarity, robustness, and maintainability.
2026-10-02 11:51:14 +02:00
0eaa9ff08f Add request validation with FluentValidation
Introduced `ValidationBehavior` to validate MediatR requests using FluentValidation. Registered the behavior in the pipeline and added validators for `ReadDocumentDetailsQuery` and `ReadDocumentQuery`. Updated dependency injection to include validators and added FluentValidation NuGet packages. Improved exception handling with `BadRequestException` for validation failures. Refactored namespaces and imports to support the changes.
2026-10-02 10:57:51 +02:00
ec79d7622e Add support for fetching document metadata separately
Introduced a new `ReadDocumentDetailsQuery` to fetch document
metadata (`DocumentDetailsDto`) without the binary payload.
Updated `MappingProfile` to map `Document` to `DocumentDetailsDto`.

Refactored `DocumentService` to handle separate fetching of
metadata and binary data, combining them into `DocumentDto`.
Changed the metadata endpoint to `/api/Document/details`.

Added a new `GetDocumentDetails` endpoint in `DocumentController`
to return metadata without binary data. Updated
`EnvelopeReceiverPageDataService` with a new method
`GetDocumentMetadataOfSenderAsync` for fetching metadata.

These changes improve efficiency by allowing clients to load
metadata without transferring large binary data when unnecessary.
2026-10-02 10:56:47 +02:00
f558d03bdf Refactor DocumentDto to use DocumentDetailsDto
Introduce a new `DocumentDetailsDto` record to encapsulate shared document metadata and elements without binary payload. Refactor `DocumentDto` to inherit from `DocumentDetailsDto`, eliminating redundant properties and improving code reuse.

Convert `DocumentDto` from a class to a record for immutability. Add a parameterized constructor to `DocumentDto` for initializing binary data alongside metadata. Update `ByteData` to use the `init` accessor.

These changes enhance maintainability, reduce duplication, and enforce immutability by leveraging C# records and inheritance.
2026-10-02 10:55:09 +02:00
db79053c62 Refactor architecture and optimize performance
Applied a modular page architecture to `EnvelopeSenderPage.razor` by splitting large pages into smaller subcomponents, eliminating duplicated code paths, and centralizing shared logic for maintainability.

Conducted a performance review of sender/receiver flows, addressing route transitions, component lifecycle behavior, loading-state correctness, and duplicate API calls. Treated loading logic defects as architectural issues.

Audited client `Services` to remove unnecessary request/payload remapping and simplify HTTP method transformations.

Centralized dependency injection registrations into extension methods/modules to reduce startup composition sprawl.

Refactored `PdfRenderController.cs` to replace JSON/base64 `byte[]` transport with binary streaming for improved memory/latency performance in large PDF rendering.

Implemented a DevExpress toast-based notification system using `DxToastProvider` and `IToastNotificationService` for consistent, actionable feedback across sender/receiver flows.
2026-10-02 10:04:06 +02:00
85dd85aa92 Refactor architecture and optimize PDF rendering
Refactor `EnvelopeSenderPage.razor` to focus on UI composition
and state orchestration, avoiding heavy domain logic. Adopt a
modular page architecture to improve maintainability by splitting
large pages into smaller subcomponents and centralizing shared
logic. Conduct a performance review of sender/receiver flows to
optimize route transitions, component lifecycle behavior, and
loading-state correctness.

Audit `Services` for redundant request/payload remapping and
eliminate unnecessary transformations. Centralize dependency
injection registrations into dedicated extension methods to
reduce startup composition sprawl.

Refactor `PdfRenderController.cs` and its client service contract
to replace JSON/base64 transport for PDF rendering with binary
streaming (`application/octet-stream` or multipart). Add a `TODO`
comment to highlight the need for further optimization of large
PDF handling to reduce payload size and memory pressure.
2026-10-02 09:50:15 +02:00
c7bc9fbcca Refactor EnvelopeType handling and improve validation
Removed AutoMapper dependency in `ReadEnvelopeTypesQueryHandler` and replaced it with manual projection to address null-materialization issues. Updated `EnvelopeType` entity properties to nullable types to align with the database schema. Adjusted computed properties to handle null values gracefully.

Enhanced `SenderEditorPage` with authentication checks, improved handling of reminder and expiry settings, and added validation for user input. Introduced `NormalizeSettings` and `ValidateSettings` methods to ensure consistent and valid configurations. Refactored checkbox value parsing for better reliability.

Injected `AuthService` and `IStringLocalizer` for authentication and localization support. Improved error messaging and localized validation errors. Performed minor code cleanup and refactoring for better readability and maintainability.
2026-10-02 01:25:43 +02:00
1e8e5c96c4 Refactor sender dashboard and improve performance
Moved sender dashboard business/status logic into dedicated
services and extension methods to decouple domain logic from
UI components. Refactored `EnvelopeSenderPage.razor` to focus
on UI composition and state orchestration.

Applied a modular page architecture by splitting large pages
into smaller subcomponents, eliminating duplicated code, and
centralizing shared logic for maintainability.

Conducted a senior-level performance review of sender/receiver
flows, addressing route transitions, loading-state correctness,
and perceived latency issues.

Audited client services for unnecessary request/payload
remapping, removing redundant transformations, and streamlining
HTTP methods. Centralized dependency injection registrations
into extension methods/modules to reduce startup composition
sprawl and improve maintainability.
2026-10-01 16:18:12 +02:00
61d04ca51a Add advanced envelope settings and EnvelopeTypeService
Enhanced `EnvelopeDto` with new properties for advanced settings, including final email options, reminders, and expiration settings. Updated `SenderEditorPage.razor` to include a new UI section for configuring these settings, with dropdowns, checkboxes, and input fields.

Introduced `EnvelopeTypeService` to fetch envelope type data from the backend and applied defaults based on the selected envelope type. Registered the service in `Program.cs` for dependency injection.

Improved lifecycle methods in `SenderEditorPage.razor` to load and apply envelope settings. Enhanced localization, error handling, and data binding for the new settings. Updated `CreateEnvelopeCommand` to include the new properties.

Refactored code for better organization and maintainability, and added logging for debugging envelope type loading issues.
2026-10-01 16:17:34 +02:00
af674a811d Refactor EnvelopeSenderPage and improve architecture
Refactored `EnvelopeSenderPage.razor` to reduce complexity by
moving business and status logic into dedicated services and
extension methods, focusing the component on UI composition
and state orchestration.

Introduced a modular page architecture by splitting large pages
into smaller subcomponents, eliminating duplicated code paths,
and centralizing shared UI/domain logic for better maintainability.

Performed an audit of `EnvelopeGenerator.Server.Client/Services/`
to remove unnecessary request/payload remapping and redundant
transformations, ensuring direct request DTO usage where safe.

Planned a senior-level performance review for sender/receiver
flows to address route transitions, loading-state correctness,
duplicate API calls, and perceived latency issues.

Updated `AGENTS.md` to document manual testing workflow changes
and reflect the refactoring efforts.
2026-10-01 15:53:28 +02:00
123889451f Simplify payload handling in UpsertAsync method
Refactored the `UpsertAsync` method in `EnvelopeService` to remove
manual construction of the HTTP POST payload. The method now
directly uses the `CreateEnvelopeCommand` object as the payload
for the `PostAsJsonAsync` call. This change reduces redundancy,
simplifies the code, and leverages the existing object structure
for better maintainability.
2026-10-01 15:53:10 +02:00
3b5075fe44 Enhance envelope creation and update functionality
Added new optional properties to `CreateEnvelopeCommand` for detailed configuration, including `EnvelopeId`, `Language`, `UseAccessCode`, and expiration settings. Introduced `ResolveEnvelopeSettingsAsync` to centralize settings resolution logic. Updated `CreateEnvelopeCommandHandler` to handle new properties and improve envelope creation/update flow. Added `EnvelopeSettings` record for structured settings management. Enhanced SQL update logic with `UseSQLExecutor` support. Improved handling of language resolution, reminders, and expiration rules.
2026-10-01 15:52:51 +02:00
48302a374f Refactor EnvelopeSenderPage for modularity and performance
Refactored `EnvelopeSenderPage.razor` to reduce complexity by
moving business/status logic into dedicated services and
extension methods. Focused the component on UI composition
and state orchestration, avoiding heavy domain logic in the
Razor file.

Introduced a modular page architecture by splitting large
pages into smaller subcomponents, eliminating duplicated
code paths, and centralizing shared logic for better
maintainability.

Conducted a senior-level performance review for sender/
receiver flows, addressing route transitions, component
mount/unmount behavior, loading-state correctness, duplicate
API calls, and perceived latency. Treated loading logic
defects as architecture/performance issues.
2026-10-01 15:19:13 +02:00
b2c245d6bd Optimize navigation by disabling force page reloads
Updated `Navigation.NavigateTo` calls across multiple files to set `forceLoad` to `false`, preventing unnecessary full page reloads.

- **LanguageControls.razor**: Prevent full reload when switching languages.
- **EnvelopeSenderPage.razor**: Avoid reload during logout navigation.
- **LoginReceiverPage.razor**: Skip reload after successful envelope receiver login.
- **LoginSenderPage.razor**: Prevent reload after successful sender login.

These changes improve performance and enhance user experience by enabling smoother page transitions.
2026-10-01 15:07:37 +02:00
e9c0ec139a Fix null reference issue in DocumentController
Added a null-conditional operator (`?.`) to safely access the
`ByteData` property of the `senderDoc` object, preventing a
potential `NullReferenceException` when `senderDoc` is null.
2026-10-01 12:14:46 +02:00
819bdcfcfb Enhance PDF preview with rendering and editing support
Replaced `DxPdfViewer` with `SenderPdfImageEditor` to enable
PDF rendering, page previews, and field overlay editing.
Added dependency injection for `PdfRenderService` and new
state fields (`_previewPageCount`, `_previewPagePreviews`,
`_previewFileName`, `_previewFieldOverlays`) to manage PDF
metadata and interactions.

Updated `OpenPreviewAsync` to render PDF pages using
`PdfRenderService` and handle errors gracefully. Introduced
`PreviewPdfDataUrl` for generating data URLs and a static
helper `EstimatePdfPageCount` for fallback page count
estimation. Added placeholder methods for user interactions
with the editor.

Improved cleanup logic to reset state fields when the viewer
is unmounted. These changes enhance the PDF preview
functionality while maintaining backward compatibility.
2026-10-01 12:05:56 +02:00
ad7686b7c2 Refactor EnvelopeSenderPage for WebAssembly support
Updated EnvelopeSenderPage to use InteractiveWebAssembly
rendering mode. Removed unused services and authentication
logic, replacing `ReceiverPageDataService` with
`DocumentService` for document retrieval. Improved error
handling by replacing `NotFoundException` with
`HttpRequestException` and a 404 status code check.
2026-10-01 11:42:31 +02:00
ade542ded0 Add method to fetch document bytes by envelope ID
Added `GetSenderDocumentBytesByEnvelopeIdAsync` to the
`DocumentService` class. This method retrieves sender
document bytes using an HTTP GET request to the endpoint
`/api/Document?EnvelopeId={envelopeId}`. It handles HTTP
response errors by throwing an `HttpRequestException` and
supports cancellation via a `CancellationToken`.
2026-10-01 11:42:19 +02:00
01c1283622 Refactor ReceiverPage and update AGENTS.md TODOs
Updated AGENTS.md to add a technical debt TODO for modularizing
server pages by splitting large components, reducing duplication,
and centralizing shared logic.

Enhanced ReceiverPage.razor with new state variables and logic
to support a "Reject" popup feature, including state management
for visibility, submission, and validation.

Improved sharing functionality in ReceiverPage.razor by adding
error handling, user feedback, and proper state updates during
asynchronous operations to enhance maintainability and UX.
2026-10-01 10:08:47 +02:00
d969dbe843 Refactor IPdfPageImageRenderer registration
Encapsulated the registration of IPdfPageImageRenderer into a new
AddDocInfrastructure extension method in the DependencyInjection
class within the EnvelopeGenerator.Infrastructure.Doc namespace.

Updated Program.cs to use the AddDocInfrastructure method, improving
modularity and centralizing the service registration logic.
2026-09-30 17:13:09 +02:00
41779a4f58 Merge branch 'master' of http://git.dd:3000/AppStd/EnvelopeGenerator 2026-09-30 17:10:54 +02:00
618451f0c1 resolve merge conflicts 2026-09-30 16:37:16 +02:00
96c7cd6e8a Add "Technical Debt TODOs" section to AGENTS.md
Added a new section in AGENTS.md to document technical debt tasks.
Outlined three specific areas for improvement:
- Refactor `EnvelopeSenderPage.razor` to reduce complexity.
- Move sender dashboard logic to dedicated services/extensions.
- Keep `EnvelopeSenderPage.razor` focused on UI/state orchestration.

These changes aim to improve code modularity, maintainability, and
separation of concerns.
2026-09-30 16:34:06 +02:00
4047ba7c6b Improve envelope status handling and UI enhancements
Refactored status handling to use `GetEffectiveEnvelopeStatus`
for better accuracy and historical context. Updated methods
like `IsEnvelopeSent`, `IsEnvelopeEditable`, and others to
use effective status instead of raw status.

Enhanced receiver status display by adding logic for rejected
receivers (`IsReceiverRejected`) and updated the UI to show
rejection badges with icons and labels. Improved badge
rendering to differentiate between signed, unsigned, and
rejected states.

Improved envelope categorization by ensuring rejected
envelopes are not shown in the active tab. Introduced
`GetEffectiveEnvelopeStatus` to centralize status logic.

Sanitized grid layout filters to prevent persistence of
filter-related properties, ensuring grids appear complete
when restoring layouts. Added utility methods for clearing
filter properties dynamically.

Added comments and refactored code for better readability
and maintainability.
2026-09-30 16:33:19 +02:00
897860b2bf Add rejection handling and dedicated page for envelopes
Introduced a new rejection flow in `ReceiverPage.razor` to detect
rejected envelopes and redirect users to a dedicated rejection
page (`ReceiverRejectedPage.razor`).

- Added `IsRejected` helper method to check envelope rejection
  status based on current status and history.
- Created `ReceiverRejectedPage.razor` to display rejection
  messages, sender details, and navigation options.
- Implemented localized strings for dynamic rejection messages.
- Designed a visually distinct layout for the rejection page.
- Improved user experience with clear messaging and proper
  handling of invalid or unauthorized access.
2026-09-30 14:21:09 +02:00
8933cff558 Add envelope rejection feature with popup UI
Introduced a new feature to allow users to reject envelopes with an optional reason. Added a `RejectAsync` method in `AnnotationService` to handle rejection requests. Updated `ReceiverPage.razor` to include a "Reject" button in the PDF toolbar and a rejection popup for user input.

Implemented state management for the rejection popup, including visibility, submission status, and validation messages. Updated `AnnotationController` to accept `envelopeKey` as a route parameter for the rejection endpoint. Modified `ReceiverClaimExtensions` to support multiple email claim types.

Added new CSS styles for the "Reject" button, including hover effects and dark theme support. Enhanced error handling and logging for the rejection process.
2026-09-30 14:03:56 +02:00
36 changed files with 1495 additions and 271 deletions

View File

@@ -236,6 +236,21 @@ Manual testing workflow:
3. Test sender login at `/sender/login`
4. Test receiver flow at `/envelope/login/{envelopeKey}`
## Technical Debt TODOs
- Refactor `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeSenderPage.razor` to reduce complexity.
- Move sender dashboard business/status logic into dedicated services and extension methods (for example effective status resolution, tab classification, receiver signed/rejected detection, and grid-layout filter sanitation).
- Keep `EnvelopeSenderPage.razor` focused on UI composition/state orchestration; avoid embedding heavy domain logic directly in the Razor component.
- Apply a modular page architecture across Server pages (starting with `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeSenderPage.razor`): split large pages into smaller subcomponents/partial modules, eliminate duplicated code paths, and centralize shared UI/domain logic for maintainability.
- Perform a senior-level page performance review across sender/receiver flows before further UX changes: verify route transitions, component mount/unmount behavior, loading-state correctness, duplicate API calls, and perceived latency (first meaningful paint and return-navigation responsiveness). Treat loading logic defects as architecture/performance issues first, not styling problems.
- Audit `EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/` for unnecessary request/payload remapping and remove redundant mappings where direct request DTO usage is safe; review client HTTP methods end-to-end for similar avoidable transformation layers.
- Centralize dependency injection registrations into dedicated extension methods/modules (especially server + client service registrations in `Program.cs`) to reduce startup composition sprawl and improve maintainability.
- Refactor `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/PdfRenderController.cs` + matching client service contract to remove JSON/base64 `byte[]` transport for PDF rendering; prefer binary streaming (`application/octet-stream` or multipart), avoid `data:` URL bloat where possible, and validate end-to-end memory/latency behavior with large PDFs.
- Establish a DevExpress toast-based notification architecture using `DxToastProvider` + `IToastNotificationService` (tab/page-scoped provider strategy, standardized `ToastOptions` factory, and severity-to-style mapping) so sender/receiver flows show consistent, non-duplicated, actionable feedback.
- Eliminate manual property-by-property request→domain mapping drift (example: `CreateEnvelopeCommandHandler`) by introducing a standardized mapping policy (AutoMapper/profile conventions or reflection/source-generator based mapper) and enforcing it across envelope workflows; this needs to be solved in the shared company Core library level so all services get the same guardrails by default.
- P1 security workstream: remove dynamic SQL string composition/interpolation from active save/create/update paths (`string.Format`, `$"...{input}..."`, `ToSqlParam`) and migrate to parameterized queries only; treat this as a potential SQL injection vulnerability (stability + security risk), execute repo-wide audit, and add CI guardrails to block reintroduction.
- Alternative UX design (Server component): in `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeSenderPage.razor`, enforce deterministic row double-click behavior as exactly one action (preview OR edit), and keep row action buttons/tooltips aligned with the same rule to eliminate ambiguous navigation.
## Database
**SQL Server** (DD_ECM)

View File

@@ -0,0 +1,40 @@
using DigitalData.Core.Exceptions;
using FluentValidation;
using MediatR;
namespace EnvelopeGenerator.Application.Common.Behaviors;
public class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
{
_validators = validators;
}
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
if (_validators.Any())
{
var context = new ValidationContext<TRequest>(request);
var failures = _validators
.Select(v => v.Validate(context))
.SelectMany(r => r.Errors)
.Where(f => f is not null)
.ToList();
if (failures.Count > 0)
{
var message = string.Join("; ", failures.Select(f => f.ErrorMessage).Distinct());
throw new BadRequestException(message);
}
}
return await next();
}
}

View File

@@ -0,0 +1,27 @@
namespace EnvelopeGenerator.Application.Common.Dto;
/// <summary>
/// Data Transfer Object representing document metadata and elements without binary payload.
/// </summary>
public record DocumentDetailsDto
{
/// <summary>
/// Gets or sets the unique identifier of the document.
/// </summary>
public int Id { get; init; }
/// <summary>
/// Gets or sets the envelope ID to which the document belongs.
/// </summary>
public int EnvelopeId { get; init; }
/// <summary>
/// Gets or sets the date and time when the document was added.
/// </summary>
public DateTime AddedWhen { get; init; }
/// <summary>
/// Gets or sets the collection of elements associated with the document for receiver interactions, if any.
/// </summary>
public IEnumerable<DocReceiverElementDto>? Elements { get; init; }
}

View File

@@ -3,30 +3,25 @@
/// <summary>
/// Data Transfer Object representing a document within an envelope, including optional binary data and form elements.
/// </summary>
public class DocumentDto
public record DocumentDto : DocumentDetailsDto
{
/// <summary>
/// Gets or sets the unique identifier of the document.
///
/// </summary>
public int Id { get; set; }
public DocumentDto() { }
/// <summary>
/// Gets or sets the envelope ID to which the document belongs.
///
/// </summary>
public int EnvelopeId { get; set; }
/// <summary>
/// Gets or sets the date and time when the document was added.
/// </summary>
public DateTime AddedWhen { get; set; }
/// <param name="byteData"></param>
/// <param name="details"></param>
public DocumentDto(byte[] byteData, DocumentDetailsDto details) : base(details)
{
ByteData = byteData;
}
/// <summary>
/// Gets or sets the binary data of the document, if available.
/// </summary>
public byte[]? ByteData { get; set; }
/// <summary>
/// Gets or sets the collection of elements associated with the document for receiver interactions, if any.
/// </summary>
public IEnumerable<DocReceiverElementDto>? Elements { get; set; }
}
public byte[]? ByteData { get; init; }
}

View File

@@ -86,6 +86,41 @@ public record EnvelopeDto : IEnvelope
/// </summary>
public int? CertificationType { get; set; }
/// <summary>
///
/// </summary>
public int? FinalEmailToCreator { get; set; }
/// <summary>
///
/// </summary>
public int? FinalEmailToReceivers { get; set; }
/// <summary>
///
/// </summary>
public bool? SendReminderEmails { get; set; }
/// <summary>
///
/// </summary>
public int? FirstReminderDays { get; set; }
/// <summary>
///
/// </summary>
public int? ReminderIntervalDays { get; set; }
/// <summary>
///
/// </summary>
public int? ExpiresWhenDays { get; set; }
/// <summary>
///
/// </summary>
public int? ExpiresWarningWhenDays { get; set; }
/// <summary>
///
/// </summary>
@@ -135,4 +170,4 @@ public record EnvelopeDto : IEnvelope
/// Envelope history entries tracking actions like DocumentSigned, EnvelopeOpened, etc.
/// </summary>
public IEnumerable<HistoryDto>? Histories { get; set; }
}
}

View File

@@ -32,6 +32,7 @@ public class MappingProfile : Profile
.ForMember(dest => dest.EnvelopeTypeTitle,
opt => opt.MapFrom(src => src.Type != null ? src.Type.Title : null));
CreateMap<Document, DocumentDto>();
CreateMap<Document, DocumentDetailsDto>();
CreateMap<History, HistoryDto>().ForMember(dest => dest.ActionDate, opt => opt.MapFrom(src => src.ChangedWhen));
CreateMap<History, HistoryCreateDto>().ForMember(dest => dest.ActionDate, opt => opt.MapFrom(src => src.ChangedWhen));
CreateMap<EnvelopeReceiver, EnvelopeReceiverDto>();
@@ -68,4 +69,4 @@ public class MappingProfile : Profile
? new SmsResponse() { Ok = true }
: new SmsResponse() { Ok = false, Errors = gtxRes });
}
}
}

View File

@@ -10,6 +10,8 @@ using System.Reflection;
using MediatR;
using EnvelopeGenerator.Application.DocReceiverElements.Commands;
using EnvelopeGenerator.Application.DocReceiverElements.Behaviors;
using EnvelopeGenerator.Application.Common.Behaviors;
using FluentValidation;
namespace EnvelopeGenerator.Application;
@@ -59,6 +61,9 @@ public static class DependencyInjection
services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly());
// Generic request validation
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
// Register SignCommand pipeline behaviors in execution order
// 0. EnvelopeReceiverResolutionBehavior - Resolves EnvelopeReceiver from query parameters (executes FIRST)
@@ -80,6 +85,8 @@ public static class DependencyInjection
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, SendSignedMailBehavior>();
});
services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
return services;
}
}

View File

@@ -0,0 +1,44 @@
using AutoMapper;
using AutoMapper.QueryableExtensions;
using DigitalData.Core.Abstraction.Application.Repository;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Domain.Entities;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace EnvelopeGenerator.Application.Documents.Queries;
/// <summary>
/// Represents a query to read document metadata/details without binary payload.
/// </summary>
/// <param name="EnvelopeId">The identifier of the envelope associated with the document.</param>
/// <param name="UserId">The unique identifier of the sending user. Optional.</param>
public record ReadDocumentDetailsQuery(int EnvelopeId, int? UserId = null) : IRequest<DocumentDetailsDto?>;
/// <summary>
/// Handles queries for reading document details without loading BYTE_DATA.
/// </summary>
public class ReadDocumentDetailsQueryHandler : IRequestHandler<ReadDocumentDetailsQuery, DocumentDetailsDto?>
{
private readonly IRepository<Document> _repo;
private readonly IMapper _mapper;
public ReadDocumentDetailsQueryHandler(IRepository<Document> repo, IMapper mapper)
{
_repo = repo;
_mapper = mapper;
}
public async Task<DocumentDetailsDto?> Handle(ReadDocumentDetailsQuery query, CancellationToken cancel)
{
var docQuery = _repo.Query.AsNoTracking();
if (query.UserId is int userId)
docQuery = docQuery.Where(doc => doc.Envelope.UserId == userId);
return await docQuery
.Where(d => d.EnvelopeId == query.EnvelopeId)
.ProjectTo<DocumentDetailsDto>(_mapper.ConfigurationProvider)
.FirstOrDefaultAsync(cancel);
}
}

View File

@@ -0,0 +1,13 @@
using FluentValidation;
namespace EnvelopeGenerator.Application.Documents.Queries;
public class ReadDocumentDetailsQueryValidator : AbstractValidator<ReadDocumentDetailsQuery>
{
public ReadDocumentDetailsQueryValidator()
{
RuleFor(x => x.EnvelopeId)
.GreaterThan(0)
.WithMessage("EnvelopeId is required.");
}
}

View File

@@ -0,0 +1,23 @@
using FluentValidation;
namespace EnvelopeGenerator.Application.Documents.Queries;
public class ReadDocumentQueryValidator : AbstractValidator<ReadDocumentQuery>
{
public ReadDocumentQueryValidator()
{
RuleFor(x => x)
.Must(x => (x.Id ?? 0) > 0 || (x.EnvelopeId ?? 0) > 0)
.WithMessage("Id or EnvelopeId is required.");
RuleFor(x => x.Id)
.GreaterThan(0)
.When(x => x.Id.HasValue)
.WithMessage("Id must be greater than 0.");
RuleFor(x => x.EnvelopeId)
.GreaterThan(0)
.When(x => x.EnvelopeId.HasValue)
.WithMessage("EnvelopeId must be greater than 0.");
}
}

View File

@@ -19,6 +19,8 @@
<PackageReference Include="DigitalData.Core.Client" Version="2.1.0" />
<PackageReference Include="DigitalData.Core.Exceptions" Version="1.1.0" />
<PackageReference Include="DigitalData.EmailProfilerDispatcher" Version="3.1.1" />
<PackageReference Include="FluentValidation" Version="11.11.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" />
<PackageReference Include="HtmlSanitizer" Version="9.0.892" />
<PackageReference Include="MediatR" Version="12.5.0" />
<PackageReference Include="Microsoft.Identity.Client" Version="4.82.1" />

View File

@@ -1,6 +1,5 @@
using EnvelopeGenerator.Application.Common.Dto;
using MediatR;
using AutoMapper;
using DigitalData.Core.Abstraction.Application.Repository;
using EnvelopeGenerator.Domain.Entities;
using Microsoft.EntityFrameworkCore;
@@ -18,17 +17,14 @@ public record ReadEnvelopeTypesQuery : IRequest<IEnumerable<EnvelopeTypeDto>>;
public class ReadEnvelopeTypesQueryHandler : IRequestHandler<ReadEnvelopeTypesQuery, IEnumerable<EnvelopeTypeDto>>
{
private readonly IRepository<EnvelopeType> _repository;
private readonly IMapper _mapper;
/// <summary>
///
/// </summary>
/// <param name="repository"></param>
/// <param name="mapper"></param>
public ReadEnvelopeTypesQueryHandler(IRepository<EnvelopeType> repository, IMapper mapper)
public ReadEnvelopeTypesQueryHandler(IRepository<EnvelopeType> repository)
{
_repository = repository;
_mapper = mapper;
}
/// <summary>
@@ -39,7 +35,28 @@ public class ReadEnvelopeTypesQueryHandler : IRequestHandler<ReadEnvelopeTypesQu
/// <returns></returns>
public async Task<IEnumerable<EnvelopeTypeDto>> Handle(ReadEnvelopeTypesQuery request, CancellationToken cancellationToken)
{
var types = await _repository.Query.ToListAsync(cancellationToken);
return _mapper.Map<IEnumerable<EnvelopeTypeDto>>(types);
// TODO: Temporary manual projection to avoid null-materialization crashes from legacy nullable DB columns in EnvelopeType.
// Revisit and switch to AutoMapper query projection (ProjectTo) after schema nullability and mapping contracts are stabilized.
return await _repository.Query
.AsNoTracking()
.Select(t => new EnvelopeTypeDto
{
Id = t.Id,
Title = t.Title ?? string.Empty,
Language = string.IsNullOrWhiteSpace(t.Language) ? "de-DE" : t.Language,
ExpiresDays = t.ExpiresDays,
CertificationType = (int?)t.CertificationType,
UseAccessCode = t.UseAccessCode,
FinalEmailToCreator = (int?)t.FinalEmailToCreator,
FinalEmailToReceivers = (int?)t.FinalEmailToReceivers,
AddedWhen = t.AddedWhen,
ChangedWhen = t.ChangedWhen,
ExpiresWarningDays = t.ExpiresWarningDays,
SendReminderEmails = t.SendReminderEmails,
FirstReminderDays = t.FirstReminderDays,
ReminderIntervalDays = t.ReminderIntervalDays,
ContractType = t.ContractType
})
.ToListAsync(cancellationToken);
}
}
}

View File

@@ -12,8 +12,66 @@ namespace EnvelopeGenerator.Application.Envelopes.Commands;
/// </summary>
public record CreateEnvelopeCommand : IRequest<EnvelopeDto?>
{
/// <summary>
/// Optionale Umschlag-ID fuer Aktualisierungsvorgaenge.
/// </summary>
public int? EnvelopeId { get; init; }
/// <summary>
/// Kennung des ausgewaehlten Umschlagtyps (Vorlage).
/// </summary>
public int? EnvelopeTypeId { get; set; }
/// <summary>
/// Sprachcode fuer Umschlaginhalt und Benachrichtigungen.
/// </summary>
public string? Language { get; set; }
/// <summary>
/// Gibt an, ob der Zugriffscode fuer Empfaenger aktiviert ist.
/// </summary>
public bool? UseAccessCode { get; set; }
/// <summary>
/// Zertifizierungstyp fuer den Signaturprozess.
/// </summary>
public int? CertificationType { get; set; }
/// <summary>
/// Finale E-Mail-Einstellung fuer den Ersteller.
/// </summary>
public int? FinalEmailToCreator { get; set; }
/// <summary>
/// Finale E-Mail-Einstellung fuer die Empfaenger.
/// </summary>
public int? FinalEmailToReceivers { get; set; }
/// <summary>
/// Gibt an, ob Erinnerungs-E-Mails aktiviert sind.
/// </summary>
public bool? SendReminderEmails { get; set; }
/// <summary>
/// Anzahl der Tage bis zur ersten Erinnerung.
/// </summary>
public int? FirstReminderDays { get; set; }
/// <summary>
/// Intervall in Tagen zwischen Erinnerungs-E-Mails.
/// </summary>
public int? ReminderIntervalDays { get; set; }
/// <summary>
/// Anzahl der Tage bis zum Ablauf des Umschlags.
/// </summary>
public int? ExpiresWhenDays { get; set; }
/// <summary>
/// Anzahl der Tage vor Ablauf, ab denen gewarnt wird.
/// </summary>
public int? ExpiresWarningWhenDays { get; set; }
/// <summary>
/// Der Titel des Umschlags. Dies ist ein Pflichtfeld.
/// </summary>
@@ -29,10 +87,19 @@ public record CreateEnvelopeCommand : IRequest<EnvelopeDto?>
/// </summary>
public bool TFAEnabled { get; set; } = false;
/// <summary>
/// Gibt an, ob der Umschlag nach dem Speichern sofort versendet werden soll.
/// </summary>
public bool Send { get; init; }
/// <summary>
/// Optionaler PDF-Dokumentinhalt als Base64.
/// </summary>
public CreateEnvelopeDocumentDto? Document { get; init; }
/// <summary>
/// Empfaengerliste inklusive optionaler Signaturfeld-Koordinaten.
/// </summary>
public List<CreateEnvelopeReceiverDto> Receivers { get; init; } = new();
/// <summary>

View File

@@ -33,6 +33,8 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
private IRepository<EnvelopeReceiver> EnvelopeReceiverRepository => _provider.GetRequiredService<IRepository<EnvelopeReceiver>>();
private IRepository<EnvelopeType> EnvelopeTypeRepository => _provider.GetRequiredService<IRepository<EnvelopeType>>();
private IRepository<Receiver> ReceiverRepository => _provider.GetRequiredService<IRepository<Receiver>>();
private IRepository<DocReceiverElement> DocReceiverElementRepository => _provider.GetRequiredService<IRepository<DocReceiverElement>>();
@@ -73,6 +75,7 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
var now = DateTime.Now;
var title = request.Title?.Trim() ?? string.Empty;
var message = request.Message?.Trim() ?? string.Empty;
var settings = await ResolveEnvelopeSettingsAsync(request, existing: null, cancel);
Envelope envelope;
if (request.EnvelopeId is int envelopeId)
@@ -85,11 +88,27 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
if (envelope.UserId != request.UserId)
throw new ForbiddenException();
settings = await ResolveEnvelopeSettingsAsync(request, envelope, cancel);
await Repository.UpdateAsync(e =>
{
e.Title = title;
e.Message = message;
e.TfaEnabled = request.TFAEnabled;
e.EnvelopeTypeId = settings.EnvelopeTypeId;
e.Language = settings.Language;
e.UseAccessCode = settings.UseAccessCode;
e.CertificationType = settings.CertificationType;
e.FinalEmailToCreator = settings.FinalEmailToCreator;
e.FinalEmailToReceivers = settings.FinalEmailToReceivers;
e.SendReminderEmails = settings.SendReminderEmails;
e.FirstReminderDays = settings.FirstReminderDays;
e.ReminderIntervalDays = settings.ReminderIntervalDays;
e.ExpiresWhenDays = settings.ExpiresWhenDays;
e.ExpiresWarningWhenDays = settings.ExpiresWarningWhenDays;
e.ContractType = settings.ContractType;
e.ExpiresWhen = settings.ExpiresWhen;
e.ExpiresWarningWhen = settings.ExpiresWarningWhen;
e.Status = EnvelopeStatus.EnvelopeSaved;
e.ChangedWhen = now;
}, e => e.Id == envelopeId, cancel);
@@ -97,6 +116,20 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
envelope.Title = title;
envelope.Message = message;
envelope.TfaEnabled = request.TFAEnabled;
envelope.EnvelopeTypeId = settings.EnvelopeTypeId;
envelope.Language = settings.Language;
envelope.UseAccessCode = settings.UseAccessCode;
envelope.CertificationType = settings.CertificationType;
envelope.FinalEmailToCreator = settings.FinalEmailToCreator;
envelope.FinalEmailToReceivers = settings.FinalEmailToReceivers;
envelope.SendReminderEmails = settings.SendReminderEmails;
envelope.FirstReminderDays = settings.FirstReminderDays;
envelope.ReminderIntervalDays = settings.ReminderIntervalDays;
envelope.ExpiresWhenDays = settings.ExpiresWhenDays;
envelope.ExpiresWarningWhenDays = settings.ExpiresWarningWhenDays;
envelope.ContractType = settings.ContractType;
envelope.ExpiresWhen = settings.ExpiresWhen;
envelope.ExpiresWarningWhen = settings.ExpiresWarningWhen;
envelope.Status = EnvelopeStatus.EnvelopeSaved;
envelope.ChangedWhen = now;
}
@@ -114,12 +147,69 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
AddedWhen = now,
ChangedWhen = now,
Comment = string.Empty,
Language = "de-DE",
EnvelopeTypeId = settings.EnvelopeTypeId,
Language = settings.Language,
TfaEnabled = request.TFAEnabled,
UseAccessCode = false,
SendReminderEmails = false
UseAccessCode = settings.UseAccessCode,
CertificationType = settings.CertificationType,
FinalEmailToCreator = settings.FinalEmailToCreator,
FinalEmailToReceivers = settings.FinalEmailToReceivers,
SendReminderEmails = settings.SendReminderEmails,
FirstReminderDays = settings.FirstReminderDays,
ReminderIntervalDays = settings.ReminderIntervalDays,
ExpiresWhenDays = settings.ExpiresWhenDays,
ExpiresWarningWhenDays = settings.ExpiresWarningWhenDays,
ExpiresWhen = settings.ExpiresWhen,
ExpiresWarningWhen = settings.ExpiresWarningWhen,
ContractType = settings.ContractType
}, cancel);
if (request.UseSQLExecutor)
{
await Repository.UpdateAsync(e =>
{
e.Title = title;
e.Message = message;
e.TfaEnabled = request.TFAEnabled;
e.EnvelopeTypeId = settings.EnvelopeTypeId;
e.Language = settings.Language;
e.UseAccessCode = settings.UseAccessCode;
e.CertificationType = settings.CertificationType;
e.FinalEmailToCreator = settings.FinalEmailToCreator;
e.FinalEmailToReceivers = settings.FinalEmailToReceivers;
e.SendReminderEmails = settings.SendReminderEmails;
e.FirstReminderDays = settings.FirstReminderDays;
e.ReminderIntervalDays = settings.ReminderIntervalDays;
e.ExpiresWhenDays = settings.ExpiresWhenDays;
e.ExpiresWarningWhenDays = settings.ExpiresWarningWhenDays;
e.ExpiresWhen = settings.ExpiresWhen;
e.ExpiresWarningWhen = settings.ExpiresWarningWhen;
e.ContractType = settings.ContractType;
e.Status = EnvelopeStatus.EnvelopeSaved;
e.ChangedWhen = now;
}, e => e.Id == envelope.Id, cancel);
envelope.Title = title;
envelope.Message = message;
envelope.TfaEnabled = request.TFAEnabled;
envelope.EnvelopeTypeId = settings.EnvelopeTypeId;
envelope.Language = settings.Language;
envelope.UseAccessCode = settings.UseAccessCode;
envelope.CertificationType = settings.CertificationType;
envelope.FinalEmailToCreator = settings.FinalEmailToCreator;
envelope.FinalEmailToReceivers = settings.FinalEmailToReceivers;
envelope.SendReminderEmails = settings.SendReminderEmails;
envelope.FirstReminderDays = settings.FirstReminderDays;
envelope.ReminderIntervalDays = settings.ReminderIntervalDays;
envelope.ExpiresWhenDays = settings.ExpiresWhenDays;
envelope.ExpiresWarningWhenDays = settings.ExpiresWarningWhenDays;
envelope.ExpiresWhen = settings.ExpiresWhen;
envelope.ExpiresWarningWhen = settings.ExpiresWarningWhen;
envelope.ContractType = settings.ContractType;
envelope.Status = EnvelopeStatus.EnvelopeSaved;
envelope.ChangedWhen = now;
}
await Mediator.Send(new CreateHistoryCommand
{
EnvelopeId = envelope.Id,
@@ -143,6 +233,105 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
return _mapper.Map<EnvelopeDto>(envelope);
}
private async Task<EnvelopeSettings> ResolveEnvelopeSettingsAsync(CreateEnvelopeCommand request, Envelope? existing, CancellationToken cancel)
{
EnvelopeType? selectedType = null;
if (request.EnvelopeTypeId is int envelopeTypeId && envelopeTypeId > 0)
{
selectedType = await EnvelopeTypeRepository.Query
.FirstOrDefaultAsync(t => t.Id == envelopeTypeId, cancel)
?? throw new BadRequestException($"Envelope type {envelopeTypeId} not found.");
}
var expiresWhenDays = Math.Max(0,
request.ExpiresWhenDays
?? selectedType?.ExpiresWhenDays
?? existing?.ExpiresWhenDays
?? 0);
var expiresWarningWhenDays = Math.Max(0,
request.ExpiresWarningWhenDays
?? selectedType?.ExpiresWarningWhenDays
?? existing?.ExpiresWarningWhenDays
?? 0);
var useAccessCode = request.UseAccessCode
?? selectedType?.UseAccessCode
?? existing?.UseAccessCode
?? false;
if (request.TFAEnabled)
useAccessCode = true;
return new EnvelopeSettings(
EnvelopeTypeId: request.EnvelopeTypeId ?? existing?.EnvelopeTypeId,
Language: ResolveLanguage(request.Language, selectedType?.Language, existing?.Language),
UseAccessCode: useAccessCode,
CertificationType: request.CertificationType
?? (int?)selectedType?.CertificationType
?? existing?.CertificationType
?? (int)CertificationType.AdvancedElectronicSignature,
FinalEmailToCreator: request.FinalEmailToCreator
?? (int?)selectedType?.FinalEmailToCreator
?? existing?.FinalEmailToCreator
?? (int)FinalEmailType.No,
FinalEmailToReceivers: request.FinalEmailToReceivers
?? (int?)selectedType?.FinalEmailToReceivers
?? existing?.FinalEmailToReceivers
?? (int)FinalEmailType.No,
SendReminderEmails: request.SendReminderEmails
?? selectedType?.SendReminderEmails
?? existing?.SendReminderEmails
?? false,
FirstReminderDays: Math.Max(0,
request.FirstReminderDays
?? selectedType?.FirstReminderDays
?? existing?.FirstReminderDays
?? 0),
ReminderIntervalDays: Math.Max(0,
request.ReminderIntervalDays
?? selectedType?.ReminderIntervalDays
?? existing?.ReminderIntervalDays
?? 0),
ExpiresWhenDays: expiresWhenDays,
ExpiresWarningWhenDays: expiresWarningWhenDays,
ExpiresWhen: expiresWhenDays > 0 ? DateTime.Now.AddDays(expiresWhenDays) : null,
ExpiresWarningWhen: expiresWarningWhenDays > 0 ? DateTime.Now.AddDays(expiresWarningWhenDays) : null,
ContractType: existing?.ContractType
?? selectedType?.ContractType
?? (int)ContractType.Contract);
}
private static string ResolveLanguage(string? preferred, string? fallback1, string? fallback2)
{
if (!string.IsNullOrWhiteSpace(preferred))
return preferred.Trim();
if (!string.IsNullOrWhiteSpace(fallback1))
return fallback1.Trim();
if (!string.IsNullOrWhiteSpace(fallback2))
return fallback2.Trim();
return "de-DE";
}
private sealed record EnvelopeSettings(
int? EnvelopeTypeId,
string Language,
bool UseAccessCode,
int CertificationType,
int FinalEmailToCreator,
int FinalEmailToReceivers,
bool SendReminderEmails,
int FirstReminderDays,
int ReminderIntervalDays,
int ExpiresWhenDays,
int ExpiresWarningWhenDays,
DateTime? ExpiresWhen,
DateTime? ExpiresWarningWhen,
int ContractType);
private async Task<string> ResolveSenderReferenceAsync(Envelope envelope, CancellationToken cancel)
{
if (!string.IsNullOrWhiteSpace(envelope.User?.Email))

View File

@@ -22,41 +22,41 @@ namespace EnvelopeGenerator.Domain.Entities
public string Language { get; set; }
[Column("EXPIRES_DAYS")]
public int ExpiresDays { get; set; }
public int? ExpiresDays { get; set; }
[Column("CERTIFICATION_TYPE")]
public Constants.CertificationType CertificationType { get; set; } = Constants.CertificationType.AdvancedElectronicSignature;
public Constants.CertificationType? CertificationType { get; set; }
[Column("USE_ACCESS_CODE")]
public bool UseAccessCode { get; set; }
public bool? UseAccessCode { get; set; }
[Column("FINAL_EMAIL_TO_CREATOR")]
public Constants.FinalEmailType FinalEmailToCreator { get; set; }
public Constants.FinalEmailType? FinalEmailToCreator { get; set; }
[Column("FINAL_EMAIL_TO_RECEIVERS")]
public Constants.FinalEmailType FinalEmailToReceivers { get; set; }
public Constants.FinalEmailType? FinalEmailToReceivers { get; set; }
[Required]
[Column("ADDED_WHEN", TypeName = "datetime")]
public DateTime AddedWhen { get; set; }
[Column("CHANGED_WHEN", TypeName = "datetime")]
public DateTime ChangedWhen { get; set; }
public DateTime? ChangedWhen { get; set; }
[Column("EXPIRES_WARNING_DAYS")]
public int ExpiresWarningDays { get; set; }
public int? ExpiresWarningDays { get; set; }
[Column("SEND_REMINDER_EMAILS")]
public bool SendReminderEmails { get; set; } = false;
public bool? SendReminderEmails { get; set; }
[Column("FIRST_REMINDER_DAYS")]
public int FirstReminderDays { get; set; } = 0;
public int? FirstReminderDays { get; set; }
[Column("REMINDER_INTERVAL_DAYS")]
public int ReminderIntervalDays { get; set; } = 0;
public int? ReminderIntervalDays { get; set; }
[Column("CONTRACT_TYPE")]
public int ContractType { get; set; }
public int? ContractType { get; set; }
[NotMapped]
public bool TFA_Enabled { get; set; } = false;
@@ -70,14 +70,14 @@ namespace EnvelopeGenerator.Domain.Entities
[NotMapped]
public int ExpiresWhenDays
{
get => ExpiresDays;
get => ExpiresDays ?? 0;
set => ExpiresDays = value;
}
[NotMapped]
public int ExpiresWarningWhenDays
{
get => ExpiresWarningDays;
get => ExpiresWarningDays ?? 0;
set => ExpiresWarningDays = value;
}
@@ -86,4 +86,4 @@ namespace EnvelopeGenerator.Domain.Entities
return Title;
}
}
}
}

View File

@@ -0,0 +1,18 @@
using EnvelopeGenerator.Application.Common.Interfaces.Doc;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace EnvelopeGenerator.Infrastructure.Doc;
public static class DependencyInjection
{
public static IServiceCollection AddDocInfrastructure(this IServiceCollection services)
{
services.AddScoped<IPdfPageImageRenderer, PdfPageImageRenderer>();
return services;
}
}

View File

@@ -47,9 +47,7 @@
{
<div class="sender-image-editor__empty">@L("SenderEditor_NoPagesToDisplay", "Keine Seiten zur Anzeige verfügbar.")</div>
}
else
{
@if (Pages.Count == 0 && !string.IsNullOrWhiteSpace(PdfDataUrl))
else if (Pages.Count == 0 && !string.IsNullOrWhiteSpace(PdfDataUrl))
{
<div class="sender-image-editor__native-preview" style="@GetZoomStyle()">
<iframe class="sender-image-editor__native-frame"
@@ -57,59 +55,60 @@
title="PDF Preview"></iframe>
</div>
}
else
{
<div class="sender-image-editor__pages" style="@GetZoomStyle()">
@for (var pageIndex = 0; pageIndex < PageCount; pageIndex++)
{
var pagePreview = pageIndex < Pages.Count ? Pages[pageIndex] : null;
var pageNumber = pagePreview?.PageNumber ?? (pageIndex + 1);
var widthPt = pagePreview?.WidthPt ?? 595.0;
var heightPt = pagePreview?.HeightPt ?? 842.0;
<div class="sender-image-editor__pages" style="@GetZoomStyle()">
@for (var pageIndex = 0; pageIndex < PageCount; pageIndex++)
{
var pagePreview = pageIndex < Pages.Count ? Pages[pageIndex] : null;
var pageNumber = pagePreview?.PageNumber ?? (pageIndex + 1);
var widthPt = pagePreview?.WidthPt ?? 595.0;
var heightPt = pagePreview?.HeightPt ?? 842.0;
<div class="sender-image-editor__page-block"
data-editor-page="@pageNumber"
data-page-width-pt="@widthPt"
data-page-height-pt="@heightPt">
<div class="sender-image-editor__page-canvas"
data-editor-page-canvas="@pageNumber"
style="@GetPageCanvasStyle(widthPt, heightPt)">
@if (!string.IsNullOrWhiteSpace(pagePreview?.ImageDataUrl))
{
<img class="sender-image-editor__page-image"
src="@(pagePreview!.ImageDataUrl)"
alt="PDF Page @pageNumber"
draggable="false" />
}
else
{
<div class="sender-image-editor__page-placeholder">@L("SenderEditor_PageLabel", "Seite") @pageNumber</div>
}
<div class="sender-image-editor__page-block"
data-editor-page="@pageNumber"
data-page-width-pt="@widthPt"
data-page-height-pt="@heightPt">
<div class="sender-image-editor__page-canvas"
data-editor-page-canvas="@pageNumber"
style="@GetPageCanvasStyle(widthPt, heightPt)">
@if (!string.IsNullOrWhiteSpace(pagePreview?.ImageDataUrl))
{
<img class="sender-image-editor__page-image"
src="@(pagePreview!.ImageDataUrl)"
alt="PDF Page @pageNumber"
draggable="false" />
}
else
{
<div class="sender-image-editor__page-placeholder">@L("SenderEditor_PageLabel", "Seite") @pageNumber</div>
}
@foreach (var field in Fields.Where(f => f.Page == pageNumber))
{
<div class="sender-image-editor__field"
style="@GetFieldStyle(field, widthPt, heightPt)"
title="@field.ReceiverName"
@onmousedown="args => OnFieldMouseDownAsync(args, field)"
@onmousedown:preventDefault="true"
@onclick:stopPropagation="true">
<span class="sender-image-editor__field-label">@field.ReceiverName</span>
<button type="button"
class="sender-image-editor__field-remove"
title='@L("Delete", "Löschen")'
draggable="false"
@onclick:stopPropagation="true"
@onclick="() => RemoveField.InvokeAsync(field.Index)">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M4 4L12 12M12 4L4 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
</div>
}
@foreach (var field in Fields.Where(f => f.Page == pageNumber))
{
<div class="sender-image-editor__field"
style="@GetFieldStyle(field, widthPt, heightPt)"
title="@field.ReceiverName"
@onmousedown="args => OnFieldMouseDownAsync(args, field)"
@onmousedown:preventDefault="true"
@onclick:stopPropagation="true">
<span class="sender-image-editor__field-label">@field.ReceiverName</span>
<button type="button"
class="sender-image-editor__field-remove"
title='@L("Delete", "Löschen")'
draggable="false"
@onclick:stopPropagation="true"
@onclick="() => RemoveField.InvokeAsync(field.Index)">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M4 4L12 12M12 4L4 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
</div>
}
</div>
</div>
</div>
}
</div>
}
</div>
}
</div>
</div>

View File

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

View File

@@ -1,24 +1,22 @@
@page "/sender"
@rendermode InteractiveServer
@rendermode InteractiveWebAssembly
@using System.Text.Json
@using EnvelopeGenerator.Domain.Constants
@using EnvelopeGenerator.Server.Client.Models
@using DevExpress.Blazor
@using DevExpress.Blazor.PdfViewer
@using EnvelopeGenerator.Server.Client.Services
@using EnvelopeGenerator.Application.Common.Dto
@using EnvelopeGenerator.Application.Common.Dto.History
@using EnvelopeGenerator.Application.Resources
@using EnvelopeGenerator.Domain.Resources
@using EnvelopeGenerator.Server.Services
@using Microsoft.Extensions.Localization
@inject EnvelopeGenerator.Server.Client.Services.EnvelopeService EnvelopeService
@inject EnvelopeGenerator.Server.Client.Services.DocumentService DocumentService
@inject EnvelopeGenerator.Server.Client.Services.PdfRenderService PdfRenderService
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
@inject EnvelopeGenerator.Server.Client.Services.ThemeService ThemeService
@inject EnvelopeGenerator.Server.Client.Services.UserPreferencesService UserPreferencesService
@inject EnvelopeGenerator.Server.Client.Services.CustomImagesService CustomImagesService
@inject EnvelopeReceiverPageDataService ReceiverPageDataService
@inject IEnvelopeAuthService EnvelopeAuthService
@inject ILogger<EnvelopeSenderPage> Logger
@inject NavigationManager Navigation
@inject IJSRuntime JSRuntime
@@ -262,7 +260,7 @@
var envelope = cellContext.DataItem as EnvelopeDto;
if (envelope != null)
{
var statusInfo = GetStatusInfo(envelope.Status);
var statusInfo = GetStatusInfo(GetEffectiveEnvelopeStatus(envelope));
<div class="status-badge status-badge--@statusInfo.CssClass">
<span class="status-dot status-dot--@statusInfo.DotColor"></span>
@statusInfo.Label
@@ -314,12 +312,20 @@
{
<div style="display: flex; align-items: center; gap: 1rem; padding: 0.5rem; background: white; border-radius: 6px; border: 1px solid #e5e7eb;">
@{
var isRejected = IsReceiverRejected(envelope, receiver);
var isSigned = IsReceiverSigned(envelope, receiver);
var accessCode = receiver.AccessCode;
var hasAccessCode = !string.IsNullOrWhiteSpace(accessCode);
}
<span class="receiver-badge receiver-badge--@(isSigned ? "signed" : "unsigned")" style="min-width: 100px;">
@if (isSigned)
<span class="receiver-badge receiver-badge--@(isRejected ? "unsigned" : (isSigned ? "signed" : "unsigned"))" style="min-width: 100px;">
@if (isRejected)
{
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z" />
</svg>
<span>@GetStatusLabel(EnvelopeStatus.DocumentRejected)</span>
}
else if (isSigned)
{
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
<path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z" />
@@ -459,10 +465,15 @@
else if (_previewPdfBytes is not null && _previewViewerMounted)
{
<div class="sender-preview-viewer-wrap">
<DxPdfViewer DocumentContent="@_previewPdfBytes"
ZoomLevel="-2"
IsSinglePagePreview="true"
CssClass="sender-preview-pdf-viewer" />
<SenderPdfImageEditor IsPlacementMode="false"
PageCount="@_previewPageCount"
Pages="@_previewPagePreviews"
PdfDataUrl="@PreviewPdfDataUrl"
PdfFileName="@_previewFileName"
Fields="@_previewFieldOverlays"
EditorClick="OnPreviewEditorClickAsync"
RemoveField="OnPreviewRemoveFieldAsync"
MoveField="OnPreviewMoveFieldAsync" />
</div>
}
</BodyContentTemplate>
@@ -536,7 +547,15 @@
private bool _previewLoading = false;
private bool _previewViewerMounted = false;
private byte[]? _previewPdfBytes = null;
private int _previewPageCount = 0;
private List<PdfPageImageDto> _previewPagePreviews = [];
private string _previewFileName = "document.pdf";
private string? _previewErrorMessage = null;
private readonly IReadOnlyList<SenderPdfFieldOverlay> _previewFieldOverlays = [];
private string? PreviewPdfDataUrl => _previewPdfBytes is { Length: > 0 }
? $"data:application/pdf;base64,{Convert.ToBase64String(_previewPdfBytes)}"
: null;
// Phase 6e (step 1): UI state only. Export logic is added in step 2.
private string _selectedExportFormat = "xlsx";
@@ -590,10 +609,11 @@
{
_allEnvelopes = await EnvelopeService.GetAsync() ?? [];
// Split into active and completed based on status
// Split into active and completed based on effective status
// (history-aware: rejected envelopes should not stay in active tab).
var envelopes = _allEnvelopes.ToList();
_activeEnvelopes = envelopes.Where(e => ((EnvelopeStatus)e.Status).IsActive()).ToList();
_completedEnvelopes = envelopes.Where(e => ((EnvelopeStatus)e.Status).IsCompleted()).ToList();
_activeEnvelopes = envelopes.Where(e => GetEffectiveEnvelopeStatus(e).IsActive()).ToList();
_completedEnvelopes = envelopes.Where(e => GetEffectiveEnvelopeStatus(e).IsCompleted()).ToList();
}
catch (Exception ex)
{
@@ -740,18 +760,18 @@
await InvokeAsync(StateHasChanged);
UserPreferencesService.InvalidateCache();
await AuthService.LogoutSenderAsync();
Navigation.NavigateTo("/sender/login", forceLoad: true);
Navigation.NavigateTo("/sender/login", forceLoad: false);
}
bool IsEnvelopeSent(EnvelopeDto envelope)
{
var status = (EnvelopeStatus)envelope.Status;
var status = GetEffectiveEnvelopeStatus(envelope);
return status >= EnvelopeStatus.EnvelopeQueued;
}
bool IsEnvelopeEditable(EnvelopeDto envelope)
{
var status = (EnvelopeStatus)envelope.Status;
var status = GetEffectiveEnvelopeStatus(envelope);
return status is EnvelopeStatus.EnvelopeCreated or EnvelopeStatus.EnvelopeSaved;
}
@@ -800,7 +820,7 @@
IReadOnlyList<StatusFilterItem> GetStatusFilterItems(IEnumerable<EnvelopeDto>? source)
{
return (source ?? Enumerable.Empty<EnvelopeDto>())
.Select(x => x.Status)
.Select(GetEffectiveEnvelopeStatus)
.Distinct()
.OrderBy(x => x)
.Select(x => new StatusFilterItem(x, GetStatusLabel(x)))
@@ -818,7 +838,7 @@
bool IsReceiverSigned(EnvelopeDto envelope, EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto receiver)
{
var status = (EnvelopeStatus)envelope.Status;
var status = GetEffectiveEnvelopeStatus(envelope);
if (status is EnvelopeStatus.EnvelopeCompletelySigned or EnvelopeStatus.MessageCompletionSent)
return true;
@@ -841,6 +861,43 @@
return false;
}
bool IsReceiverRejected(EnvelopeDto envelope, EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto receiver)
{
var status = GetEffectiveEnvelopeStatus(envelope);
var histories = envelope.Histories?.Where(h =>
h.Status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected).ToList() ?? [];
if (histories.Count == 0)
return false;
if (histories.Any(h => h.Receiver?.Id == receiver.ReceiverId))
return true;
var receiverEmail = receiver.Receiver?.EmailAddress;
if (!string.IsNullOrWhiteSpace(receiverEmail) &&
histories.Any(h => string.Equals(h.UserReference, receiverEmail, StringComparison.OrdinalIgnoreCase)))
return true;
if (!string.IsNullOrWhiteSpace(receiver.Name) &&
histories.Any(h => string.Equals(h.UserReference, receiver.Name, StringComparison.OrdinalIgnoreCase)))
return true;
return status == EnvelopeStatus.EnvelopeRejected && (envelope.EnvelopeReceivers?.Count() ?? 0) == 1;
}
EnvelopeStatus GetEffectiveEnvelopeStatus(EnvelopeDto envelope)
{
var rawStatus = envelope.Status;
if (rawStatus is EnvelopeStatus.EnvelopeRejected or EnvelopeStatus.EnvelopeWithdrawn)
return rawStatus;
var hasRejectedHistory = (envelope.Histories ?? Enumerable.Empty<HistoryDto>())
.Any(h => h.Status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected);
return hasRejectedHistory ? EnvelopeStatus.EnvelopeRejected : rawStatus;
}
DateTime GetHistoryDate(HistoryDto history)
{
return history.ActionDate ?? history.AddedWhen;
@@ -916,6 +973,9 @@
// Unmount the viewer first — let the previous instance fully dispose
_previewViewerMounted = false;
_previewPdfBytes = null;
_previewPageCount = 0;
_previewPagePreviews = [];
_previewFileName = $"envelope-{envelope.Id}.pdf";
_previewErrorMessage = null;
_previewLoading = true;
_previewVisible = true;
@@ -923,19 +983,35 @@
try
{
var user = EnvelopeAuthService.GetCurrentUser();
if (user?.Identity is null || !user.Identity.IsAuthenticated)
_previewPdfBytes = await DocumentService.GetSenderDocumentBytesByEnvelopeIdAsync(envelope.Id);
if (_previewPdfBytes is null || _previewPdfBytes.Length == 0)
{
_previewErrorMessage = L("NotAuthenticatedPleaseLoginAgain", "Nicht authentifiziert. Bitte melden Sie sich erneut an.");
_previewErrorMessage = L("DocumentCouldNotBeLoaded", "Dokument konnte nicht geladen werden");
return;
}
_previewPdfBytes = await ReceiverPageDataService.GetDocumentOfSenderAsync(user, envelope.Id);
var previewPdfBytes = _previewPdfBytes;
_previewPagePreviews = [];
try
{
_previewPagePreviews = (await PdfRenderService.RenderPagesAsync(previewPdfBytes))
.OrderBy(p => p.PageNumber)
.ToList();
}
catch (Exception renderEx)
{
Logger.LogWarning(renderEx, "Preview: page image rendering failed for envelope {EnvelopeId}. Falling back to native PDF preview.", envelope.Id);
}
_previewPageCount = _previewPagePreviews.Count > 0
? _previewPagePreviews.Count
: EstimatePdfPageCount(previewPdfBytes) ?? 1;
// PDF ready — mount the viewer now
_previewViewerMounted = true;
}
catch (DigitalData.Core.Exceptions.NotFoundException)
catch (HttpRequestException ex) when ((int?)ex.StatusCode == 404)
{
_previewErrorMessage = string.Format(
L("Sender_PreviewDocumentNotFound", "Das Dokument für Umschlag #{0} wurde nicht gefunden oder Sie haben keinen Zugriff darauf."),
@@ -965,12 +1041,60 @@
{
await Task.Yield(); // wait one render cycle for the viewer to be removed from the DOM
_previewPdfBytes = null;
_previewPageCount = 0;
_previewPagePreviews = [];
_previewFileName = "document.pdf";
_previewErrorMessage = null;
_previewLoading = false;
await InvokeAsync(StateHasChanged);
});
}
Task OnPreviewEditorClickAsync(Microsoft.AspNetCore.Components.Web.MouseEventArgs _)
{
return Task.CompletedTask;
}
Task OnPreviewRemoveFieldAsync(int _)
{
return Task.CompletedTask;
}
Task OnPreviewMoveFieldAsync(SenderPdfFieldMoveRequest _)
{
return Task.CompletedTask;
}
static int? EstimatePdfPageCount(byte[]? documentBytes)
{
if (documentBytes is null || documentBytes.Length == 0)
return null;
try
{
var text = System.Text.Encoding.ASCII.GetString(documentBytes);
const string marker = "/Type /Page";
var count = 0;
var index = 0;
while (true)
{
index = text.IndexOf(marker, index, StringComparison.Ordinal);
if (index < 0)
break;
count++;
index += marker.Length;
}
return count > 0 ? count : null;
}
catch
{
return null;
}
}
// ── Grid Layout Persistence ───────────────────────────────────────────────
// LayoutAutoLoading fires when the grid initializes — restore saved layout.
// LayoutAutoSaving fires when the user changes column order/width/sort — persist it.
@@ -983,13 +1107,18 @@
// If cache is empty (LoadFromServerAsync failed), skip layout restore silently.
var prefs = UserPreferencesService.GetCached();
if (prefs is not null && prefs.GridLayouts.TryGetValue(CurrentGridKey, out var json))
e.Layout = JsonSerializer.Deserialize<GridPersistentLayout>(json);
{
var layout = JsonSerializer.Deserialize<GridPersistentLayout>(json);
SanitizeLayoutFilters(layout);
e.Layout = layout;
}
}
async Task OnGridLayoutAutoSaving(GridPersistentLayoutEventArgs e)
{
try
{
SanitizeLayoutFilters(e.Layout);
var json = JsonSerializer.Serialize(e.Layout);
await UserPreferencesService.SaveGridLayoutAsync(CurrentGridKey, json);
}
@@ -999,5 +1128,55 @@
}
}
// Keep column/order personalization, but never persist/restore data filters.
// Tab counters are based on tab datasets; persisted grid filters can hide rows and
// make the grid appear empty or incomplete.
static void SanitizeLayoutFilters(GridPersistentLayout? layout)
{
if (layout is null)
return;
ClearWritableProperty(layout, "FilterCriteria");
ClearWritableProperty(layout, "SearchText");
if (layout.Columns is not System.Collections.IEnumerable columns)
return;
foreach (var column in columns)
{
if (column is null)
continue;
ClearWritableProperty(column, "FilterCriteria");
ClearWritableProperty(column, "FilterValues");
ClearWritableProperty(column, "FilterValue");
ClearWritableProperty(column, "SearchText");
}
}
static void ClearWritableProperty(object target, string propertyName)
{
var prop = target.GetType()
.GetProperties(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public)
.FirstOrDefault(p => p.Name == propertyName && p.GetIndexParameters().Length == 0);
if (prop is null || !prop.CanWrite)
return;
var propertyType = prop.PropertyType;
object? value;
if (!propertyType.IsValueType || Nullable.GetUnderlyingType(propertyType) is not null)
{
value = null;
}
else
{
value = Activator.CreateInstance(propertyType);
}
prop.SetValue(target, value);
}
private sealed record StatusFilterItem(EnvelopeStatus Value, string Text);
}

View File

@@ -170,7 +170,7 @@
if (result == EnvelopeLoginResult.Success)
{
Navigation.NavigateTo($"/envelope/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true);
Navigation.NavigateTo($"/envelope/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: false);
return;
}

View File

@@ -162,7 +162,7 @@
var result = await AuthService.LoginSenderAsync(Username.Trim(), Password.Trim());
if (result == SenderLoginResult.Success) {
Navigation.NavigateTo("/sender", forceLoad: true);
Navigation.NavigateTo("/sender", forceLoad: false);
return;
}

View File

@@ -5,6 +5,7 @@
@using EnvelopeGenerator.Application.Common.Dto
@using EnvelopeGenerator.Application.Envelopes.Commands
@using EnvelopeGenerator.Application.Resources
@using EnvelopeGenerator.Domain.Constants
@using EnvelopeGenerator.Server.Client.Components.PdfEditor
@using EnvelopeGenerator.Server.Client.Services
@using Microsoft.AspNetCore.WebUtilities
@@ -18,6 +19,8 @@
@inject PdfRenderService PdfRenderService
@inject ReceiverService ReceiverService
@inject EnvelopeService EnvelopeService
@inject EnvelopeTypeService EnvelopeTypeService
@inject AuthService AuthService
@inject IStringLocalizer<Resource> UiLocalizer
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
@@ -94,6 +97,107 @@
}
</div>
<details style="border:1px solid #dbe5ef;border-radius:8px;background:#f9fbfd;">
<summary style="cursor:pointer;list-style:none;display:flex;align-items:center;justify-content:space-between;padding:0.55rem 0.7rem;font-size:0.74rem;font-weight:700;color:#1f2937;text-transform:uppercase;letter-spacing:0.04em;">
<span>@L("SenderEditor_EnvelopeSettings", "Envelope-Einstellungen")</span>
<span style="font-size:0.66rem;color:#64748b;font-weight:600;">@L("SenderEditor_AdvancedOptional", "Erweitert")</span>
</summary>
<div style="padding:0 0.7rem 0.7rem;display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:0.45rem;">
<div style="display:flex;flex-direction:column;gap:0.2rem;">
<label style="font-size:0.65rem;font-weight:600;color:#64748b;">@L("EnvelopeType", "Umschlagtyp")</label>
<select value="@(_selectedEnvelopeTypeId?.ToString() ?? string.Empty)"
@onchange="OnEnvelopeTypeChanged"
style="font-size:0.78rem;border:1px solid #d1d5db;border-radius:5px;padding:0.3rem 0.45rem;background:#fff;">
<option value="">@L("SenderEditor_SelectTemplate", "Vorlage auswählen")</option>
@foreach (var type in _envelopeTypes)
{
<option value="@type.Id">@type.Title</option>
}
</select>
</div>
<div style="display:flex;flex-direction:column;gap:0.2rem;">
<label style="font-size:0.65rem;font-weight:600;color:#64748b;">@L("Language", "Sprache")</label>
<select @bind="_language"
style="font-size:0.78rem;border:1px solid #d1d5db;border-radius:5px;padding:0.3rem 0.45rem;background:#fff;">
<option value="de">Deutsch (de)</option>
<option value="en">English (en)</option>
<option value="de-DE">Deutsch (de-DE)</option>
<option value="en-US">English (en-US)</option>
</select>
</div>
<div style="display:flex;flex-direction:column;gap:0.2rem;">
<label style="font-size:0.65rem;font-weight:600;color:#64748b;">@L("CertificationType", "Zertifizierung")</label>
<select @bind="_certificationType"
style="font-size:0.78rem;border:1px solid #d1d5db;border-radius:5px;padding:0.3rem 0.45rem;background:#fff;">
<option value="1">@L("AdvancedElectronicSignature", "Advanced Electronic Signature")</option>
<option value="2">@L("ElectronicSignature", "Electronic Signature")</option>
<option value="3">@L("QualifiedSignature", "Qualified Signature")</option>
</select>
</div>
<div style="display:flex;flex-direction:column;gap:0.3rem;justify-content:flex-end;padding-bottom:0.25rem;">
<label style="display:flex;align-items:center;gap:0.45rem;font-size:0.74rem;color:#1f2937;">
<input type="checkbox" checked="@_tfaEnabled" @onchange="OnTfaChanged" />
@L("TFAEnabled", "2FA aktiviert")
</label>
<label style="display:flex;align-items:center;gap:0.45rem;font-size:0.74rem;color:#1f2937;">
<input type="checkbox" checked="@_useAccessCode" @onchange="OnUseAccessCodeChanged" disabled="@_tfaEnabled" />
@L("UseAccessCode", "Zugriffscode verwenden")
</label>
</div>
<div style="display:flex;flex-direction:column;gap:0.2rem;">
<label style="font-size:0.65rem;font-weight:600;color:#64748b;">@L("FinalEmailToCreator", "Finale E-Mail an Ersteller")</label>
<select @bind="_finalEmailToCreator"
style="font-size:0.78rem;border:1px solid #d1d5db;border-radius:5px;padding:0.3rem 0.45rem;background:#fff;">
<option value="0">@L("No", "Nein")</option>
<option value="1">@L("Yes", "Ja")</option>
<option value="2">@L("YesWithAttachment", "Ja, mit Anhang")</option>
</select>
</div>
<div style="display:flex;flex-direction:column;gap:0.2rem;">
<label style="font-size:0.65rem;font-weight:600;color:#64748b;">@L("FinalEmailToReceivers", "Finale E-Mail an Empfänger")</label>
<select @bind="_finalEmailToReceivers"
style="font-size:0.78rem;border:1px solid #d1d5db;border-radius:5px;padding:0.3rem 0.45rem;background:#fff;">
<option value="0">@L("No", "Nein")</option>
<option value="1">@L("Yes", "Ja")</option>
<option value="2">@L("YesWithAttachment", "Ja, mit Anhang")</option>
</select>
</div>
<div style="display:flex;flex-direction:column;gap:0.3rem;justify-content:flex-end;padding-bottom:0.25rem;">
<label style="display:flex;align-items:center;gap:0.45rem;font-size:0.74rem;color:#1f2937;">
<input type="checkbox" checked="@_sendReminderEmails" @onchange="OnSendReminderEmailsChanged" />
@L("SendReminderEmails", "Erinnerungs-E-Mails senden")
</label>
</div>
<div style="display:flex;flex-direction:column;gap:0.2rem;">
<label style="font-size:0.65rem;font-weight:600;color:#64748b;">@L("FirstReminderDays", "Erste Erinnerung (Tage)")</label>
<InputNumber @bind-Value="_firstReminderDays" class="form-control form-control-sm" disabled="@(!_sendReminderEmails)" />
</div>
<div style="display:flex;flex-direction:column;gap:0.2rem;">
<label style="font-size:0.65rem;font-weight:600;color:#64748b;">@L("ReminderIntervalDays", "Erinnerungsintervall (Tage)")</label>
<InputNumber @bind-Value="_reminderIntervalDays" class="form-control form-control-sm" disabled="@(!_sendReminderEmails)" />
</div>
<div style="display:flex;flex-direction:column;gap:0.2rem;">
<label style="font-size:0.65rem;font-weight:600;color:#64748b;">@L("ExpiresWhenDays", "Läuft ab nach (Tage)")</label>
<InputNumber @bind-Value="_expiresWhenDays" class="form-control form-control-sm" />
</div>
<div style="display:flex;flex-direction:column;gap:0.2rem;">
<label style="font-size:0.65rem;font-weight:600;color:#64748b;">@L("ExpiresWarningWhenDays", "Ablaufwarnung (Tage)")</label>
<InputNumber @bind-Value="_expiresWarningWhenDays" class="form-control form-control-sm" />
</div>
</div>
</details>
<div class="sender-receivers-panel">
<div class="sender-receivers-panel__header">
<div class="sender-receivers-panel__title-wrap">
@@ -560,6 +664,20 @@
string _envelopeMessage = string.Empty;
bool _titleTouched = false;
List<EnvelopeTypeDto> _envelopeTypes = [];
int? _selectedEnvelopeTypeId;
string _language = "de-DE";
bool _useAccessCode;
bool _tfaEnabled;
int _certificationType = (int)CertificationType.AdvancedElectronicSignature;
int _finalEmailToCreator = (int)FinalEmailType.No;
int _finalEmailToReceivers = (int)FinalEmailType.No;
bool _sendReminderEmails;
int _firstReminderDays;
int _reminderIntervalDays;
int _expiresWhenDays;
int _expiresWarningWhenDays;
List<ReceiverDraft> _receivers = [];
bool _receiverPopupVisible;
string _receiverDraftName = string.Empty;
@@ -577,6 +695,32 @@
// ── Lifecycle ──
protected override async Task OnInitializedAsync()
{
var hasAccess = await AuthService.CheckSenderAccessAsync();
if (!hasAccess)
{
NavigationManager.NavigateTo("/sender/login");
return;
}
try
{
_envelopeTypes = (await EnvelopeTypeService.GetAsync()).ToList();
if (!IsEditMode && _selectedEnvelopeTypeId is null && _envelopeTypes.Count > 0)
{
_selectedEnvelopeTypeId = _envelopeTypes[0].Id;
ApplyEnvelopeTypeDefaults(_envelopeTypes[0]);
}
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to load envelope types for sender editor.");
_envelopeTypes = [];
}
}
protected override async Task OnParametersSetAsync()
{
if (EnvelopeId.HasValue && EnvelopeId.Value > 0)
@@ -621,6 +765,24 @@
_envelopeTitle = envelope.Title ?? string.Empty;
_envelopeMessage = envelope.Message ?? string.Empty;
_selectedEnvelopeTypeId = envelope.EnvelopeTypeId;
_language = string.IsNullOrWhiteSpace(envelope.Language) ? "de-DE" : envelope.Language;
_useAccessCode = envelope.UseAccessCode;
_tfaEnabled = envelope.TFAEnabled;
if (_tfaEnabled)
_useAccessCode = true;
_certificationType = envelope.CertificationType ?? (int)CertificationType.AdvancedElectronicSignature;
_finalEmailToCreator = envelope.FinalEmailToCreator ?? (int)FinalEmailType.No;
_finalEmailToReceivers = envelope.FinalEmailToReceivers ?? (int)FinalEmailType.No;
_sendReminderEmails = envelope.SendReminderEmails ?? envelope.EnvelopeType?.SendReminderEmails ?? false;
_firstReminderDays = envelope.FirstReminderDays ?? envelope.EnvelopeType?.FirstReminderDays ?? 0;
_reminderIntervalDays = envelope.ReminderIntervalDays ?? envelope.EnvelopeType?.ReminderIntervalDays ?? 0;
_expiresWhenDays = envelope.ExpiresWhenDays ?? envelope.EnvelopeType?.ExpiresDays ?? 0;
_expiresWarningWhenDays = envelope.ExpiresWarningWhenDays ?? envelope.EnvelopeType?.ExpiresWarningDays ?? 0;
NormalizeSettings();
var envelopeReceivers = envelope.EnvelopeReceivers?.ToList() ?? [];
_receivers = envelopeReceivers
@@ -664,11 +826,31 @@
_originalPdfBytes = document?.ByteData;
_pdfLoaded = _originalPdfBytes is { Length: > 0 };
_pagePreviews = [];
_pageCount = _pagePreviews.Count > 0
? _pagePreviews.Count
: EstimatePdfPageCount(_originalPdfBytes)
?? Math.Max(1, _signatureFields.Select(f => f.Page).DefaultIfEmpty(1).Max());
if (_pdfLoaded)
{
try
{
_pagePreviews = (await PdfRenderService.RenderPagesAsync(_originalPdfBytes!))
.OrderBy(p => p.PageNumber)
.ToList();
}
catch (Exception renderEx)
{
Logger.LogWarning(renderEx, "Failed to render PDF page previews for envelope {EnvelopeId}. Falling back to native preview.", envelopeId);
_pagePreviews = [];
}
_pageCount = _pagePreviews.Count > 0
? _pagePreviews.Count
: 1;
}
else
{
_pagePreviews = [];
_pageCount = Math.Max(1, _signatureFields.Select(f => f.Page).DefaultIfEmpty(1).Max());
}
_fileName = $"Envelope-{envelopeId}.pdf";
_pendingReceiverForPlacement = null;
_loadedEnvelopeId = envelopeId;
@@ -755,6 +937,90 @@
void CancelPlacement() => _pendingReceiverForPlacement = null;
Task OnEnvelopeTypeChanged(ChangeEventArgs e)
{
var raw = e.Value?.ToString();
if (!int.TryParse(raw, out var id) || id <= 0)
{
_selectedEnvelopeTypeId = null;
return Task.CompletedTask;
}
_selectedEnvelopeTypeId = id;
var selectedType = _envelopeTypes.FirstOrDefault(x => x.Id == id);
if (selectedType is not null)
ApplyEnvelopeTypeDefaults(selectedType);
return Task.CompletedTask;
}
void ApplyEnvelopeTypeDefaults(EnvelopeTypeDto envelopeType)
{
if (!string.IsNullOrWhiteSpace(envelopeType.Language))
_language = envelopeType.Language;
if (envelopeType.UseAccessCode.HasValue)
_useAccessCode = envelopeType.UseAccessCode.Value;
if (_tfaEnabled)
_useAccessCode = true;
if (envelopeType.CertificationType.HasValue)
_certificationType = envelopeType.CertificationType.Value;
if (envelopeType.FinalEmailToCreator.HasValue)
_finalEmailToCreator = envelopeType.FinalEmailToCreator.Value;
if (envelopeType.FinalEmailToReceivers.HasValue)
_finalEmailToReceivers = envelopeType.FinalEmailToReceivers.Value;
if (envelopeType.SendReminderEmails.HasValue)
_sendReminderEmails = envelopeType.SendReminderEmails.Value;
if (envelopeType.FirstReminderDays.HasValue)
_firstReminderDays = envelopeType.FirstReminderDays.Value;
if (envelopeType.ReminderIntervalDays.HasValue)
_reminderIntervalDays = envelopeType.ReminderIntervalDays.Value;
if (envelopeType.ExpiresDays.HasValue)
_expiresWhenDays = envelopeType.ExpiresDays.Value;
if (envelopeType.ExpiresWarningDays.HasValue)
_expiresWarningWhenDays = envelopeType.ExpiresWarningDays.Value;
}
void OnTfaChanged(ChangeEventArgs e)
{
_tfaEnabled = ParseCheckboxValue(e);
if (_tfaEnabled)
_useAccessCode = true;
}
void OnUseAccessCodeChanged(ChangeEventArgs e)
{
if (_tfaEnabled)
{
_useAccessCode = true;
return;
}
_useAccessCode = ParseCheckboxValue(e);
}
void OnSendReminderEmailsChanged(ChangeEventArgs e)
{
_sendReminderEmails = ParseCheckboxValue(e);
}
static bool ParseCheckboxValue(ChangeEventArgs e)
{
if (e.Value is bool b)
return b;
return bool.TryParse(e.Value?.ToString(), out var parsed) && parsed;
}
// ── PDF area click → place field ──
async Task OnPdfAreaClickAsync(MouseEventArgs e)
{
@@ -872,6 +1138,13 @@
async Task SaveDraftAsync()
{
if (!ValidateSettings(out var settingsError))
{
_saveErrorMessage = settingsError;
_savePopupVisible = true;
return;
}
_isSaving = true;
_saveErrorMessage = null;
await InvokeAsync(StateHasChanged);
@@ -912,6 +1185,13 @@
// ── Send envelope ──
async Task SendAsync()
{
if (!ValidateSettings(out var settingsError))
{
_saveErrorMessage = settingsError;
_savePopupVisible = true;
return;
}
// ── Validation ──
_titleTouched = true;
if (!_pdfLoaded || _originalPdfBytes is null)
@@ -999,6 +1279,8 @@
CreateEnvelopeCommand BuildCreateEnvelopeCommand(bool send)
{
NormalizeSettings();
var envelopeId = _editingEnvelopeId
?? EnvelopeId
?? _loadedEnvelopeId
@@ -1031,15 +1313,68 @@
return new CreateEnvelopeCommand
{
EnvelopeId = envelopeId,
EnvelopeTypeId = _selectedEnvelopeTypeId,
Title = _envelopeTitle,
Message = _envelopeMessage,
TFAEnabled = false,
Language = _language,
UseAccessCode = _useAccessCode,
TFAEnabled = _tfaEnabled,
CertificationType = _certificationType,
FinalEmailToCreator = _finalEmailToCreator,
FinalEmailToReceivers = _finalEmailToReceivers,
SendReminderEmails = _sendReminderEmails,
FirstReminderDays = _firstReminderDays,
ReminderIntervalDays = _reminderIntervalDays,
ExpiresWhenDays = _expiresWhenDays,
ExpiresWarningWhenDays = _expiresWarningWhenDays,
Send = send,
Document = string.IsNullOrWhiteSpace(docBase64) ? null : new CreateEnvelopeDocumentDto(docBase64),
Receivers = receiverCommands
};
}
void NormalizeSettings()
{
_language = string.IsNullOrWhiteSpace(_language) ? "de-DE" : _language.Trim();
if (_tfaEnabled)
_useAccessCode = true;
_firstReminderDays = Math.Max(0, _firstReminderDays);
_reminderIntervalDays = Math.Max(0, _reminderIntervalDays);
_expiresWhenDays = Math.Max(0, _expiresWhenDays);
_expiresWarningWhenDays = Math.Max(0, _expiresWarningWhenDays);
if (_expiresWhenDays > 0 && _expiresWarningWhenDays > _expiresWhenDays)
_expiresWarningWhenDays = _expiresWhenDays;
}
bool ValidateSettings(out string? message)
{
NormalizeSettings();
if (_sendReminderEmails && _reminderIntervalDays <= 0)
{
message = L("SenderEditor_Validation_ReminderInterval", "Bitte geben Sie ein Erinnerungsintervall groesser als 0 Tage ein.");
return false;
}
if (_sendReminderEmails && _firstReminderDays < 0)
{
message = L("SenderEditor_Validation_FirstReminder", "Bitte geben Sie gueltige Tage fuer die erste Erinnerung ein.");
return false;
}
if (_expiresWhenDays > 0 && _expiresWarningWhenDays > _expiresWhenDays)
{
message = L("SenderEditor_Validation_ExpiryWarning", "Die Ablaufwarnung darf nicht nach dem Ablaufdatum liegen.");
return false;
}
message = null;
return true;
}
int? GetEnvelopeIdFromCurrentUri()
{
try

View File

@@ -41,6 +41,7 @@ builder.Services.AddScoped<SignatureService>();
builder.Services.AddScoped<SignatureCacheService>();
builder.Services.AddSingleton<AppVersionService>();
builder.Services.AddScoped<EnvelopeService>();
builder.Services.AddScoped<EnvelopeTypeService>();
builder.Services.AddScoped<LocalizationService>();
builder.Services.AddScoped<ReceiverService>();
builder.Services.AddScoped<PdfRenderService>();

View File

@@ -26,4 +26,24 @@ public class AnnotationService(IHttpClientFactory httpClientFactory)
var result = await response.Content.ReadFromJsonAsync<List<AnnotationDto>>(_jsonOptions, cancel);
return result ?? [];
}
public async Task RejectAsync(string envelopeKey, string? reason, CancellationToken cancel = default)
{
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
var response = await http.PostAsJsonAsync(
$"/api/Annotation/reject/{Uri.EscapeDataString(envelopeKey)}",
reason,
_jsonOptions,
cancel);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(cancel);
throw new HttpRequestException(
$"Failed to reject envelope {envelopeKey}: {(int)response.StatusCode} {response.StatusCode} - {body}",
null,
response.StatusCode);
}
}
}

View File

@@ -10,6 +10,15 @@ public class DocumentService(IHttpClientFactory httpClientFactory)
{
private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
private static HttpRequestException CreateHttpException(string messagePrefix, HttpStatusCode statusCode, string? reasonPhrase)
{
var statusCodeInt = (int)statusCode;
return new HttpRequestException(
$"{messagePrefix}. Status: {statusCodeInt} ({reasonPhrase ?? "Unknown error"})",
null,
statusCode);
}
/// <summary>
/// Fetches the PDF bytes for the given envelope key from the API.
/// Throws HttpRequestException on failure with appropriate status code.
@@ -21,14 +30,7 @@ public class DocumentService(IHttpClientFactory httpClientFactory)
var response = await http.GetAsync($"/api/Document/{Uri.EscapeDataString(envelopeKey)}", cancel);
if (!response.IsSuccessStatusCode)
{
var statusCode = (int)response.StatusCode;
var reasonPhrase = response.ReasonPhrase ?? "Unknown error";
throw new HttpRequestException(
$"Failed to load document. Status: {statusCode} ({reasonPhrase})",
null,
response.StatusCode);
}
throw CreateHttpException("Failed to load document", response.StatusCode, response.ReasonPhrase);
var bytes = await response.Content.ReadAsByteArrayAsync(cancel);
return bytes;
@@ -36,23 +38,67 @@ public class DocumentService(IHttpClientFactory httpClientFactory)
/// <summary>
/// Fetches sender document details (including signature elements) by envelope id.
/// Uses GET /api/Document?EnvelopeId={envelopeId}.
/// Uses GET /api/Document/details?EnvelopeId={envelopeId}.
/// </summary>
public async Task<DocumentDto?> GetSenderDocumentDetailsByEnvelopeIdAsync(int envelopeId, CancellationToken cancel = default)
{
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
var response = await http.GetAsync($"/api/Document?EnvelopeId={envelopeId}", cancel);
var detailsResponse = await http.GetAsync($"/api/Document/details?EnvelopeId={envelopeId}", cancel);
if (!response.IsSuccessStatusCode)
if (detailsResponse.StatusCode == HttpStatusCode.NotFound)
{
var statusCode = (int)response.StatusCode;
var reasonPhrase = response.ReasonPhrase ?? "Unknown error";
throw new HttpRequestException(
$"Failed to load sender document details. Status: {statusCode} ({reasonPhrase})",
null,
response.StatusCode);
var fallbackBytes = await TryGetSenderDocumentBytesByEnvelopeIdAsync(http, envelopeId, cancel);
if (fallbackBytes is null)
return null;
return new DocumentDto(fallbackBytes, new DocumentDetailsDto
{
EnvelopeId = envelopeId,
AddedWhen = DateTime.UtcNow,
Elements = []
});
}
return await response.Content.ReadFromJsonAsync<DocumentDto>(_jsonOptions, cancel);
if (!detailsResponse.IsSuccessStatusCode)
throw CreateHttpException("Failed to load sender document details", detailsResponse.StatusCode, detailsResponse.ReasonPhrase);
var details = await detailsResponse.Content.ReadFromJsonAsync<DocumentDetailsDto>(_jsonOptions, cancel)
?? throw new HttpRequestException("Failed to deserialize sender document details response.");
var byteData = await TryGetSenderDocumentBytesByEnvelopeIdAsync(http, envelopeId, cancel);
if (byteData is null)
byteData = [];
return new DocumentDto(byteData, details);
}
/// <summary>
/// Fetches sender document bytes by envelope id.
/// Uses GET /api/Document?EnvelopeId={envelopeId}.
/// </summary>
public async Task<byte[]?> GetSenderDocumentBytesByEnvelopeIdAsync(int envelopeId, CancellationToken cancel = default)
{
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
var response = await http.GetAsync($"/api/Document?EnvelopeId={envelopeId}", cancel);
if (response.StatusCode == HttpStatusCode.NotFound)
return null;
if (!response.IsSuccessStatusCode)
throw CreateHttpException("Failed to load sender document bytes", response.StatusCode, response.ReasonPhrase);
return await response.Content.ReadAsByteArrayAsync(cancel);
}
private static async Task<byte[]?> TryGetSenderDocumentBytesByEnvelopeIdAsync(HttpClient http, int envelopeId, CancellationToken cancel)
{
var bytesResponse = await http.GetAsync($"/api/Document?EnvelopeId={envelopeId}", cancel);
if (bytesResponse.StatusCode == HttpStatusCode.NotFound)
return null;
if (!bytesResponse.IsSuccessStatusCode)
throw CreateHttpException("Failed to load sender document bytes", bytesResponse.StatusCode, bytesResponse.ReasonPhrase);
return await bytesResponse.Content.ReadAsByteArrayAsync(cancel);
}
}

View File

@@ -101,26 +101,7 @@ public class EnvelopeService(IHttpClientFactory clientFactory)
CancellationToken cancel = default)
{
var httpClient = clientFactory.CreateClient("EnvelopeGenerator.Server");
var payload = new
{
EnvelopeId = request.EnvelopeId,
Title = request.Title,
Message = request.Message,
TFAEnabled = request.TFAEnabled,
Send = request.Send,
Document = request.Document is null
? null
: new { DataAsBase64 = request.Document.DataAsBase64 },
Receivers = request.Receivers.Select(r => new
{
EmailAddress = r.EmailAddress,
Name = r.Name,
PhoneNumber = r.PhoneNumber,
Fields = r.Fields.Select(f => new { X = f.X, Y = f.Y, Page = f.Page }).ToList()
}).ToList()
};
var response = await httpClient.PostAsJsonAsync("/api/Envelope", payload, _jsonOptions, cancel);
var response = await httpClient.PostAsJsonAsync("/api/Envelope", request, _jsonOptions, cancel);
if (response.IsSuccessStatusCode)
return await response.Content.ReadFromJsonAsync<EnvelopeDto>(_jsonOptions, cancel);

View File

@@ -0,0 +1,22 @@
using EnvelopeGenerator.Application.Common.Dto;
using System.Net.Http.Json;
using System.Text.Json;
namespace EnvelopeGenerator.Server.Client.Services;
public class EnvelopeTypeService(IHttpClientFactory clientFactory)
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public async Task<IReadOnlyList<EnvelopeTypeDto>> GetAsync(CancellationToken cancel = default)
{
var http = clientFactory.CreateClient("EnvelopeGenerator.Server");
var response = await http.GetAsync("/api/EnvelopeType", cancel);
if (!response.IsSuccessStatusCode)
return [];
var data = await response.Content.ReadFromJsonAsync<List<EnvelopeTypeDto>>(JsonOptions, cancel);
return data ?? [];
}
}

View File

@@ -22,6 +22,7 @@
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverAuthorizationService ReceiverAuthorizationService
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverPageDataService PageDataService
@inject AnnotationService AnnotationService
@inject AppVersionService AppVersion
@inject IMemoryCache MemoryCache
@inject ILogger<ReceiverPage> Logger
@@ -132,32 +133,6 @@
<span class="pdf-toolbar__btn-text">Unterschreiben</span>
</button>
}
@if (!(_envelopeReceiver?.Envelope?.ReadOnly ?? false))
{
<button class="pdf-toolbar__btn"
@onclick="OpenSharePopup"
title="Dokument teilen"
style="flex-shrink: 0;">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
<path d="M13.5 1a1.5 1.5 0 1 0 1.415 2H15a1.5 1.5 0 0 0-1.5-2zm-11 5a1.5 1.5 0 1 0 1.415 2H4a1.5 1.5 0 0 0-1.5-2zm11 5a1.5 1.5 0 1 0 1.415 2H15a1.5 1.5 0 0 0-1.5-2z"/>
<path d="M2.854 7.646a.5.5 0 0 1 .0-.707l9-5a.5.5 0 1 1 .486.874l-9 5a.5.5 0 0 1-.486-.167zm0 .708a.5.5 0 0 1 .486-.167l9 5a.5.5 0 0 1-.486.874l-9-5a.5.5 0 0 1 0-.707z"/>
</svg>
<span class="pdf-toolbar__btn-text">Teilen</span>
</button>
}
<button class="pdf-toolbar__btn"
@onclick="LogoutAsync"
disabled="@_isLoggingOut"
title="Abmelden"
style="flex-shrink: 0;">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M10 15a1 1 0 0 0 1-1v-2a.5.5 0 0 1 1 0v2a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v2a.5.5 0 0 1-1 0V2a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h7z" />
<path fill-rule="evenodd" d="M15.854 8.354a.5.5 0 0 0 0-.708l-3-3a.5.5 0 1 0-.708.708L14.293 7.5H6.5a.5.5 0 0 0 0 1h7.793l-2.147 2.146a.5.5 0 0 0 .708.708l3-3z" />
</svg>
<span class="pdf-toolbar__btn-text">@(_isLoggingOut ? "Abmelden..." : "Abmelden")</span>
</button>
</div>
</div>
@@ -375,57 +350,6 @@
</FooterContentTemplate>
</DxPopup>
<DxPopup @bind-Visible="_sharePopupVisible"
HeaderText="Dokument teilen"
Width="520px"
MaxWidth="95vw"
ShowFooter="true"
CloseOnOutsideClick="true">
<BodyContentTemplate>
<div class="mb-3">
<label class="form-label" for="rp-share-mail" style="font-size: 0.9rem; font-weight: 500;">E-Mail</label>
<input id="rp-share-mail"
class="form-control"
placeholder="user@mail.com"
value="@_shareReceiverMail"
@oninput="args => _shareReceiverMail = args.Value?.ToString() ?? string.Empty" />
</div>
<div class="mb-1">
<label class="form-label" for="rp-share-date" style="font-size: 0.9rem; font-weight: 500;">Gultig bis</label>
<input id="rp-share-date"
class="form-control"
type="date"
min="@DateTime.Today.AddDays(1).ToString("yyyy-MM-dd")"
max="@DateTime.Today.AddDays(90).ToString("yyyy-MM-dd")"
value="@_shareDateValid.ToString("yyyy-MM-dd")"
@oninput="OnShareDateChanged" />
</div>
@if (!string.IsNullOrWhiteSpace(_shareMessage))
{
<div class="mt-3 alert @(_shareMessageIsError ? "alert-danger" : "alert-success") mb-0" role="alert">
@_shareMessage
</div>
}
</BodyContentTemplate>
<FooterContentTemplate>
<div class="d-flex justify-content-end gap-2 w-100">
<button class="btn btn-outline-secondary"
@onclick="CloseSharePopup"
disabled="@_isSharing">
Schliessen
</button>
<button class="btn btn-primary"
@onclick="ShareEnvelopeAsync"
disabled="@_isSharing"
style="background: linear-gradient(135deg, #4F46E5 0%, #4338CA 100%); border: none;">
@(_isSharing ? "Senden..." : "Senden")
</button>
</div>
</FooterContentTemplate>
</DxPopup>
@code {
// ----- Constants -----
const string SignatureTabDraw = "draw";
@@ -480,6 +404,12 @@
string? _shareMessage;
bool _shareMessageIsError = false;
// ----- Reject popup state -----
bool _rejectPopupVisible = false;
bool _isRejectSubmitting = false;
string _rejectReason = string.Empty;
string? _rejectValidationMessage;
async Task LogoutAsync()
{
if (_isLoggingOut || string.IsNullOrWhiteSpace(EnvelopeKey))
@@ -552,23 +482,12 @@
try
{
var shared = await AuthService.ShareEnvelopeReadOnlyAsync(EnvelopeKey!, email, _shareDateValid.Date);
if (shared)
{
_shareMessage = "Dokument wurde erfolgreich als Nur-Lesen freigegeben.";
_shareMessageIsError = false;
_shareReceiverMail = string.Empty;
_shareDateValid = DateTime.Today.AddDays(7);
}
else
{
_shareMessage = "Freigabe fehlgeschlagen. Bitte versuchen Sie es erneut.";
_shareMessageIsError = true;
}
_shareMessage = "Freigabe ist in dieser Ansicht derzeit nicht verfugbar.";
_shareMessageIsError = false;
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to share envelope in read-only mode for {EnvelopeKey}", EnvelopeKey);
Logger.LogError(ex, "Failed to share envelope {EnvelopeKey}", EnvelopeKey);
_shareMessage = "Freigabe fehlgeschlagen. Bitte versuchen Sie es erneut.";
_shareMessageIsError = true;
}
@@ -627,6 +546,11 @@
_envelopeReceiver = await PageDataService.GetEnvelopeReceiverAsync(EnvelopeKey);
if (_envelopeReceiver is null)
Logger.LogWarning("Envelope receiver data is null for {EnvelopeKey}", EnvelopeKey);
else if (IsRejected(_envelopeReceiver))
{
Navigation.NavigateTo($"/envelope/{Uri.EscapeDataString(EnvelopeKey)}/rejected", forceLoad: true);
return;
}
// Build initial report (no signature image yet)
_report = BuildReport(_pdfBytes, _signatures, capturedSignature: null);
@@ -672,6 +596,16 @@
}
// ----- Report builder -----
static bool IsRejected(EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto envelopeReceiver)
{
var status = envelopeReceiver.Envelope?.Status;
if (status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected)
return true;
return envelopeReceiver.Envelope?.Histories?.Any(h =>
h.Status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected) == true;
}
/// <summary>
/// Builds an XtraReport wrapping the PDF bytes.
/// If a signature is captured and there are signature fields, the signature image is
@@ -905,6 +839,56 @@
forceLoad: true);
}
void OpenRejectPopup()
{
_rejectValidationMessage = null;
_rejectPopupVisible = true;
}
void CloseRejectPopup()
{
if (_isRejectSubmitting)
return;
_rejectPopupVisible = false;
}
async Task SubmitRejectAsync()
{
if (_isRejectSubmitting)
return;
_isRejectSubmitting = true;
_rejectValidationMessage = null;
await InvokeAsync(StateHasChanged);
try
{
var reason = string.IsNullOrWhiteSpace(_rejectReason)
? null
: _rejectReason.Trim();
if (string.IsNullOrWhiteSpace(EnvelopeKey))
{
_rejectValidationMessage = "Envelope-Schluessel fehlt.";
_isRejectSubmitting = false;
await InvokeAsync(StateHasChanged);
return;
}
await AnnotationService.RejectAsync(EnvelopeKey, reason);
Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true);
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to reject envelope {EnvelopeKey}", EnvelopeKey);
_rejectValidationMessage = "Die Ablehnung konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.";
_isRejectSubmitting = false;
await InvokeAsync(StateHasChanged);
}
}
async Task<string?> GetActiveSignatureDataUrlAsync()
{
if (_activeSignatureTab == SignatureTabDraw)

View File

@@ -0,0 +1,105 @@
@page "/envelope/{EnvelopeKey}/rejected"
@rendermode InteractiveServer
@using EnvelopeGenerator.Application.Resources
@using EnvelopeGenerator.Domain.Constants
@using EnvelopeGenerator.Domain.Interfaces
@using Microsoft.Extensions.Localization
@inject NavigationManager Navigation
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverAuthorizationService ReceiverAuthorizationService
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverPageDataService PageDataService
@inject IStringLocalizer<Resource> UiLocalizer
<div class="envelope-viewer-layout" style="justify-content: center; align-items: center; padding: 1.5rem;">
<div style="width: min(920px, 100%); background: rgba(255,255,255,0.96); border-radius: 20px; box-shadow: 0 18px 48px rgba(15,23,42,0.22); overflow: hidden;">
<div style="padding: 1.2rem 1.4rem; border-bottom: 1px solid rgba(15,23,42,0.08); background: linear-gradient(135deg, rgba(239,68,68,0.12) 0%, rgba(220,38,38,0.08) 100%);">
<div style="font-size: 1rem; font-weight: 700; color: #991b1b;">@L("DocRejected", "Dokument abgelehnt")</div>
</div>
<div style="padding: 2rem 1.5rem;">
<div style="display: flex; justify-content: center; margin-bottom: 1rem;">
<div style="width: 90px; height: 90px; border-radius: 999px; background: #fee2e2; display: inline-flex; align-items: center; justify-content: center; color: #b91c1c;">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z" />
</svg>
</div>
</div>
<h2 style="text-align: center; font-size: 1.4rem; color: #7f1d1d; margin-bottom: 0.75rem;">
@(_isReadAndConfirm
? L("RejectionInfo1Confirmation", "Dieses Dokument wurde als nicht bestaetigt markiert.")
: L("RejectionInfo1", "Dieses Dokument wurde abgelehnt."))
</h2>
<p style="text-align: center; margin: 0 auto; max-width: 760px; color: #374151; line-height: 1.6;">
@((MarkupString)_messageLine2)
</p>
<div style="margin-top: 1.4rem; text-align: center;">
<a href="/" class="btn btn-outline-secondary" style="font-weight: 600;">Portal Startseite</a>
</div>
</div>
</div>
</div>
@code {
[Parameter] public string? EnvelopeKey { get; set; }
bool _isReadAndConfirm;
string _messageLine2 = string.Empty;
protected override async Task OnInitializedAsync()
{
if (string.IsNullOrWhiteSpace(EnvelopeKey))
{
Navigation.NavigateTo("/");
return;
}
var user = await ReceiverAuthorizationService.AuthorizeAsync(EnvelopeKey);
if (user is null)
{
Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}");
return;
}
var envelopeReceiver = await PageDataService.GetEnvelopeReceiverAsync(EnvelopeKey);
if (envelopeReceiver is null)
{
Navigation.NavigateTo($"/envelope/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true);
return;
}
if (!IsRejected(envelopeReceiver))
{
Navigation.NavigateTo($"/envelope/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true);
return;
}
_isReadAndConfirm = envelopeReceiver.Envelope?.IsReadAndConfirm() ?? false;
var senderName = envelopeReceiver.Envelope?.User is null
? string.Empty
: $"{envelopeReceiver.Envelope.User.Prename} {envelopeReceiver.Envelope.User.Name}".Trim();
var senderEmail = envelopeReceiver.Envelope?.User?.Email ?? string.Empty;
var envelopeTitle = envelopeReceiver.Envelope?.Title ?? string.Empty;
var template = L("RejectionInfo2", "Das Dokument \"{2}\" von {0} ({1}) wurde abgelehnt.");
_messageLine2 = string.Format(template, senderName, senderEmail, envelopeTitle);
}
static bool IsRejected(EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto envelopeReceiver)
{
var status = envelopeReceiver.Envelope?.Status;
if (status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected)
return true;
return envelopeReceiver.Envelope?.Histories?.Any(h =>
h.Status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected) == true;
}
string L(string key, string fallback)
{
var value = UiLocalizer[key];
return value.ResourceNotFound ? fallback : value.Value;
}
}

View File

@@ -100,11 +100,12 @@ public class AnnotationController : ControllerBase
/// <summary>
/// Rejects the document for the current receiver.
/// </summary>
/// <param name="envelopeKey">Envelope key route parameter.</param>
/// <param name="reason">Optional rejection reason.</param>
[Authorize(Policy = AuthPolicy.Receiver)]
[HttpPost("reject")]
[HttpPost("reject/{envelopeKey}")]
[Obsolete("Use MediatR")]
public async Task<IActionResult> Reject([FromBody] string? reason = null)
public async Task<IActionResult> Reject([FromRoute] string envelopeKey, [FromBody] string? reason = null)
{
var signature = User.ReceiverSignature();
var uuid = User.EnvelopeUuid();

View File

@@ -31,17 +31,30 @@ public class DocumentController(IMediator mediator, IAuthorizationService authSe
/// <param name="cancel">Cancellation token.</param>
[HttpGet]
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
public async Task<IActionResult> GetDocument(CancellationToken cancel, [FromQuery] ReadDocumentQuery? query = null)
public async Task<IActionResult> GetDocument(CancellationToken cancel, [FromQuery] ReadDocumentQuery query)
{
if (query is null)
return BadRequest("Missing document query.");
var senderDoc = await mediator.Send(query, cancel);
return senderDoc.ByteData is byte[] senderDocByte
return senderDoc?.ByteData is byte[] senderDocByte
? File(senderDocByte, "application/octet-stream")
: NotFound("Document is empty.");
}
/// <summary>
/// Returns sender document metadata and element details (without binary payload) for editor loading.
/// </summary>
/// <param name="query">Document query with EnvelopeId.</param>
/// <param name="cancel">Cancellation token.</param>
[HttpGet("details")]
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
public async Task<IActionResult> GetDocumentDetails(CancellationToken cancel, [FromQuery] ReadDocumentDetailsQuery query)
{
var senderDoc = await mediator.Send(query with { UserId = User.GetId() }, cancel);
if (senderDoc is null)
return NotFound("Document is empty.");
return Ok(senderDoc);
}
/// <summary>
/// Gets the document for the specified envelope key.
/// </summary>

View File

@@ -11,6 +11,7 @@ namespace EnvelopeGenerator.Server.Controllers;
[Route("api/[controller]")]
public class PdfRenderController(IPdfPageImageRenderer renderer) : ControllerBase
{
// TODO(perf): Replace JSON/base64 transport for PdfBytes with application/octet-stream (or multipart) and stream-based request parsing to reduce payload size and allocation pressure for large PDFs.
[HttpPost("pages")]
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
public async Task<IActionResult> RenderPages([FromBody] PdfRenderRequestDto? request, CancellationToken cancel)

View File

@@ -67,7 +67,7 @@ public static class ReceiverClaimExtensions
/// Gets the authenticated receiver email address from the claims.
/// </summary>
public static string ReceiverMail(this ClaimsPrincipal user)
=> user.GetRequiredClaimValue(JwtRegisteredClaimNames.Email);
=> user.GetRequiredClaimValue(JwtRegisteredClaimNames.Email, ClaimTypes.Email);
/// <summary>
/// Gets the authenticated envelope identifier from the claims.
@@ -120,4 +120,4 @@ public static class ReceiverClaimExtensions
{
return Role.Receiver.Authorized.Any(user.IsInRole);
}
}
}

View File

@@ -345,6 +345,7 @@ try
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.LocalizationService>();
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.ReceiverService>();
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.PdfRenderService>();
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.EnvelopeTypeService>();
// Business Services (Server specific)
builder.Services.AddScoped<DocumentService>();
@@ -353,7 +354,7 @@ try
builder.Services.AddScoped<EnvelopeReceiverService>();
builder.Services.AddScoped<SignatureService>();
builder.Services.AddScoped<SignatureCacheService>();
builder.Services.AddScoped<IPdfPageImageRenderer, PdfPageImageRenderer>();
builder.Services.AddDocInfrastructure();
builder.Services.AddScoped<EnvelopeGenerator.Application.Common.Interfaces.Services.IEnvelopeMailService, EnvelopeGenerator.Application.Services.EnvelopeMailService>();
builder.Services.AddSingleton<AppVersionService>();

View File

@@ -57,6 +57,14 @@ public class EnvelopeReceiverPageDataService(
return await mediator.Send(new ReadDocumentQuery(EnvelopeId: envelopeId, UserId: user.SenderId()), cancellationToken);
}
/// <summary>
/// Loads document metadata/elements for the authenticated sender without binary payload.
/// </summary>
public async Task<DocumentDetailsDto?> GetDocumentMetadataOfSenderAsync(ClaimsPrincipal user, int envelopeId, CancellationToken cancellationToken = default)
{
return await mediator.Send(new ReadDocumentDetailsQuery(EnvelopeId: envelopeId, UserId: user.SenderId()), cancellationToken);
}
/// <summary>
/// Loads the current receiver's signature placeholders.
/// </summary>

View File

@@ -954,6 +954,29 @@ body.resizing {
cursor: not-allowed;
}
.pdf-toolbar__btn--reject {
display: flex;
align-items: center;
gap: 0.375rem;
min-width: auto;
padding: 0.5rem 0.75rem;
background: linear-gradient(135deg, rgba(239, 68, 68, 0.08) 0%, rgba(220, 38, 38, 0.08) 100%);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #b91c1c;
}
.pdf-toolbar__btn--reject:hover:not(:disabled) {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
border-color: transparent;
color: #ffffff;
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
}
.pdf-toolbar__btn--reject:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.pdf-toolbar__btn-text {
font-size: 0.813rem;
font-weight: 600;
@@ -1591,6 +1614,18 @@ body.resizing {
color: #a5b4fc;
}
[data-sf-theme="blazing-dark"] .pdf-toolbar__btn--reject {
background: rgba(239, 68, 68, 0.15);
border-color: rgba(248, 113, 113, 0.4);
color: #fca5a5;
}
[data-sf-theme="blazing-dark"] .pdf-toolbar__btn--reject:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.35);
border-color: rgba(248, 113, 113, 0.6);
color: #ffffff;
}
/* Error container */
[data-sf-theme="blazing-dark"] .error-container .alert-danger {
background: rgba(239, 68, 68, 0.12);