Compare commits
109 Commits
bugfix/sav
...
refactor/w
| Author | SHA1 | Date | |
|---|---|---|---|
| 0cf603fc79 | |||
| e3d2225a44 | |||
| 595bbf3f25 | |||
| ee7d01ed7f | |||
| 85104511fd | |||
| 1b87528cca | |||
| 2913514da8 | |||
| ce7e70162b | |||
| 5e00b6e9ec | |||
| f0b4c8d00c | |||
| abf616c115 | |||
| 42585e1f2c | |||
| ff10fc6b0a | |||
| 7222f7e9a7 | |||
| d913b949e0 | |||
| a35e05d016 | |||
| 8aabc3e233 | |||
| 8b7f99eb76 | |||
| 91e90b322b | |||
| 74e55e9206 | |||
| fc5ad7ea3c | |||
| ff5582c9b5 | |||
| 6523cca945 | |||
| 28d0b6f81c | |||
| 5df38754cb | |||
| c2b7579717 | |||
| ae1ecbc15f | |||
| 4dcfe67038 | |||
| c812d4dc1f | |||
| 8d27718062 | |||
| c746612d80 | |||
| 06551d502b | |||
| 0f28b80e16 | |||
| f7af3776c2 | |||
| 8ba9e8ec20 | |||
| 226d852c6f | |||
| fb155fe706 | |||
| 658cb28e1c | |||
| f38098aed8 | |||
| 00c3fd18a9 | |||
| 8929c517fe | |||
| 07ce6ccfe1 | |||
| 26beae0d14 | |||
| e423c6d751 | |||
| 56d1dc050f | |||
| 016dec74f9 | |||
| b9cd629766 | |||
| e729adb441 | |||
| 67d0b07926 | |||
| 780277d656 | |||
| 635d5d8278 | |||
| f1c8e223dd | |||
| 31bf64a6f2 | |||
| 93142a56f7 | |||
| 64baef907c | |||
| 232b341894 | |||
| 57a08e8a89 | |||
| 198eabcbae | |||
| 04cea1ca4b | |||
| 29108594d9 | |||
| 51c0f51738 | |||
| 816bbce9c6 | |||
| 5194d638ac | |||
| 6b3622b366 | |||
| 4f8fa9afd8 | |||
| 7a36316b7e | |||
| 68d9dbdcdf | |||
| 7f3d1afcb9 | |||
| 955ac57a1a | |||
| b8a96330c0 | |||
| 8f2744afc7 | |||
| c8ddfac8d0 | |||
| 1942d78155 | |||
| f361898ba4 | |||
| 7766e2ea25 | |||
| 6dfcc1b2be | |||
| 63c60a91b2 | |||
| a5d2c477f6 | |||
| 3a71d57619 | |||
| 73b6c2712c | |||
| 200972c370 | |||
| 9f7519941a | |||
| 66e58b4a1a | |||
| de9d8c6f37 | |||
| 47f48ba12a | |||
| 60241c9c6d | |||
| 2a939b4cad | |||
| e89d471c6f | |||
| ece676fbcf | |||
| fac4ec28a4 | |||
| a3b11ed63f | |||
| 5627483589 | |||
| bf23e88cf8 | |||
| 9435561e78 | |||
| db1e91a3f6 | |||
| 0c664c0995 | |||
| 5db2f6f1e3 | |||
| a535e20b0e | |||
| bb35a68b1f | |||
| 715add7f51 | |||
| 621e22701a | |||
| 5ef32a2b0c | |||
| 54d16eb3b8 | |||
| ae3264253d | |||
| 4cbfe52d5b | |||
| 27c6b017cd | |||
| 8378c33465 | |||
| c7c4e1194d | |||
| 3b367eff7f |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -370,3 +370,4 @@ FodyWeavers.xsd
|
||||
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/publish-output
|
||||
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/tekh_softHSM_test.md
|
||||
/ignore/EnvelopeGenerator.Form
|
||||
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/appsettings.Secrets.json
|
||||
|
||||
87
AGENTS.md
87
AGENTS.md
@@ -48,6 +48,16 @@ This section explains every markdown file in the repo: what it contains, why it
|
||||
- Implementing sender-side PDF.js overlay (signature field placement) — the receiver side is the reference implementation
|
||||
**Maintenance:** Update when `pdf-viewer.js` API changes, new JS interop functions are added, or the signing flow is modified.
|
||||
|
||||
#### `DATABASE_STRUCTURE_CONTEXT.md`
|
||||
**What:** Deep DB documentation for the envelope domain. Includes table/column snapshot, entity-to-table mapping, EF relationship/constraint map, stored procedure behavior + call sites, and trigger behavior + wiring path.
|
||||
**Why it exists:** DB is centralized and can evolve independently; this file preserves migration-critical data semantics when historical DB context is hard to recover.
|
||||
**When you need it:**
|
||||
- Planning or reviewing DB/schema changes
|
||||
- Debugging status transitions/history side effects
|
||||
- Tracing where `PRSIG_*` procedures or DB triggers affect app behavior
|
||||
- Reconciling Domain entities with physical SQL schema
|
||||
**Maintenance:** Refresh on each DB migration, trigger/procedure change, or significant Domain/EGDbContext model change.
|
||||
|
||||
---
|
||||
|
||||
### Deployment Docs
|
||||
@@ -63,6 +73,36 @@ This section explains every markdown file in the repo: what it contains, why it
|
||||
|
||||
---
|
||||
|
||||
## Markdown Audit Snapshot (2026-10-05)
|
||||
|
||||
The following table summarizes repository markdown files by practical status: completed, partially complete, or removable candidates.
|
||||
|
||||
| File | Type | Status | Missing / Gap | Removal Recommendation |
|
||||
|---|---|---|---|---|
|
||||
| `AGENTS.md` | Agent onboarding | Partially complete | Route/migration statements can drift from real code over time. | Keep |
|
||||
| `COPILOT_CONTEXT.md` | Architecture reference | Partially complete | Some route/render details may be out of sync with other docs; needs regular reconciliation. | Keep |
|
||||
| `FORM_APPLICATION_CONTEXT.md` | Legacy-to-modern feature spec | Partially complete | Large sections still marked not implemented / needs implementation. | Keep |
|
||||
| `RECEIVER_PDF_VIEWER_CONTEXT.md` | Receiver PDF viewer deep-dive | Partially complete | Long migration plan needs periodic sync with active implementation. | Keep |
|
||||
| `SENDER_SAVE_EDIT_MIGRATION_PLAN.md` | Sender workflow implementation plan | Partially complete | No explicit implementation progress markers per phase. | Keep (until migration closes) |
|
||||
| `SENDER_PDF_TOOLBAR_PLAN_EN.md` | Sender PDF toolbar plan | Incomplete | Explicit pending item remains (`Toolbar visual polish`). | Keep (until done) |
|
||||
| `SENDER_EDITOR_WASM_PDF_EDITOR_IMPLEMENTATION_PLAN_en.md` | Sender editor WASM migration plan | Incomplete | Plan exists but completion/progress tracking is minimal. | Keep (until done) |
|
||||
| `EnvelopeGenerator.Server/EnvelopeGenerator.Server/README.md` | Deployment guide | Complete (operational) | No critical gap identified. | Keep |
|
||||
| `envelope-generator-ui/README.md` | Angular scaffold README | Complete but generic | Template text only; low project-specific value. | Removable or replace with project-specific summary |
|
||||
| `EnvelopeGenerator.Web/wwwroot/lib/typed.js@2.1.0/README.md` | Third-party vendor README | Complete (vendor) | Not project workflow documentation. | Keep with vendor assets |
|
||||
| `EnvelopeGenerator.Web/wwwroot/lib/jquery-validation/LICENSE.md` | Third-party license | Complete (legal) | No gap. | Keep |
|
||||
| `EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/css/open-iconic/README.md` | Third-party vendor README | Complete (vendor) | Not project-specific guidance. | Keep with vendor assets |
|
||||
| `ignore/EnvelopeGenerator/EnvelopeGenerator.Web/wwwroot/README.md` | Legacy PSPDFKit note | Outdated | Mentions deprecated PSPDFKit-based flow; may mislead if reused. | Removable/archive |
|
||||
| `ignore/EnvelopeGenerator/EnvelopeGenerator.Web/wwwroot/lib/typed.js@2.1.0/README.md` | Duplicate vendor README | Redundant | Duplicate copy under `ignore/`. | Removable/archive |
|
||||
| `ignore/EnvelopeGenerator/EnvelopeGenerator.Web/wwwroot/lib/bootstrap-icons/README.md` | Vendor README in archive area | Secondary | Archive-only utility, not active docs. | Removable/archive |
|
||||
| `ignore/EnvelopeGenerator/EnvelopeGenerator.Web/wwwroot/lib/jquery-validation/LICENSE.md` | Duplicate license in archive area | Redundant | Duplicate copy under `ignore/`. | Removable/archive (if legal policy allows) |
|
||||
| `ignore/EnvelopeGenerator/EnvelopeGenerator.GeneratorAPI/ClientApp/envelope-generator-ui/README.md` | Duplicate Angular scaffold README | Redundant | Generic template + duplicate in archive path. | Removable/archive |
|
||||
|
||||
### Documentation Audit TODO
|
||||
|
||||
- [ ] Validate this markdown audit table against the current codebase and update statuses (`complete`, `partial`, `incomplete`, `removable`) when implementation changes land.
|
||||
|
||||
---
|
||||
|
||||
## Active Architecture (Post-Migration)
|
||||
|
||||
**Frontend:** Blazor Auto (Server+WASM hybrid)
|
||||
@@ -238,6 +278,7 @@ Manual testing workflow:
|
||||
|
||||
## Technical Debt TODOs
|
||||
|
||||
- **CRITICAL (Licensing / pre-commercial release):** Remove all `SixLabors.ImageSharp` and `QRCoder-ImageSharp` package dependencies from production/runtime projects before commercial release. Replace remaining QR/image paths with permissive-license alternatives (no copyleft/share-source obligations), run repo-wide package audit (`*.csproj`), and add CI guardrails to block reintroduction.
|
||||
- Refactor `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeSenderPage.razor` to reduce complexity.
|
||||
- Move sender dashboard business/status logic into dedicated services and extension methods (for example effective status resolution, tab classification, receiver signed/rejected detection, and grid-layout filter sanitation).
|
||||
- Keep `EnvelopeSenderPage.razor` focused on UI composition/state orchestration; avoid embedding heavy domain logic directly in the Razor component.
|
||||
@@ -250,6 +291,8 @@ Manual testing workflow:
|
||||
- Eliminate manual property-by-property request→domain mapping drift (example: `CreateEnvelopeCommandHandler`) by introducing a standardized mapping policy (AutoMapper/profile conventions or reflection/source-generator based mapper) and enforcing it across envelope workflows; this needs to be solved in the shared company Core library level so all services get the same guardrails by default.
|
||||
- P1 security workstream: remove dynamic SQL string composition/interpolation from active save/create/update paths (`string.Format`, `$"...{input}..."`, `ToSqlParam`) and migrate to parameterized queries only; treat this as a potential SQL injection vulnerability (stability + security risk), execute repo-wide audit, and add CI guardrails to block reintroduction.
|
||||
- Alternative UX design (Server component): in `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeSenderPage.razor`, enforce deterministic row double-click behavior as exactly one action (preview OR edit), and keep row action buttons/tooltips aligned with the same rule to eliminate ambiguous navigation.
|
||||
- Implement SPA-style language switching so the UI culture updates immediately without a full page reload.
|
||||
- Implement sender-scoped caching for "recent receiver suggestions" used during envelope creation (wizard/editor flows): cache key should include sender identity, support short TTL + invalidation hooks after successful envelope receiver writes, and prevent cross-sender suggestion leakage.
|
||||
|
||||
## Database
|
||||
|
||||
@@ -261,11 +304,51 @@ Manual testing workflow:
|
||||
**Key Tables:**
|
||||
- `TBSIG_ENVELOPE` - Envelope metadata
|
||||
- `TBSIG_ENVELOPE_RECEIVER` - Receiver assignments
|
||||
- `TBSIG_DOC_RECEIVER_ELEMENT` - Signature fields (X, Y in INCHES)
|
||||
- `TBSIG_DOCUMENT_RECEIVER_ELEMENT` - Signature fields (X, Y in INCHES)
|
||||
- `TBSIG_RECEIVER` - Receiver registry
|
||||
- `TBSIG_DOCUMENT` - PDF binary data
|
||||
- `TBSIG_ENVELOPE_DOCUMENT` - PDF binary data
|
||||
- `TBSIG_ENVELOPE_HISTORY` - Audit trail
|
||||
|
||||
### Data Structure (Domain + EGDbContext)
|
||||
|
||||
Use this as the canonical quick map before touching sender/receiver workflow code.
|
||||
|
||||
#### Core Aggregate Shape
|
||||
- `Envelope` (`TBSIG_ENVELOPE`) is the root aggregate for sending workflow.
|
||||
- `Envelope -> Documents` is `1:N` via `Document.EnvelopeId` (`TBSIG_ENVELOPE_DOCUMENT`).
|
||||
- `Envelope -> Histories` is `1:N` via `History.EnvelopeId` (`TBSIG_ENVELOPE_HISTORY`).
|
||||
- `Envelope <-> Receiver` is `N:N` through `EnvelopeReceiver` (`TBSIG_ENVELOPE_RECEIVER`, composite PK `{EnvelopeId, ReceiverId}`).
|
||||
- `Document -> DocReceiverElement` is `1:N` via `DocReceiverElement.DocumentId` (`TBSIG_DOCUMENT_RECEIVER_ELEMENT`).
|
||||
- `DocReceiverElement -> ElementAnnotation` is `1:N` via `ElementAnnotation.ElementId` (`TBSIG_DOCUMENT_RECEIVER_ELEMENT_ANNOTATION`).
|
||||
|
||||
#### Status and Tracking Entities
|
||||
- `DocumentStatus` (`TBSIG_DOCUMENT_STATUS`) links `EnvelopeId + ReceiverId` and stores receiver-level signing state.
|
||||
- `History` stores timeline events (`EnvelopeStatus`) with polymorphic `UserReference` lookup semantics.
|
||||
- `EnvelopeReceiverReadOnly` (`TBSIG_ENVELOPE_RECEIVER_READ_ONLY`) stores temporary read-only delegation with validity window (`DateValid`).
|
||||
|
||||
#### Important Constraints From `EGDbContext`
|
||||
- `Envelope.Uuid` has a unique index.
|
||||
- `Receiver.Signature` has a unique index.
|
||||
- `Receiver.EmailAddress` has a unique index.
|
||||
- `EnvelopeReceiver` uses composite primary key: `{ EnvelopeId, ReceiverId }`.
|
||||
- `History.UserReference` is mapped conditionally (`IsMigration == false`) to:
|
||||
- `Receiver.EmailAddress` (`History.Receiver`)
|
||||
- `User.Email` from UserManager (`History.Sender`)
|
||||
- `EnvelopeReceiverReadOnly.AddedWho` maps to `Receiver.EmailAddress` (principal key mapping, not receiver GUID).
|
||||
|
||||
#### Coordinate-Critical Fields
|
||||
- Signature placement is persisted in `DocReceiverElement` as `X`, `Y`, `Width`, `Height`, `Page`.
|
||||
- These coordinates are in INCHES (not PDF points, not pixels).
|
||||
|
||||
#### Cross-Module Tables in Same Context
|
||||
- `EGDbContext` also includes UserManager entities: `User`, `Group`, `Module`, `GroupOfUser`, `ModuleOfUser`, `UserRep`, and `ClientUser`.
|
||||
- Mail dispatcher entity `EmailOut` is also registered under `#if NET` and trigger-configurable.
|
||||
|
||||
#### Trigger-Aware Model Notes
|
||||
- Trigger names are injected from `DbTriggerParams` in appsettings and applied via `ToTable(tb => tb.HasTrigger(...))`.
|
||||
- Current trigger registration is explicit (`AddTrigger<T>()` calls), not attribute/reflection driven.
|
||||
- Do not remove trigger mappings casually; this project depends on DB-side automation/audit behavior.
|
||||
|
||||
## DevExpress
|
||||
|
||||
**License:** Commercial (v25.2.3)
|
||||
|
||||
384
DATABASE_STRUCTURE_CONTEXT.md
Normal file
384
DATABASE_STRUCTURE_CONTEXT.md
Normal file
@@ -0,0 +1,384 @@
|
||||
# Database Structure Context
|
||||
|
||||
## Why this file exists
|
||||
|
||||
This is the deep-dive DB reference for migration work when schema can change and old DB knowledge may be lost.
|
||||
|
||||
- Scope: `EnvelopeGenerator.Domain` entities + `EnvelopeGenerator.Infrastructure/EGDbContext.cs` + live DB metadata snapshot from `DD_ECM`.
|
||||
- Goal: make schema transitions predictable (what exists, how it is linked, where logic executes).
|
||||
- Safety: all DB checks used read-only `SELECT` metadata queries; no data mutation.
|
||||
|
||||
## Snapshot metadata
|
||||
|
||||
- Source DB: `DD_ECM` on `SDD-VHP04-SQL19\DD_TESTING01`
|
||||
- Snapshot date: `2026-10-20`
|
||||
- App context: `EnvelopeGenerator.Infrastructure/EGDbContext.cs`
|
||||
|
||||
## Fast navigation
|
||||
|
||||
- High-level aggregate map: `AGENTS.md` -> `Database` section
|
||||
- EF model config source: `EnvelopeGenerator.Infrastructure/EGDbContext.cs`
|
||||
- Stored procedure wrappers:
|
||||
- `EnvelopeGenerator.Application/Common/SQL/EnvelopeCreateReadSQL.cs`
|
||||
- `EnvelopeGenerator.Application/Common/SQL/EnvelopeReceiverAddReadSQL.cs`
|
||||
- `EnvelopeGenerator.Application/Common/SQL/DocumentCreateReadSQL.cs`
|
||||
- Direct SQL execution in controller:
|
||||
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/EnvelopeReceiverController.cs`
|
||||
|
||||
## Core data model (ER-level)
|
||||
|
||||
- `Envelope` (`TBSIG_ENVELOPE`) is root aggregate.
|
||||
- `Envelope (1) -> (N) Document` via `TBSIG_ENVELOPE_DOCUMENT.ENVELOPE_ID`.
|
||||
- `Envelope (1) -> (N) History` via `TBSIG_ENVELOPE_HISTORY.ENVELOPE_ID`.
|
||||
- `Envelope (N) <-> (N) Receiver` through `EnvelopeReceiver` (`TBSIG_ENVELOPE_RECEIVER`, composite PK `{ENVELOPE_ID, RECEIVER_ID}`).
|
||||
- `Document (1) -> (N) DocReceiverElement` via `TBSIG_DOCUMENT_RECEIVER_ELEMENT.DOCUMENT_ID`.
|
||||
- `DocReceiverElement (1) -> (N) ElementAnnotation` via `TBSIG_DOCUMENT_RECEIVER_ELEMENT_ANNOTATION.ELEMENT_ID`.
|
||||
- `DocumentStatus` links `Envelope + Receiver` state in `TBSIG_DOCUMENT_STATUS`.
|
||||
|
||||
## Entity <-> table mapping (code-level)
|
||||
|
||||
| Entity | Table | PK in code | PK in DB | Important relation notes |
|
||||
|---|---|---|---|---|
|
||||
| `Envelope` | `TBSIG_ENVELOPE` | `Id` | `GUID` | Has `Documents`, `Histories`, `EnvelopeReceivers`; unique index on `Uuid` configured in EF |
|
||||
| `EnvelopeReceiver` | `TBSIG_ENVELOPE_RECEIVER` | `{EnvelopeId, ReceiverId}` | `{ENVELOPE_ID, RECEIVER_ID}` | DB also has `GUID` identity (not modeled as entity PK) |
|
||||
| `Document` | `TBSIG_ENVELOPE_DOCUMENT` | `Id` | `GUID` | FK `ENVELOPE_ID` |
|
||||
| `DocReceiverElement` | `TBSIG_DOCUMENT_RECEIVER_ELEMENT` | `Id` | `GUID` | Coordinates persisted here (`POSITION_X/Y`, `WIDTH`, `HEIGHT`, `PAGE`) |
|
||||
| `ElementAnnotation` | `TBSIG_DOCUMENT_RECEIVER_ELEMENT_ANNOTATION` | `Id` | `GUID` | FK `ELEMENT_ID` |
|
||||
| `DocumentStatus` | `TBSIG_DOCUMENT_STATUS` | `Id` | `GUID` | FK `ENVELOPE_ID`, `RECEIVER_ID` |
|
||||
| `History` | `TBSIG_ENVELOPE_HISTORY` | `Id` | `GUID` | `USER_REFERENCE` maps to `Receiver.EmailAddress` and `User.Email` when `IsMigration == false` |
|
||||
| `Receiver` | `TBSIG_RECEIVER` | `Id` | `GUID` | Unique indexes in code: `Signature`, `EmailAddress` |
|
||||
| `EnvelopeReceiverReadOnly` | `TBSIG_ENVELOPE_RECEIVER_READ_ONLY` | `Id` | `GUID` | `AddedWho` is FK-like mapping to `Receiver.EmailAddress` |
|
||||
| `EnvelopeType` | `TBSIG_ENVELOPE_TYPE` | `Id` | `GUID` | Used as optional envelope defaults/template |
|
||||
| `EmailTemplate` | `TBSIG_EMAIL_TEMPLATE` | `Id` | `GUID` | Trigger-managed `CHANGED_WHEN` |
|
||||
| `EmailOut` (legacy/bridge) | `TBEMLP_EMAIL_OUT` | `Id` | `GUID` | Internal/obsolete domain class, still registered for DB access |
|
||||
|
||||
## Physical schema snapshot (table/column level)
|
||||
|
||||
Notes:
|
||||
- Format per column: `COLUMN_NAME TYPE NULLABLE`.
|
||||
- This section reflects live DB metadata at snapshot time.
|
||||
|
||||
### `TBSIG_ENVELOPE`
|
||||
|
||||
- `GUID int NOT NULL`
|
||||
- `USER_ID int NOT NULL`
|
||||
- `STATUS int NOT NULL`
|
||||
- `ENVELOPE_UUID nvarchar(36) NOT NULL`
|
||||
- `MESSAGE nvarchar(max) NOT NULL`
|
||||
- `EXPIRES_WHEN datetime NULL`
|
||||
- `EXPIRES_WARNING_WHEN datetime NULL`
|
||||
- `VERIFICATION_TYPE tinyint NOT NULL`
|
||||
- `TITLE nvarchar(128) NULL`
|
||||
- `CONTRACT_TYPE int NULL`
|
||||
- `LANGUAGE nvarchar(5) NULL`
|
||||
- `SEND_REMINDER_EMAILS bit NULL`
|
||||
- `FIRST_REMINDER_DAYS int NULL`
|
||||
- `REMINDER_INTERVAL_DAYS int NULL`
|
||||
- `ENVELOPE_TYPE int NULL`
|
||||
- `CERTIFICATION_TYPE int NULL`
|
||||
- `USE_ACCESS_CODE bit NULL`
|
||||
- `FINAL_EMAIL_TO_CREATOR int NULL`
|
||||
- `FINAL_EMAIL_TO_RECEIVERS int NULL`
|
||||
- `EXPIRES_WHEN_DAYS int NULL`
|
||||
- `EXPIRES_WARNING_WHEN_DAYS int NULL`
|
||||
- `TFA_ENABLED bit NOT NULL`
|
||||
- `DOC_RESULT varbinary(max) NULL`
|
||||
- `LANG_CODE varchar(5) NOT NULL`
|
||||
- `SOURCE varchar(10) NOT NULL`
|
||||
- `COMMENT varchar(128) NULL`
|
||||
- `ADDED_WHEN datetime NOT NULL`
|
||||
- `CHANGED_WHEN datetime NULL`
|
||||
|
||||
### `TBSIG_ENVELOPE_RECEIVER`
|
||||
|
||||
- `ENVELOPE_ID int NOT NULL`
|
||||
- `RECEIVER_ID int NOT NULL`
|
||||
- `SEQUENCE int NOT NULL`
|
||||
- `NAME nvarchar(128) NULL`
|
||||
- `JOB_TITLE nvarchar(128) NULL`
|
||||
- `COMPANY_NAME nvarchar(128) NULL`
|
||||
- `PRIVATE_MESSAGE nvarchar(max) NULL`
|
||||
- `ACCESS_CODE nvarchar(64) NULL`
|
||||
- `ADDED_WHEN datetime NOT NULL`
|
||||
- `CHANGED_WHEN datetime NULL`
|
||||
- `GUID bigint NOT NULL`
|
||||
- `PHONE_NUMBER varchar(20) NULL`
|
||||
- `PARENT_ENVELOPE_RECEIVER_ID int NOT NULL`
|
||||
|
||||
### `TBSIG_ENVELOPE_DOCUMENT`
|
||||
|
||||
- `GUID int NOT NULL`
|
||||
- `ENVELOPE_ID int NOT NULL`
|
||||
- `FILENAME nvarchar(256) NOT NULL`
|
||||
- `FILEPATH nvarchar(256) NOT NULL`
|
||||
- `ADDED_WHEN datetime NOT NULL`
|
||||
- `FILENAME_ORIGINAL nvarchar(256) NULL`
|
||||
- `BYTE_DATA varbinary(max) NULL`
|
||||
|
||||
### `TBSIG_DOCUMENT_RECEIVER_ELEMENT`
|
||||
|
||||
- `GUID int NOT NULL`
|
||||
- `DOCUMENT_ID int NOT NULL`
|
||||
- `RECEIVER_ID int NOT NULL`
|
||||
- `ELEMENT_TYPE int NOT NULL`
|
||||
- `POSITION_X float NOT NULL`
|
||||
- `POSITION_Y float NOT NULL`
|
||||
- `WIDTH float NOT NULL`
|
||||
- `HEIGHT float NOT NULL`
|
||||
- `PAGE int NOT NULL`
|
||||
- `REQUIRED bit NOT NULL`
|
||||
- `TOOLTIP nvarchar(512) NULL`
|
||||
- `READ_ONLY bit NOT NULL`
|
||||
- `ANNOTATION_INDEX int NOT NULL`
|
||||
- `ADDED_WHEN datetime NOT NULL`
|
||||
- `CHANGED_WHEN datetime NULL`
|
||||
- `CHANGED_WHO nvarchar(100) NULL`
|
||||
- `FULL_NAME nvarchar(100) NULL`
|
||||
- `POSITION nvarchar(100) NULL`
|
||||
- `PLACE nvarchar(100) NULL`
|
||||
- `INK varbinary(max) NULL`
|
||||
|
||||
### `TBSIG_DOCUMENT_RECEIVER_ELEMENT_ANNOTATION`
|
||||
|
||||
- `GUID bigint NOT NULL`
|
||||
- `ELEMENT_ID int NOT NULL`
|
||||
- `NAME nvarchar(100) NOT NULL`
|
||||
- `VALUE nvarchar(max) NOT NULL`
|
||||
- `ADDED_WHEN datetime NOT NULL`
|
||||
- `CHANGED_WHEN datetime NULL`
|
||||
- `CHANGED_WHO nvarchar(100) NULL`
|
||||
- `TYPE nvarchar(50) NOT NULL`
|
||||
- `POSITION_X float NULL`
|
||||
- `POSITION_Y float NULL`
|
||||
- `WIDTH float NULL`
|
||||
- `HEIGHT float NULL`
|
||||
|
||||
### `TBSIG_DOCUMENT_STATUS`
|
||||
|
||||
- `GUID int NOT NULL`
|
||||
- `ENVELOPE_ID int NOT NULL`
|
||||
- `RECEIVER_ID int NOT NULL`
|
||||
- `STATUS int NOT NULL`
|
||||
- `STATUS_CHANGED_WHEN datetime NULL`
|
||||
- `VALUE nvarchar(max) NULL`
|
||||
- `ADDED_WHEN datetime NOT NULL`
|
||||
- `CHANGED_WHEN datetime NULL`
|
||||
|
||||
### `TBSIG_ENVELOPE_HISTORY`
|
||||
|
||||
- `GUID bigint NOT NULL`
|
||||
- `ENVELOPE_ID int NOT NULL`
|
||||
- `USER_REFERENCE nvarchar(128) NOT NULL`
|
||||
- `STATUS int NOT NULL`
|
||||
- `ADDED_WHEN datetime NOT NULL`
|
||||
- `ACTION_DATE datetime NULL`
|
||||
- `COMMENT nvarchar(max) NULL`
|
||||
|
||||
### `TBSIG_RECEIVER`
|
||||
|
||||
- `GUID int NOT NULL`
|
||||
- `EMAIL_ADDRESS nvarchar(128) NOT NULL`
|
||||
- `SIGNATURE nvarchar(64) NOT NULL`
|
||||
- `ADDED_WHEN datetime NOT NULL`
|
||||
- `TOTP_SECRET_KEY nvarchar(max) NULL`
|
||||
- `TFA_REG_DEADLINE datetime NULL`
|
||||
- `CULTURE nvarchar(10) NULL`
|
||||
|
||||
### `TBSIG_ENVELOPE_RECEIVER_READ_ONLY`
|
||||
|
||||
- `GUID bigint NOT NULL`
|
||||
- `ENVELOPE_ID bigint NOT NULL`
|
||||
- `RECEIVER_MAIL nvarchar(250) NOT NULL`
|
||||
- `DATE_VALID date NOT NULL`
|
||||
- `ADDED_WHO varchar(100) NOT NULL`
|
||||
- `ADDED_WHEN datetime NULL`
|
||||
- `CHANGED_WHO varchar(100) NULL`
|
||||
- `CHANGED_WHEN datetime NULL`
|
||||
|
||||
### `TBSIG_ENVELOPE_TYPE`
|
||||
|
||||
- `GUID int NOT NULL`
|
||||
- `TITLE nvarchar(128) NOT NULL`
|
||||
- `LANGUAGE nvarchar(5) NOT NULL`
|
||||
- `EXPIRES_DAYS int NULL`
|
||||
- `CERTIFICATION_TYPE int NULL`
|
||||
- `USE_ACCESS_CODE bit NULL`
|
||||
- `FINAL_EMAIL_TO_CREATOR int NULL`
|
||||
- `FINAL_EMAIL_TO_RECEIVERS int NULL`
|
||||
- `ADDED_WHEN datetime NOT NULL`
|
||||
- `CHANGED_WHEN datetime NULL`
|
||||
- `EXPIRES_WARNING_DAYS int NULL`
|
||||
- `SEND_REMINDER_EMAILS bit NULL`
|
||||
- `FIRST_REMINDER_DAYS int NULL`
|
||||
- `REMINDER_INTERVAL_DAYS int NULL`
|
||||
- `CONTRACT_TYPE int NULL`
|
||||
|
||||
### `TBSIG_EMAIL_TEMPLATE`
|
||||
|
||||
- `GUID int NOT NULL`
|
||||
- `NAME nvarchar(64) NULL`
|
||||
- `BODY nvarchar(max) NULL`
|
||||
- `SUBJECT nvarchar(512) NULL`
|
||||
- `ADDED_WHEN datetime NOT NULL`
|
||||
- `CHANGED_WHEN datetime NULL`
|
||||
- `LANG_CODE varchar(5) NOT NULL`
|
||||
|
||||
### `TBEMLP_EMAIL_OUT`
|
||||
|
||||
- `GUID int NOT NULL`
|
||||
- `REMINDER_TYPE_ID int NOT NULL`
|
||||
- `SENDING_PROFILE int NOT NULL`
|
||||
- `REFERENCE_ID int NOT NULL`
|
||||
- `REFERENCE_STRING varchar(200) NULL`
|
||||
- `ENTITY_ID int NULL`
|
||||
- `WF_ID int NOT NULL`
|
||||
- `WF_REFERENCE varchar(200) NULL`
|
||||
- `EMAIL_ADRESS varchar(1000) NOT NULL`
|
||||
- `EMAIL_SUBJ varchar(500) NOT NULL`
|
||||
- `EMAIL_BODY varchar(max) NOT NULL`
|
||||
- `EMAIL_ATTMT1 varchar(512) NULL`
|
||||
- `EMAIL_SENT datetime NULL`
|
||||
- `COMMENT varchar(500) NULL`
|
||||
- `ADDED_WHO varchar(50) NOT NULL`
|
||||
- `ADDED_WHEN datetime NULL`
|
||||
- `CHANGED_WHO varchar(50) NULL`
|
||||
- `CHANGED_WHEN datetime NULL`
|
||||
- `ERROR_TIMESTAMP datetime NULL`
|
||||
- `ERROR_MSG varchar(900) NULL`
|
||||
- `ATTACHMENT1_BYTES varbinary(max) NULL`
|
||||
- `ATT1_RELATED_ID bigint NULL`
|
||||
- `ATT1_REL_TYPE varchar(20) NULL`
|
||||
|
||||
## Constraints and indexes (relevant)
|
||||
|
||||
From EF + DB metadata snapshot:
|
||||
|
||||
- `TBSIG_ENVELOPE`: PK `PK_TBSIG_ENVELOPE (GUID)`.
|
||||
- `TBSIG_ENVELOPE_RECEIVER`: composite PK `PK_TBSIG_ENVELOPE_RECEIVER (ENVELOPE_ID, RECEIVER_ID)`.
|
||||
- `TBSIG_DOCUMENT_RECEIVER_ELEMENT`: PK `GUID`, plus unique key `UQTBSIG_DOCUMENT_RECEIVER_ELEMENT_DocID_PosX_PosY (DOCUMENT_ID, POSITION_X, POSITION_Y)`.
|
||||
- `TBSIG_RECEIVER`: PK `GUID`, unique key `UQTBSIG_RECEIVER (EMAIL_ADDRESS)`.
|
||||
- `TBEMLP_EMAIL_OUT`: PK `GUID`, FK `FK_TBEMLP_EMAIL_OUT_SENDING_PROFILE`.
|
||||
|
||||
## Stored procedures: behavior and code call sites
|
||||
|
||||
### Active in .NET code path
|
||||
|
||||
1) `PRSIG_API_CREATE_ENVELOPE`
|
||||
- Purpose: creates envelope row (`STATUS=1002`, `SOURCE='API'`, defaults) and returns `ENVELOPE_UUID` via output.
|
||||
- DB writes: `TBSIG_ENVELOPE`.
|
||||
- Called by:
|
||||
- SQL template `EnvelopeGenerator.Application/Common/SQL/EnvelopeCreateReadSQL.cs`
|
||||
- executor `EnvelopeGenerator.Infrastructure/Executor/EnvelopeExecutor.cs`
|
||||
- used in `EnvelopeReceiverController.CreateAsync` and `CreateEnvelopeReceiverCommandHandler`.
|
||||
|
||||
2) `PRSIG_API_CREATE_RECEIVER`
|
||||
- Purpose: upserts `TBSIG_RECEIVER` by email, adds envelope assignment row with generated access code.
|
||||
- DB writes: `TBSIG_RECEIVER`, `TBSIG_ENVELOPE_RECEIVER`.
|
||||
- Called by:
|
||||
- SQL template `EnvelopeGenerator.Application/Common/SQL/EnvelopeReceiverAddReadSQL.cs`
|
||||
- executor `EnvelopeGenerator.Infrastructure/Executor/EnvelopeReceiverExecutor.cs`
|
||||
- used in `EnvelopeReceiverController.CreateAsync` and `CreateEnvelopeReceiverCommandHandler`.
|
||||
|
||||
3) `PRSIG_API_ADD_DOC`
|
||||
- Purpose: stores one document for envelope if absent; returns doc id.
|
||||
- DB writes: `TBSIG_ENVELOPE_DOCUMENT`.
|
||||
- Called by:
|
||||
- SQL template `EnvelopeGenerator.Application/Common/SQL/DocumentCreateReadSQL.cs`
|
||||
- executor `EnvelopeGenerator.Infrastructure/Executor/DocumentExecutor.cs`
|
||||
- used in `EnvelopeReceiverController.CreateAsync`.
|
||||
|
||||
4) `PRSIG_API_ADD_DOC_RECEIVER_ELEM`
|
||||
- Purpose: inserts signature element record if `(DOC_ID, RECEIVER_ID)` pair does not exist.
|
||||
- DB writes: `TBSIG_DOCUMENT_RECEIVER_ELEMENT`.
|
||||
- Called directly by:
|
||||
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/EnvelopeReceiverController.cs`
|
||||
|
||||
5) `PRSIG_API_ADD_HISTORY_STATE`
|
||||
- Purpose: appends history row with status and user reference.
|
||||
- DB writes: `TBSIG_ENVELOPE_HISTORY`.
|
||||
- Called directly by:
|
||||
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/EnvelopeReceiverController.cs`
|
||||
|
||||
### Legacy/other path references in repo
|
||||
|
||||
- `PRSIG_GET_ENVELOPES_FOR_USER` and `PRSIG_GET_CLOSED_ENVELOPES_FOR_USER` are called in legacy VB path:
|
||||
- `EnvelopeGenerator.CommonServices/Models/EnvelopeModel.vb`
|
||||
|
||||
- `PRSIG_API_WITHDRAW_ENVELOPE` exists in DB snapshot but no active `.cs` call site found in current server flow.
|
||||
|
||||
## Trigger behavior and where they are wired
|
||||
|
||||
## How trigger names are wired in app
|
||||
|
||||
- Config source: `EnvelopeGenerator.Server/EnvelopeGenerator.Server/appsettings.json` -> `DbTriggerParams`.
|
||||
- DI binding: `EnvelopeGenerator.Infrastructure/DependencyInjection.cs` (`AddDbTriggerParams`).
|
||||
- Model binding: `EnvelopeGenerator.Infrastructure/EGDbContext.cs` -> `AddTrigger<T>()` -> `ToTable(tb => tb.HasTrigger(triggerName))`.
|
||||
|
||||
### Trigger definitions observed in DB snapshot
|
||||
|
||||
1) `TBSIG_DOCUMENT_RECEIVER_ELEMENT_AFT_INS`
|
||||
- Fires on insert of `TBSIG_DOCUMENT_RECEIVER_ELEMENT`.
|
||||
- Normalizes/rewrites `POSITION_X`, `POSITION_Y` and sets `CHANGED_WHO='TBSIG_DOC_REC_EL_AFT_INS'`.
|
||||
|
||||
2) `TBSIG_DOCUMENT_RECEIVER_ELEMENT_AFT_UPD`
|
||||
- Fires on update of `TBSIG_DOCUMENT_RECEIVER_ELEMENT`.
|
||||
- Rewrites coordinates and `CHANGED_WHO='TBSIG_DOC_REC_EL_AFT_UPD'` except when `CHANGED_WHO='OFFSET_Y_POS'`.
|
||||
|
||||
3) `TBSIG_ENVELOPE_HISTORY_AFT_INS`
|
||||
- Fires on history insert.
|
||||
- Central state machine side-effects:
|
||||
- syncs `TBSIG_ENVELOPE.STATUS` for selected `100x` states,
|
||||
- on `1003` applies Y-offset update to receiver elements,
|
||||
- on `2005` calculates partial/fully signed and may insert follow-up `1006` history,
|
||||
- on `2007` (type-dependent reject path) writes rejection emails into `TBEMLP_EMAIL_OUT` and updates envelope rejected state,
|
||||
- on `3001` invitation-message counting can move envelope to `1004`.
|
||||
|
||||
4) `TBSIG_EMAIL_TEMPLATE_AFT_UPD`
|
||||
- Fires on update of `TBSIG_EMAIL_TEMPLATE`.
|
||||
- Sets `CHANGED_WHEN = GETDATE()`.
|
||||
|
||||
5) `TBSIG_ENVELOPE_RECEIVER_READ_ONLY_UPD`
|
||||
- Fires on update of `TBSIG_ENVELOPE_RECEIVER_READ_ONLY`.
|
||||
- Updates config timestamp row (`TBSIG_CONFIG.GUID = 1`).
|
||||
|
||||
6) `TBEMLP_EMAIL_OUT_AFT_UPD`
|
||||
- Fires on update of `TBEMLP_EMAIL_OUT`.
|
||||
- When `EMAIL_SENT` changes and `WF_ID in (3001..3005)`, writes corresponding history entries to `TBSIG_ENVELOPE_HISTORY`.
|
||||
|
||||
### Trigger mismatch alert (important)
|
||||
|
||||
- `DbTriggerParams` contains `TBEMLP_EMAIL_OUT_AFT_INS`, but this trigger was not found in the snapshot DB (`ExistsInDb = 0`).
|
||||
- `TBEMLP_EMAIL_OUT_AFT_UPD` exists and is active.
|
||||
|
||||
This should be treated as environment drift and validated before migrations.
|
||||
|
||||
## Known model-vs-DB drift risks
|
||||
|
||||
These are not necessarily bugs, but they are migration risk hotspots:
|
||||
|
||||
- `Receiver.EmailAddress` is `[StringLength(250)]` in entity, DB column is `nvarchar(128)`.
|
||||
- `EnvelopeReceiverReadOnly.AddedWho` entity expects `nvarchar(250)`, DB column is `varchar(100)`.
|
||||
- `EnvelopeReceiver` entity does not model DB columns `GUID` and `PARENT_ENVELOPE_RECEIVER_ID`.
|
||||
- `Envelope` entity does not explicitly model some DB columns (`VERIFICATION_TYPE`, `LANG_CODE`, `SOURCE`).
|
||||
- `EmailOut` entity has `[NotMapped] AddedWhen`, while DB has `ADDED_WHEN` column.
|
||||
|
||||
Before schema changes, reconcile these deltas intentionally (either mapping update or documented accepted divergence).
|
||||
|
||||
## Migration-safe operating rules
|
||||
|
||||
- Do not rely on implicit trigger behavior; document expected side effects in feature code touching history/status flows.
|
||||
- Any change to `TBSIG_ENVELOPE_HISTORY` semantics requires trigger impact review first.
|
||||
- Keep `DbTriggerParams` synchronized with actual DB trigger inventory per environment.
|
||||
- For stored procedure changes, update both:
|
||||
- SQL wrapper templates (`Application/Common/SQL/*`)
|
||||
- direct SQL callers in controllers/executors.
|
||||
|
||||
## Refresh checklist (read-only)
|
||||
|
||||
Run these metadata checks before/after DB migration:
|
||||
|
||||
1. Column inventory per table (`INFORMATION_SCHEMA.COLUMNS`).
|
||||
2. Constraint inventory (`INFORMATION_SCHEMA.TABLE_CONSTRAINTS` + `KEY_COLUMN_USAGE`).
|
||||
3. Trigger inventory (`sys.triggers`) and definitions (`OBJECT_DEFINITION`).
|
||||
4. Stored procedure inventory (`sys.procedures`) and definitions (`OBJECT_DEFINITION`).
|
||||
5. Repo call-site grep for `PRSIG_` names to detect dead or missing integration points.
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace EnvelopeGenerator.Application.Common.Configurations;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public class ESignParams
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public required string ApplicationName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public string DefaultReason { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace EnvelopeGenerator.Application.Common.Configurations;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public class SignatureFieldParams
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public double Width { get; init; } = 1.77;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public double Height { get; init; } = 1.96;
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
using EnvelopeGenerator.Domain.Constants;
|
||||
using EnvelopeGenerator.Application.Common.Dto.Receiver;
|
||||
using EnvelopeGenerator.Domain.Constants;
|
||||
using EnvelopeGenerator.Domain.Interfaces;
|
||||
|
||||
namespace EnvelopeGenerator.Application.Common.Dto;
|
||||
|
||||
// TODO: Add the “Reason” property and integrate with UI and the digital signature process.
|
||||
/// <summary>
|
||||
/// Data Transfer Object representing a positioned element assigned to a document receiver.
|
||||
/// </summary>
|
||||
public class DocReceiverElementDto : IDocReceiverElement
|
||||
public class DocReceiverElementDto : IDocReceiverElement, ISignatureInkPayload
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the unique identifier of the element.
|
||||
@@ -18,11 +20,21 @@ public class DocReceiverElementDto : IDocReceiverElement
|
||||
/// </summary>
|
||||
public int DocumentId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public virtual DocumentDto? Document { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the identifier of the receiver.
|
||||
/// </summary>
|
||||
public int ReceiverId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public virtual ReceiverDto? Receiver { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the type of the element.
|
||||
/// </summary>
|
||||
@@ -122,4 +134,41 @@ public class DocReceiverElementDto : IDocReceiverElement
|
||||
///
|
||||
/// </summary>
|
||||
public byte[]? Ink { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the stored signature bytes as an image payload with detected MIME type.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">Thrown when signature image bytes do not exist.</exception>
|
||||
public (byte[] Bytes, string ContentType, string FileExtension) ToImage()
|
||||
{
|
||||
if (Ink is not { Length: > 0 })
|
||||
throw new InvalidOperationException("Signature image (Ink) does not exist.");
|
||||
|
||||
if (HasPrefix(Ink, 0x89, 0x50, 0x4E, 0x47))
|
||||
return (Ink, "image/png", ".png");
|
||||
|
||||
if (HasPrefix(Ink, 0xFF, 0xD8, 0xFF))
|
||||
return (Ink, "image/jpeg", ".jpg");
|
||||
|
||||
if (HasPrefix(Ink, 0x52, 0x49, 0x46, 0x46) &&
|
||||
Ink.Length >= 12 &&
|
||||
Ink[8] == 0x57 && Ink[9] == 0x45 && Ink[10] == 0x42 && Ink[11] == 0x50)
|
||||
return (Ink, "image/webp", ".webp");
|
||||
|
||||
return (Ink, "application/octet-stream", ".bin");
|
||||
}
|
||||
|
||||
private static bool HasPrefix(byte[] data, params byte[] prefix)
|
||||
{
|
||||
if (data.Length < prefix.Length)
|
||||
return false;
|
||||
|
||||
for (var i = 0; i < prefix.Length; i++)
|
||||
{
|
||||
if (data[i] != prefix[i])
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,5 +23,5 @@ public record DocumentDto : DocumentDetailsDto
|
||||
/// <summary>
|
||||
/// Gets or sets the binary data of the document, if available.
|
||||
/// </summary>
|
||||
public byte[]? ByteData { get; init; }
|
||||
public byte[]? ByteData { get; set; }
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ public record EnvelopeDto : IEnvelope
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public int? ContractType { get; set; }
|
||||
public ContractType? ContractType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Default value is 'de-DE'
|
||||
@@ -84,7 +84,7 @@ public record EnvelopeDto : IEnvelope
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public int? CertificationType { get; set; }
|
||||
public CertificationType? CertificationType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
namespace EnvelopeGenerator.Application.Common.Dto;
|
||||
using EnvelopeGenerator.Domain.Constants;
|
||||
|
||||
namespace EnvelopeGenerator.Application.Common.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// Data Transfer Object representing a type of envelope with its configuration settings.
|
||||
@@ -28,7 +30,7 @@ public class EnvelopeTypeDto
|
||||
/// <summary>
|
||||
/// Gets or sets the certification type identifier.
|
||||
/// </summary>
|
||||
public int? CertificationType { get; set; }
|
||||
public CertificationType? CertificationType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether an access code is required.
|
||||
@@ -78,5 +80,5 @@ public class EnvelopeTypeDto
|
||||
/// <summary>
|
||||
/// Gets or sets the contract type associated with the envelope type.
|
||||
/// </summary>
|
||||
public int? ContractType { get; set; }
|
||||
public ContractType? ContractType { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using EnvelopeGenerator.Application.Common.Enums;
|
||||
|
||||
namespace EnvelopeGenerator.Application.Common.Dto;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public sealed record PdfSignOptionsDto
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public long DocumentElementId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public string Name { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public string Reason { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public string Location { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public string? Email { get; set; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public string? Phone { get; set; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public string ApplicationName { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public PdfSignatureCertificationLevel CertificationLevel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public PdfSignatureAppearanceDto? Appearance { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
namespace EnvelopeGenerator.Application.Common.Dto;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public enum PdfSignatureCoordinateUnit
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
Point = 0,
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
Inch = 1
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public enum PdfSignatureCoordinateOrigin
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
BottomLeft = 0,
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
TopLeft = 1
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="PageNumber"></param>
|
||||
/// <param name="Left"></param>
|
||||
/// <param name="Bottom"></param>
|
||||
/// <param name="Width"></param>
|
||||
/// <param name="Height"></param>
|
||||
/// <param name="ShowLabels"></param>
|
||||
/// <param name="ShowName"></param>
|
||||
/// <param name="ShowDate"></param>
|
||||
/// <param name="ShowReason"></param>
|
||||
/// <param name="ShowLocation"></param>
|
||||
/// <param name="ShowDistinguishedName"></param>
|
||||
/// <param name="DateTimeFormat"></param>
|
||||
/// <param name="CoordinateUnit"></param>
|
||||
/// <param name="CoordinateOrigin"></param>
|
||||
public sealed record PdfSignatureAppearanceDto(
|
||||
int PageNumber,
|
||||
double Left,
|
||||
double Bottom,
|
||||
double Width,
|
||||
double Height,
|
||||
bool ShowLabels = false,
|
||||
bool ShowName = false,
|
||||
bool ShowDate = false,
|
||||
bool ShowReason = false,
|
||||
bool ShowLocation = false,
|
||||
bool ShowDistinguishedName = false,
|
||||
string DateTimeFormat = "dd.MM.yyyy HH:mm:ss zzz",
|
||||
PdfSignatureCoordinateUnit CoordinateUnit = PdfSignatureCoordinateUnit.Point,
|
||||
PdfSignatureCoordinateOrigin CoordinateOrigin = PdfSignatureCoordinateOrigin.BottomLeft)
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="FamilyName"></param>
|
||||
/// <param name="Size"></param>
|
||||
/// <param name="IsBold"></param>
|
||||
/// <param name="IsItalic"></param>
|
||||
/// <param name="IsUnderline"></param>
|
||||
public sealed record FontDto(string FamilyName = "Helvetica", float Size = 10, bool IsBold = false, bool IsItalic = false, bool IsUnderline = false)
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public RgbaColorDto Color { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="R"></param>
|
||||
/// <param name="G"></param>
|
||||
/// <param name="B"></param>
|
||||
public sealed record RgbaColorDto(double R = 0, double G = 0, double B = 0);
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public FontDto Font { get; init; } = new();
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
namespace EnvelopeGenerator.Application.Common.Dto;
|
||||
|
||||
public sealed record PdfSignaturePrincipalDto(
|
||||
string? CommonName,
|
||||
string? Organization,
|
||||
string? Country,
|
||||
string? Full);
|
||||
|
||||
public sealed record PdfSignatureCertificateInfoDto(
|
||||
PdfSignaturePrincipalDto IssuedTo,
|
||||
PdfSignaturePrincipalDto IssuedBy,
|
||||
string? SerialNumber,
|
||||
DateTimeOffset? ValidFrom,
|
||||
DateTimeOffset? ValidTo,
|
||||
bool SelfSigned,
|
||||
bool Expired,
|
||||
bool NotYetValid,
|
||||
bool IsSigner,
|
||||
int? Version,
|
||||
string? KeyAlgorithm,
|
||||
string? SignatureAlgorithm,
|
||||
string? Pem,
|
||||
int? KeySizeBits);
|
||||
|
||||
public sealed record PdfSignatureCheckItemDto(
|
||||
bool CoversWholeDoc,
|
||||
string? Reason,
|
||||
string? BusinessReason,
|
||||
string? Location,
|
||||
string? DeclaredName,
|
||||
string? ContactInfo,
|
||||
string? Email,
|
||||
string? Phone,
|
||||
string? DigestAlgorithm,
|
||||
string? SignatureAlgorithm,
|
||||
string? SignerName,
|
||||
string? Issuer,
|
||||
string? SerialNumber,
|
||||
DateTimeOffset? ValidFrom,
|
||||
DateTimeOffset? ValidTo,
|
||||
string ExpiryCheckedAgainst,
|
||||
IReadOnlyList<PdfSignatureCertificateInfoDto> Certificates,
|
||||
bool Unchanged,
|
||||
string? FieldName,
|
||||
bool IsCryptographicallyValid,
|
||||
string? SigningTime,
|
||||
string? Filter,
|
||||
string? SubFilter,
|
||||
string? ApplicationName,
|
||||
long? DocumentElementId,
|
||||
string? OriginalDocumentSha256,
|
||||
string? MessageDigestHex,
|
||||
string? Error);
|
||||
|
||||
public sealed record PdfSignatureCheckResultDto(
|
||||
bool Signed,
|
||||
IReadOnlyList<PdfSignatureCheckItemDto> Signatures);
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace EnvelopeGenerator.Application.Common.Dto.Receiver;
|
||||
|
||||
public class RecentReceiverSuggestionDto
|
||||
{
|
||||
public required string FullName { get; init; }
|
||||
|
||||
public required string EmailAddress { get; init; }
|
||||
|
||||
public required DateTime LastUsedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace EnvelopeGenerator.Application.Common.Enums;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public enum PdfSignatureCertificationLevel
|
||||
{
|
||||
/// <summary>
|
||||
/// All changes are allowed
|
||||
/// </summary>
|
||||
NoCertification = 0,
|
||||
|
||||
/// <summary>
|
||||
/// No changes are allowed
|
||||
/// </summary>
|
||||
NoChangesAllowed,
|
||||
|
||||
/// <summary>
|
||||
/// Allows users to fill in forms and sign the document.
|
||||
/// </summary>
|
||||
FillForms,
|
||||
|
||||
/// <summary>
|
||||
/// Allows users to fill in forms, create, modify and delete annotations, and sign the document.
|
||||
/// </summary>
|
||||
FillFormsAndAnnotate
|
||||
}
|
||||
@@ -1,8 +1,66 @@
|
||||
using DigitalData.Core.Exceptions;
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
using EnvelopeGenerator.Domain.Interfaces;
|
||||
|
||||
namespace EnvelopeGenerator.Application.Common.Interfaces.Doc;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public interface IPdfPageImageRenderer
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="pdfBytes"></param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns></returns>
|
||||
Task<IReadOnlyList<PdfPageImageDto>> RenderPagesAsImagesAsync(byte[] pdfBytes, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="element"></param>
|
||||
/// <returns></returns>
|
||||
Stream ComposeSignaturePreviewPng(ISignatureInkPayload element);
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="bytes"></param>
|
||||
/// <returns></returns>
|
||||
public bool IsSupportedImageFormat(byte[] bytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public static class PdfPageImageRendererExtensions
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="renderer"></param>
|
||||
/// <param name="inkBytes"></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="BadRequestException"></exception>
|
||||
public static byte[] EnsureSupportedImageBytes(this IPdfPageImageRenderer renderer, byte[] inkBytes)
|
||||
{
|
||||
if (!renderer.IsSupportedImageFormat(inkBytes))
|
||||
throw new BadRequestException("Only PNG and JPEG signature formats are supported.");
|
||||
|
||||
return inkBytes;
|
||||
}
|
||||
|
||||
public static byte[] ComposeSignaturePreviewPngBytes(this IPdfPageImageRenderer renderer, ISignatureInkPayload element)
|
||||
{
|
||||
using var stream = renderer.ComposeSignaturePreviewPng(element);
|
||||
|
||||
if (stream is MemoryStream memoryStream)
|
||||
return memoryStream.ToArray();
|
||||
|
||||
using var copy = new MemoryStream();
|
||||
stream.CopyTo(copy);
|
||||
return copy.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace EnvelopeGenerator.Application.Common.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public interface IRequestContext
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
string? Origin { get; }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
|
||||
namespace EnvelopeGenerator.Application.Common.Interfaces.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Defines PDF digital-signing operations for the application boundary.
|
||||
/// </summary>
|
||||
public interface IPdfDigitalSignatureService
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a digital signature for the provided PDF bytes and returns
|
||||
/// a new signed PDF document.
|
||||
/// </summary>
|
||||
/// <param name="inputPdf">Source PDF content stream.</param>
|
||||
/// <param name="outputPdf">Destination stream for signed PDF output.</param>
|
||||
/// <param name="options">Optional signing options that map to PDF signature metadata fields.</param>
|
||||
/// <param name="image">Optional image stream for signature appearance.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for cooperative cancellation.</param>
|
||||
Task SignPdfAsync(Stream inputPdf, Stream outputPdf, PdfSignOptionsDto? options = null, Stream? image = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the provided PDF contains signature field(s) and whether
|
||||
/// those signatures are cryptographically valid.
|
||||
/// </summary>
|
||||
/// <param name="inputPdf">PDF content stream to inspect.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for cooperative cancellation.</param>
|
||||
/// <returns>A structured result containing signature presence and per-signature details.</returns>
|
||||
Task<PdfSignatureCheckResultDto> CheckPdfSignatureAsync(Stream inputPdf, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using EnvelopeGenerator.Application.Common.Extensions;
|
||||
using EnvelopeGenerator.Domain.Constants;
|
||||
|
||||
namespace EnvelopeGenerator.Application.Common.Interfaces.Services.Notification;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public record DocumentReceivedNotification : EnvelopeNotificationBase
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public required string DocumentTitle { get; init; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public required string NameReceiver { get; init; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public required string NameSender { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// [SIGNATURE_TYPE]
|
||||
/// </summary>
|
||||
public required bool IsReadAndConfirm { private get; init; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public string SignatureType => IsReadAndConfirm ? "Signieren" : "Lesen und bestätigen";
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public required bool AdvancedSignature { private get; init; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public string DocumentProcess => AdvancedSignature ? " und elektronisch unterschreiben" : string.Empty;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public required Uri Origin { private get; init; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public required string EnvelopeUuid { private get; init; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public required string ReceiverSignature { private get; init; }
|
||||
|
||||
private Uri EnvelopeURL => new(Origin, $"Envelope/{(EnvelopeUuid, ReceiverSignature).ToEnvelopeKey()}");
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public string LinkToDocument => EnvelopeURL.AbsoluteUri;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public string LinkToDocumentText
|
||||
{
|
||||
get
|
||||
{
|
||||
var urlText = EnvelopeURL.ToString();
|
||||
return $"{urlText[..Math.Min(urlText.Length, 40)]}..";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public string NamePortal { get; set; } = "signFLOW Portal";
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public override EmailTemplateType GetTemplateType()
|
||||
{
|
||||
return EmailTemplateType.DocumentReceived;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using EnvelopeGenerator.Domain.Constants;
|
||||
|
||||
namespace EnvelopeGenerator.Application.Common.Interfaces.Services.Notification;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public record DocumentSignedNotification : EnvelopeNotificationBase
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public required string DocumentTitle { get; init; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public required string NameReceiver { get; init; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public string NamePortal { get; init; } = "signFLOW Portal";
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public required bool IsReadAndConfirm { private get; init; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public string ReceiverAction => IsReadAndConfirm ? "bestätigt" : "unterschrieben";
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public string FinalStatus => IsReadAndConfirm ? "Lesebestätigung" : "Signatur";
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public string FinalAction => IsReadAndConfirm ? "Empfänger bestätigt" : "Vertragspartner unterzeichnet";
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public override EmailTemplateType GetTemplateType() => EmailTemplateType.DocumentSigned;
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
Umschlag abgeschlossen: '[DOCUMENT_TITLE]'
|
||||
|
||||
---
|
||||
|
||||
Guten Tag [NAME_RECEIVER],<br /> <br /> Der Vorgang <B><I>'[DOCUMENT_TITLE]'</I></B> wurde erfolgreich abgeschlossen.<br /> <br /> Sie erhalten das Dokument mit einem detaillierten Ergebnisbericht als Anhang zu dieser Email.<br /> <br /> Mit freundlichen Grüßen<br /> <br /> [NAME_PORTAL]
|
||||
|
||||
*/
|
||||
@@ -0,0 +1,19 @@
|
||||
using EnvelopeGenerator.Domain.Constants;
|
||||
|
||||
namespace EnvelopeGenerator.Application.Common.Interfaces.Services.Notification;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public abstract record EnvelopeNotificationBase
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public abstract EmailTemplateType GetTemplateType();
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public IReadOnlyList<(string FileName, byte[] Content)>? Attachments { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace EnvelopeGenerator.Application.Common.Interfaces.Services.Notification;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public interface IEnvelopeNotificationService
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <typeparam name="TEnvelopeNotificationBase"></typeparam>
|
||||
/// <param name="receiverEmail"></param>
|
||||
/// <param name="notification"></param>
|
||||
/// <param name="cancel"></param>
|
||||
/// <returns></returns>
|
||||
public Task Notify<TEnvelopeNotificationBase>(string receiverEmail, TEnvelopeNotificationBase notification, CancellationToken cancel = default)
|
||||
where TEnvelopeNotificationBase : EnvelopeNotificationBase;
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
using DigitalData.EmailProfilerDispatcher.Abstraction.Entities;
|
||||
using EnvelopeGenerator.Application.Common.Configurations;
|
||||
using EnvelopeGenerator.Application.Common.Extensions;
|
||||
using EnvelopeGenerator.Application.Common.Notifications.EnvelopeDeleted;
|
||||
using EnvelopeGenerator.Domain.Constants;
|
||||
using EnvelopeGenerator.Domain.Entities;
|
||||
using MediatR;
|
||||
@@ -96,6 +97,10 @@ public abstract class SendMailHandler<TNotification> : INotificationHandler<TNot
|
||||
/// <exception cref="NotImplementedException"></exception>
|
||||
public virtual async Task Handle(TNotification notification, CancellationToken cancel)
|
||||
{
|
||||
// do not send email if the envelope is deleted and the status is less than or equal to EnvelopeSaved
|
||||
if (notification is EnvelopeDeletedNotification delNot && delNot.Envelope.Status <= EnvelopeStatus.EnvelopeSaved)
|
||||
return;
|
||||
|
||||
var placeHolders = CreatePlaceHolders(notification);
|
||||
|
||||
var temp = await TempRepo
|
||||
|
||||
@@ -49,8 +49,10 @@ public static class DependencyInjection
|
||||
|
||||
services.Configure<DispatcherParams>(config.GetSection(nameof(DispatcherParams)));
|
||||
services.Configure<MailParams>(config.GetSection(nameof(MailParams)));
|
||||
services.Configure<SignatureFieldParams>(config.GetSection(nameof(SignatureFieldParams)));
|
||||
services.Configure<AuthenticatorParams>(config.GetSection(nameof(AuthenticatorParams)));
|
||||
services.Configure<TotpSmsParams>(config.GetSection(nameof(TotpSmsParams)));
|
||||
services.Configure<ESignParams>(config.GetSection(nameof(ESignParams)));
|
||||
|
||||
services.AddHttpClientService<GtxMessagingParams>(config.GetSection(nameof(GtxMessagingParams)));
|
||||
services.TryAddScoped<ISmsSender, GTXSmsSender>(); // Changed: Singleton → Scoped
|
||||
|
||||
@@ -14,6 +14,15 @@ namespace EnvelopeGenerator.Application.DocReceiverElements.Queries;
|
||||
/// </summary>
|
||||
public record ReadDocReceiverElementQuery : EnvelopeReceiverQueryBase, IRequest<IEnumerable<DocReceiverElementDto>>
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public int? Id { get; set; } = null;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public bool IncludeDocument { get; set; } = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -47,7 +56,10 @@ public class ReadDocReceiverElementQueryHandler : IRequestHandler<ReadDocReceive
|
||||
{
|
||||
var q = _repository.Query;
|
||||
|
||||
if(request.Envelope.Id is int envelopeId)
|
||||
if (request.Id is not null)
|
||||
q = q.Where(e => e.Id == request.Id);
|
||||
|
||||
if (request.Envelope.Id is int envelopeId)
|
||||
q = q.Where(e => e.Document.EnvelopeId == envelopeId);
|
||||
|
||||
if (request.Envelope.Uuid is string envelopeUuid)
|
||||
@@ -59,6 +71,9 @@ public class ReadDocReceiverElementQueryHandler : IRequestHandler<ReadDocReceive
|
||||
if (request.Receiver.Signature is string signature)
|
||||
q = q.Where(e => e.Receiver.Signature == signature);
|
||||
|
||||
if( request.IncludeDocument)
|
||||
q = q.Include(e => e.Document);
|
||||
|
||||
var elements = await q.ToListAsync(cancellationToken);
|
||||
|
||||
return _mapper.Map<IEnumerable<DocReceiverElementDto>>(elements);
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
using AutoMapper;
|
||||
using AutoMapper.QueryableExtensions;
|
||||
using DigitalData.Core.Abstraction.Application.Repository;
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
using EnvelopeGenerator.Domain.Entities;
|
||||
@@ -21,12 +19,10 @@ public record ReadDocumentDetailsQuery(int EnvelopeId, int? UserId = null) : IRe
|
||||
public class ReadDocumentDetailsQueryHandler : IRequestHandler<ReadDocumentDetailsQuery, DocumentDetailsDto?>
|
||||
{
|
||||
private readonly IRepository<Document> _repo;
|
||||
private readonly IMapper _mapper;
|
||||
|
||||
public ReadDocumentDetailsQueryHandler(IRepository<Document> repo, IMapper mapper)
|
||||
public ReadDocumentDetailsQueryHandler(IRepository<Document> repo)
|
||||
{
|
||||
_repo = repo;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
public async Task<DocumentDetailsDto?> Handle(ReadDocumentDetailsQuery query, CancellationToken cancel)
|
||||
@@ -36,9 +32,71 @@ public class ReadDocumentDetailsQueryHandler : IRequestHandler<ReadDocumentDetai
|
||||
if (query.UserId is int userId)
|
||||
docQuery = docQuery.Where(doc => doc.Envelope.UserId == userId);
|
||||
|
||||
return await docQuery
|
||||
// shortcut: manual select avoids AutoMapper ProjectTo pulling BYTE_DATA and the full navigation
|
||||
// graph (Envelope → History → User etc.) which caused an extremely slow query (~87s).
|
||||
// Annotations navigation is IEnumerable<T> on the entity (not ICollection<T>), so EF Core
|
||||
// cannot translate it in a Select projection — we skip it entirely here; sender editor only
|
||||
// needs element coordinates, not annotation payloads.
|
||||
var raw = await docQuery
|
||||
.Where(d => d.EnvelopeId == query.EnvelopeId)
|
||||
.ProjectTo<DocumentDetailsDto>(_mapper.ConfigurationProvider)
|
||||
.Select(d => new
|
||||
{
|
||||
d.Id,
|
||||
d.EnvelopeId,
|
||||
d.AddedWhen,
|
||||
Elements = d.Elements == null ? null : d.Elements.Select(e => new
|
||||
{
|
||||
e.Id,
|
||||
e.DocumentId,
|
||||
e.ReceiverId,
|
||||
e.ElementType,
|
||||
e.X,
|
||||
e.Y,
|
||||
e.Width,
|
||||
e.Height,
|
||||
e.Page,
|
||||
e.Required,
|
||||
e.Tooltip,
|
||||
e.ReadOnly,
|
||||
e.AnnotationIndex,
|
||||
e.AddedWhen,
|
||||
e.ChangedWhen,
|
||||
e.FullName,
|
||||
e.Position,
|
||||
e.Place,
|
||||
}).ToList()
|
||||
})
|
||||
.FirstOrDefaultAsync(cancel);
|
||||
|
||||
if (raw is null)
|
||||
return null;
|
||||
|
||||
return new DocumentDetailsDto
|
||||
{
|
||||
Id = raw.Id,
|
||||
EnvelopeId = raw.EnvelopeId,
|
||||
AddedWhen = raw.AddedWhen,
|
||||
Elements = raw.Elements?.Select(e => new DocReceiverElementDto
|
||||
{
|
||||
Id = e.Id,
|
||||
DocumentId = e.DocumentId,
|
||||
ReceiverId = e.ReceiverId,
|
||||
ElementType = e.ElementType,
|
||||
X = e.X,
|
||||
Y = e.Y,
|
||||
Width = e.Width,
|
||||
Height = e.Height,
|
||||
Page = e.Page,
|
||||
Required = e.Required,
|
||||
Tooltip = e.Tooltip,
|
||||
ReadOnly = e.ReadOnly,
|
||||
AnnotationIndex = e.AnnotationIndex,
|
||||
AddedWhen = e.AddedWhen,
|
||||
ChangedWhen = e.ChangedWhen,
|
||||
FullName = e.FullName,
|
||||
Position = e.Position,
|
||||
Place = e.Place,
|
||||
})
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<TargetFrameworks>net7.0;net8.0;net9.0</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
using MediatR;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace EnvelopeGenerator.Application.EnvelopeReceivers.Commands;
|
||||
namespace EnvelopeGenerator.Application.EnvelopeReceivers.Commands.CreateEnvelopeReceiver;
|
||||
|
||||
/// <summary>
|
||||
/// Befehl zur Erstellung eines Umschlags.
|
||||
@@ -4,7 +4,7 @@ using MediatR;
|
||||
using EnvelopeGenerator.Application.Common.Dto.Receiver;
|
||||
using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor;
|
||||
|
||||
namespace EnvelopeGenerator.Application.EnvelopeReceivers.Commands;
|
||||
namespace EnvelopeGenerator.Application.EnvelopeReceivers.Commands.CreateEnvelopeReceiver;
|
||||
|
||||
/// <summary>
|
||||
/// Handles the creation of an envelope along with its associated document and recipients.
|
||||
@@ -1,7 +1,7 @@
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
using EnvelopeGenerator.Application.Common.Dto.Receiver;
|
||||
|
||||
namespace EnvelopeGenerator.Application.EnvelopeReceivers.Commands;
|
||||
namespace EnvelopeGenerator.Application.EnvelopeReceivers.Commands.CreateEnvelopeReceiver;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
@@ -0,0 +1,84 @@
|
||||
using DigitalData.Core.Abstraction.Application.Repository;
|
||||
using DigitalData.Core.Exceptions;
|
||||
using EnvelopeGenerator.Domain.Constants;
|
||||
using EnvelopeGenerator.Domain.Entities;
|
||||
using EnvelopeGenerator.Domain.Exceptions;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace EnvelopeGenerator.Application.EnvelopeReceivers.Commands;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public record RemoveEnvelopeReceiverCommand : IRequest
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public required int EnvelopeId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public required int ReceiverId { get; init; }
|
||||
|
||||
internal int? UserId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="userId"></param>
|
||||
/// <returns></returns>
|
||||
public RemoveEnvelopeReceiverCommand Authenticated(int userId)
|
||||
{
|
||||
UserId = userId;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="Repo"></param>
|
||||
public class RemoveEnvelopeReceiverCommandHandler(
|
||||
IRepository<EnvelopeReceiver> Repo,
|
||||
IRepository<DocReceiverElement> ElementRepo,
|
||||
IRepository<ElementAnnotation> AnnotRepo) : IRequestHandler<RemoveEnvelopeReceiverCommand>
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="request"></param>
|
||||
/// <param name="cancel"></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="NotImplementedException"></exception>
|
||||
public async Task Handle(RemoveEnvelopeReceiverCommand request, CancellationToken cancel)
|
||||
{
|
||||
var er = await Repo.Query
|
||||
.Where(er => er.EnvelopeId == request.EnvelopeId && er.ReceiverId == request.ReceiverId)
|
||||
.Include(er => er.Envelope)
|
||||
.SingleOrDefaultAsync(cancel)
|
||||
?? throw new NotFoundException();
|
||||
|
||||
if (er.Envelope is null)
|
||||
throw new InvalidOperationException($"In {nameof(RemoveEnvelopeReceiverCommandHandler)} EnvelopeReceiver.Envelope is null." +
|
||||
$"This should not happen." +
|
||||
$"EnvelopeId: {request.EnvelopeId}, ReceiverId: {request.ReceiverId}");
|
||||
|
||||
if (request.UserId is int userId && er.Envelope.UserId != userId)
|
||||
throw new AuthorizationException("Cannot remove receiver from envelope that you do not own.");
|
||||
|
||||
if (er.Envelope.Status > EnvelopeStatus.EnvelopeSaved)
|
||||
throw new ForbiddenException("Cannot remove receiver from envelope that is already processed.");
|
||||
|
||||
// 1. Delete all ElementAnnotations for this EnvelopeReceiver
|
||||
await AnnotRepo.DeleteAsync(ann => ann.Element.Document.EnvelopeId == request.EnvelopeId && ann.Element.ReceiverId == request.ReceiverId, cancel);
|
||||
|
||||
// 2. Delete all DocReceiverElements for this EnvelopeReceiver
|
||||
await ElementRepo.DeleteAsync(el => el.Document.EnvelopeId == request.EnvelopeId && el.ReceiverId == request.ReceiverId, cancel);
|
||||
|
||||
// 3. Delete the EnvelopeReceiver
|
||||
await Repo.DeleteAsync(er => er.EnvelopeId == request.EnvelopeId && er.ReceiverId == request.ReceiverId, cancel);
|
||||
}
|
||||
}
|
||||
@@ -16,5 +16,9 @@ public class MappingProfile : Profile
|
||||
public MappingProfile()
|
||||
{
|
||||
CreateMap<Receiver, ReceiverDto>();
|
||||
|
||||
CreateMap<EnvelopeReceiver, RecentReceiverSuggestionDto>()
|
||||
.ForMember(dest => dest.FullName, opt => opt.MapFrom(src => src.Name))
|
||||
.ForMember(dest => dest.EmailAddress, opt => opt.MapFrom(src => src.Receiver.EmailAddress));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,24 @@ namespace EnvelopeGenerator.Application.EnvelopeReceivers.Queries;
|
||||
/// </remarks>
|
||||
public record ReadEnvelopeReceiverSecretQuery
|
||||
: EnvelopeReceiverQueryBase<ReadEnvelopeQuery, ReadReceiverQuery>,
|
||||
IRequest<EnvelopeReceiverSecretDto?>;
|
||||
IRequest<EnvelopeReceiverSecretDto?>
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public ReadEnvelopeReceiverSecretQuery() { }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="envelopeId"></param>
|
||||
/// <param name="receiverId"></param>
|
||||
public ReadEnvelopeReceiverSecretQuery(int envelopeId, int receiverId)
|
||||
{
|
||||
Envelope.Id = envelopeId;
|
||||
Receiver.Id = receiverId;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for dispatching <see cref="ReadEnvelopeReceiverSecretQuery"/> via <see cref="IMediator"/>.
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
using DigitalData.Core.Abstraction.Application.Repository;
|
||||
using EnvelopeGenerator.Application.Common.Dto.Receiver;
|
||||
using EnvelopeGenerator.Domain.Entities;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace EnvelopeGenerator.Application.EnvelopeReceivers.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// Reads recent receiver suggestions for envelope creation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Sender scope is assigned via chain method to avoid exposing username mutation from API query binding.
|
||||
/// </remarks>
|
||||
public record ReadRecentReceiverSuggestionsQuery : IRequest<IReadOnlyList<RecentReceiverSuggestionDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// Sender username used for scoping suggestion results.
|
||||
/// </summary>
|
||||
internal string? Username { get; private init; }
|
||||
|
||||
/// <summary>
|
||||
/// Desired number of results.
|
||||
/// </summary>
|
||||
public int? Take { get; init; } = null;
|
||||
|
||||
/// <summary>
|
||||
/// Returns a new query instance scoped to the specified sender username.
|
||||
/// </summary>
|
||||
/// <param name="username">Sender username from authenticated claims.</param>
|
||||
/// <returns>Scoped query instance.</returns>
|
||||
public ReadRecentReceiverSuggestionsQuery ForSender(string username)
|
||||
{
|
||||
return this with { Username = username };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="ReadRecentReceiverSuggestionsQuery"/>.
|
||||
/// </summary>
|
||||
public class ReadRecentReceiverSuggestionsQueryHandler(IRepository<EnvelopeReceiver> repository)
|
||||
: IRequestHandler<ReadRecentReceiverSuggestionsQuery, IReadOnlyList<RecentReceiverSuggestionDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves distinct recent receiver suggestions grouped by receiver email.
|
||||
/// </summary>
|
||||
/// <param name="request">Suggestion query.</param>
|
||||
/// <param name="cancel">Cancellation token.</param>
|
||||
/// <returns>Ordered list of recent receiver suggestions.</returns>
|
||||
public async Task<IReadOnlyList<RecentReceiverSuggestionDto>> Handle(ReadRecentReceiverSuggestionsQuery request, CancellationToken cancel)
|
||||
{
|
||||
var query = repository.Query
|
||||
.AsNoTracking()
|
||||
.Where(er => er.Receiver != null);
|
||||
|
||||
if (request.Username is not null)
|
||||
query = query.Where(er => er.Envelope != null && er.Envelope.User.Username == request.Username);
|
||||
|
||||
// 1) For each email, compute the latest usage timestamp.
|
||||
var latestPerEmail = query
|
||||
.GroupBy(er => er.Receiver!.EmailAddress)
|
||||
.Select(g => new
|
||||
{
|
||||
EmailAddress = g.Key,
|
||||
LastUsedAt = g.Max(er => er.AddedWhen),
|
||||
});
|
||||
|
||||
// 2) Join back to rows that match the latest timestamp per email.
|
||||
var latestRows =
|
||||
from er in query
|
||||
join latest in latestPerEmail
|
||||
on new { EmailAddress = er.Receiver!.EmailAddress, LastUsedAt = er.AddedWhen }
|
||||
equals new { latest.EmailAddress, latest.LastUsedAt }
|
||||
select new
|
||||
{
|
||||
EmailAddress = er.Receiver!.EmailAddress,
|
||||
FullName = er.Name,
|
||||
LastUsedAt = er.AddedWhen,
|
||||
};
|
||||
|
||||
// 3) Tie-break: if multiple rows share the same latest timestamp for an email,
|
||||
// pick a deterministic name via Max(FullName), then order by most recent usage.
|
||||
var suggestions = latestRows
|
||||
.GroupBy(x => new { x.EmailAddress, x.LastUsedAt })
|
||||
.Select(g => new RecentReceiverSuggestionDto
|
||||
{
|
||||
EmailAddress = g.Key.EmailAddress,
|
||||
FullName = g.Max(x => x.FullName) ?? string.Empty,
|
||||
LastUsedAt = g.Key.LastUsedAt,
|
||||
})
|
||||
.OrderByDescending(x => x.LastUsedAt)
|
||||
.ThenBy(x => x.EmailAddress);
|
||||
|
||||
return request.Take is int take
|
||||
? await suggestions.Take(take).ToListAsync(cancel)
|
||||
: await suggestions.ToListAsync(cancel);
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ public class ReadEnvelopeTypesQueryHandler : IRequestHandler<ReadEnvelopeTypesQu
|
||||
Title = t.Title ?? string.Empty,
|
||||
Language = string.IsNullOrWhiteSpace(t.Language) ? "de-DE" : t.Language,
|
||||
ExpiresDays = t.ExpiresDays,
|
||||
CertificationType = (int?)t.CertificationType,
|
||||
CertificationType = t.CertificationType,
|
||||
UseAccessCode = t.UseAccessCode,
|
||||
FinalEmailToCreator = (int?)t.FinalEmailToCreator,
|
||||
FinalEmailToReceivers = (int?)t.FinalEmailToReceivers,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor;
|
||||
using EnvelopeGenerator.Domain.Constants;
|
||||
using MediatR;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
@@ -35,7 +36,7 @@ public record CreateEnvelopeCommand : IRequest<EnvelopeDto?>
|
||||
/// <summary>
|
||||
/// Zertifizierungstyp fuer den Signaturprozess.
|
||||
/// </summary>
|
||||
public int? CertificationType { get; set; }
|
||||
public CertificationType? CertificationType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Finale E-Mail-Einstellung fuer den Ersteller.
|
||||
@@ -129,10 +130,27 @@ public record CreateEnvelopeCommand : IRequest<EnvelopeDto?>
|
||||
public bool UseSQLExecutor { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="DataAsBase64"></param>
|
||||
public record CreateEnvelopeDocumentDto(string DataAsBase64);
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="X"></param>
|
||||
/// <param name="Y"></param>
|
||||
/// <param name="Page"></param>
|
||||
public record CreateEnvelopeFieldDto(double X, double Y, int Page);
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="EmailAddress"></param>
|
||||
/// <param name="Name"></param>
|
||||
/// <param name="PhoneNumber"></param>
|
||||
/// <param name="Fields"></param>
|
||||
public record CreateEnvelopeReceiverDto(
|
||||
string EmailAddress,
|
||||
string? Name,
|
||||
|
||||
@@ -1,68 +1,66 @@
|
||||
using AutoMapper;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using DigitalData.Core.Abstraction.Application.Repository;
|
||||
using DigitalData.EmailProfilerDispatcher.Abstraction.Entities;
|
||||
using DigitalData.Core.Exceptions;
|
||||
using EnvelopeGenerator.Domain.Entities;
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
using DigitalData.EmailProfilerDispatcher.Abstraction.Entities;
|
||||
using DigitalData.UserManager.Domain.Entities;
|
||||
using EnvelopeGenerator.Application.Common.Configurations;
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
using EnvelopeGenerator.Application.Common.Extensions;
|
||||
using EnvelopeGenerator.Application.Common.Interfaces;
|
||||
using EnvelopeGenerator.Application.Common.Interfaces.Services;
|
||||
using EnvelopeGenerator.Application.Common.Interfaces.Services.Notification;
|
||||
using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor;
|
||||
using EnvelopeGenerator.Application.Histories.Commands;
|
||||
using EnvelopeGenerator.Domain.Constants;
|
||||
using EnvelopeGenerator.Domain;
|
||||
using EnvelopeGenerator.Domain.Constants;
|
||||
using EnvelopeGenerator.Domain.Entities;
|
||||
using EnvelopeGenerator.Domain.Interfaces;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace EnvelopeGenerator.Application.Envelopes.Commands;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeCommand, EnvelopeDto?>
|
||||
/// <param name="Provider"></param>
|
||||
/// <param name="Mapper"></param>
|
||||
/// <param name="EnvNtfService"></param>
|
||||
/// <param name="ReqCntxt"></param>
|
||||
/// <param name="Logger"></param>
|
||||
public class CreateEnvelopeCommandHandler(IServiceProvider Provider, IMapper Mapper, IEnvelopeNotificationService EnvNtfService, IRequestContext ReqCntxt, ILogger<CreateEnvelopeCommandHandler> Logger) : IRequestHandler<CreateEnvelopeCommand, EnvelopeDto?>
|
||||
{
|
||||
private readonly IServiceProvider _provider;
|
||||
private IEnvelopeExecutor Executor => Provider.GetRequiredService<IEnvelopeExecutor>();
|
||||
|
||||
private IEnvelopeExecutor Executor => _provider.GetRequiredService<IEnvelopeExecutor>();
|
||||
private IRepository<Envelope> Repository => Provider.GetRequiredService<IRepository<Envelope>>();
|
||||
|
||||
private IRepository<Envelope> Repository => _provider.GetRequiredService<IRepository<Envelope>>();
|
||||
private IRepository<Document> DocumentRepository => Provider.GetRequiredService<IRepository<Document>>();
|
||||
|
||||
private IRepository<Document> DocumentRepository => _provider.GetRequiredService<IRepository<Document>>();
|
||||
private IRepository<EnvelopeReceiver> EnvelopeReceiverRepository => Provider.GetRequiredService<IRepository<EnvelopeReceiver>>();
|
||||
|
||||
private IRepository<EnvelopeReceiver> EnvelopeReceiverRepository => _provider.GetRequiredService<IRepository<EnvelopeReceiver>>();
|
||||
private IRepository<EnvelopeType> EnvelopeTypeRepository => Provider.GetRequiredService<IRepository<EnvelopeType>>();
|
||||
|
||||
private IRepository<EnvelopeType> EnvelopeTypeRepository => _provider.GetRequiredService<IRepository<EnvelopeType>>();
|
||||
private IRepository<Receiver> ReceiverRepository => Provider.GetRequiredService<IRepository<Receiver>>();
|
||||
|
||||
private IRepository<Receiver> ReceiverRepository => _provider.GetRequiredService<IRepository<Receiver>>();
|
||||
private IRepository<DocReceiverElement> DocReceiverElementRepository => Provider.GetRequiredService<IRepository<DocReceiverElement>>();
|
||||
|
||||
private IRepository<DocReceiverElement> DocReceiverElementRepository => _provider.GetRequiredService<IRepository<DocReceiverElement>>();
|
||||
private IRepository<EmailTemplate> EmailTemplateRepository => Provider.GetRequiredService<IRepository<EmailTemplate>>();
|
||||
|
||||
private IRepository<EmailTemplate> EmailTemplateRepository => _provider.GetRequiredService<IRepository<EmailTemplate>>();
|
||||
private IRepository<EmailOut> EmailOutRepository => Provider.GetRequiredService<IRepository<EmailOut>>();
|
||||
|
||||
private IRepository<EmailOut> EmailOutRepository => _provider.GetRequiredService<IRepository<EmailOut>>();
|
||||
private MailParams MailParams => Provider.GetRequiredService<IOptions<MailParams>>().Value;
|
||||
|
||||
private MailParams MailParams => _provider.GetRequiredService<IOptions<MailParams>>().Value;
|
||||
private DispatcherParams DispatcherParams => Provider.GetRequiredService<IOptions<DispatcherParams>>().Value;
|
||||
|
||||
private DispatcherParams DispatcherParams => _provider.GetRequiredService<IOptions<DispatcherParams>>().Value;
|
||||
private SignatureFieldParams SignatureFieldParams => Provider.GetRequiredService<IOptionsMonitor<SignatureFieldParams>>().CurrentValue;
|
||||
|
||||
private IConfigService ConfigService => _provider.GetRequiredService<IConfigService>();
|
||||
[Obsolete("Use IConfigService instead.")]
|
||||
private IConfigService ConfigService => Provider.GetRequiredService<IConfigService>();
|
||||
|
||||
private IMediator Mediator => _provider.GetRequiredService<IMediator>();
|
||||
|
||||
private readonly IMapper _mapper;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="provider"></param>
|
||||
/// <param name="mapper"></param>
|
||||
public CreateEnvelopeCommandHandler(IServiceProvider provider, IMapper mapper)
|
||||
{
|
||||
_provider = provider;
|
||||
_mapper = mapper;
|
||||
}
|
||||
private IMediator Mediator => Provider.GetRequiredService<IMediator>();
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
@@ -230,7 +228,7 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
|
||||
.ThenInclude(er => er.Receiver)
|
||||
.FirstAsync(e => e.Id == envelope.Id, cancel);
|
||||
|
||||
return _mapper.Map<EnvelopeDto>(envelope);
|
||||
return Mapper.Map<EnvelopeDto>(envelope);
|
||||
}
|
||||
|
||||
private async Task<EnvelopeSettings> ResolveEnvelopeSettingsAsync(CreateEnvelopeCommand request, Envelope? existing, CancellationToken cancel)
|
||||
@@ -268,9 +266,9 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
|
||||
Language: ResolveLanguage(request.Language, selectedType?.Language, existing?.Language),
|
||||
UseAccessCode: useAccessCode,
|
||||
CertificationType: request.CertificationType
|
||||
?? (int?)selectedType?.CertificationType
|
||||
?? selectedType?.CertificationType
|
||||
?? existing?.CertificationType
|
||||
?? (int)CertificationType.AdvancedElectronicSignature,
|
||||
?? CertificationType.AdvancedElectronicSignature,
|
||||
FinalEmailToCreator: request.FinalEmailToCreator
|
||||
?? (int?)selectedType?.FinalEmailToCreator
|
||||
?? existing?.FinalEmailToCreator
|
||||
@@ -299,7 +297,7 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
|
||||
ExpiresWarningWhen: expiresWarningWhenDays > 0 ? DateTime.Now.AddDays(expiresWarningWhenDays) : null,
|
||||
ContractType: existing?.ContractType
|
||||
?? selectedType?.ContractType
|
||||
?? (int)ContractType.Contract);
|
||||
?? ContractType.Contract);
|
||||
}
|
||||
|
||||
private static string ResolveLanguage(string? preferred, string? fallback1, string? fallback2)
|
||||
@@ -320,7 +318,7 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
|
||||
int? EnvelopeTypeId,
|
||||
string Language,
|
||||
bool UseAccessCode,
|
||||
int CertificationType,
|
||||
CertificationType CertificationType,
|
||||
int FinalEmailToCreator,
|
||||
int FinalEmailToReceivers,
|
||||
bool SendReminderEmails,
|
||||
@@ -330,7 +328,7 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
|
||||
int ExpiresWarningWhenDays,
|
||||
DateTime? ExpiresWhen,
|
||||
DateTime? ExpiresWarningWhen,
|
||||
int ContractType);
|
||||
ContractType ContractType);
|
||||
|
||||
private async Task<string> ResolveSenderReferenceAsync(Envelope envelope, CancellationToken cancel)
|
||||
{
|
||||
@@ -444,8 +442,8 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
|
||||
ElementType = 1,
|
||||
X = field.X,
|
||||
Y = field.Y,
|
||||
Width = 1.77,
|
||||
Height = 1.96,
|
||||
Width = SignatureFieldParams.Width,
|
||||
Height = SignatureFieldParams.Height,
|
||||
Page = field.Page,
|
||||
Required = true,
|
||||
ReadOnly = false,
|
||||
@@ -529,15 +527,15 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
|
||||
if (!hasDocument)
|
||||
throw new BadRequestException("Cannot send envelope without a document.");
|
||||
|
||||
var links = await EnvelopeReceiverRepository.Query
|
||||
var envRcvs = await EnvelopeReceiverRepository.Query
|
||||
.Include(er => er.Receiver)
|
||||
.Where(er => er.EnvelopeId == envelope.Id)
|
||||
.ToListAsync(cancel);
|
||||
|
||||
if (links.Count == 0)
|
||||
if (envRcvs.Count == 0)
|
||||
throw new BadRequestException("Cannot send envelope without receivers.");
|
||||
|
||||
var receiverIds = links.Select(l => l.ReceiverId).Distinct().ToList();
|
||||
var receiverIds = envRcvs.Select(l => l.ReceiverId).Distinct().ToList();
|
||||
var documentIds = await DocumentRepository.Query
|
||||
.Where(d => d.EnvelopeId == envelope.Id)
|
||||
.Select(d => d.Id)
|
||||
@@ -550,7 +548,7 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
|
||||
.Select(g => new { ReceiverId = g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(x => x.ReceiverId, x => x.Count, cancel);
|
||||
|
||||
var receiversWithoutFields = links
|
||||
var receiversWithoutFields = envRcvs
|
||||
.Where(link => !receiverFieldCounts.TryGetValue(link.ReceiverId, out var count) || count <= 0)
|
||||
.Select(link => link.Name)
|
||||
.ToList();
|
||||
@@ -574,70 +572,39 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
|
||||
Status = EnvelopeStatus.EnvelopeQueued
|
||||
}, cancel);
|
||||
|
||||
await QueueInvitationEmailsAsync(envelope, links, userReference, cancel);
|
||||
await SendInvitationEmailsAsync(envelope, envRcvs, cancel);
|
||||
|
||||
envelope.Status = EnvelopeStatus.EnvelopeQueued;
|
||||
}
|
||||
|
||||
private async Task QueueInvitationEmailsAsync(
|
||||
private async Task SendInvitationEmailsAsync(
|
||||
Envelope envelope,
|
||||
IReadOnlyCollection<EnvelopeReceiver> links,
|
||||
string senderReference,
|
||||
IReadOnlyCollection<EnvelopeReceiver> envRcvs,
|
||||
CancellationToken cancel)
|
||||
{
|
||||
var template = await EmailTemplateRepository.Query
|
||||
.SingleOrDefaultAsync(x => x.Name == EmailTemplateType.DocumentReceived.ToString(), cancel)
|
||||
?? throw new InvalidOperationException($"Email template not found: {EmailTemplateType.DocumentReceived}");
|
||||
|
||||
var signatureHost = await ConfigService.ReadDefaultSignatureHost();
|
||||
var senderName = senderReference;
|
||||
|
||||
foreach (var link in links)
|
||||
foreach (var er in envRcvs)
|
||||
{
|
||||
var receiver = link.Receiver;
|
||||
if (receiver is null || string.IsNullOrWhiteSpace(receiver.EmailAddress) || string.IsNullOrWhiteSpace(receiver.Signature))
|
||||
continue;
|
||||
|
||||
var envelopeKey = (envelope.Uuid, receiver.Signature).ToEnvelopeKey();
|
||||
var documentLink = $"{signatureHost.TrimEnd('/')}/envelope/login/{Uri.EscapeDataString(envelopeKey)}";
|
||||
|
||||
var placeholders = new Dictionary<string, string>(MailParams.Placeholders)
|
||||
var advancedSignature = envelope.CertificationType == CertificationType.AdvancedElectronicSignature;
|
||||
var origin = new Uri(ReqCntxt.Origin ?? throw new InvalidOperationException($"The request origin is missing in {nameof(CreateEnvelopeCommandHandler)}."));
|
||||
try
|
||||
{
|
||||
["[NAME_RECEIVER]"] = link.Name ?? string.Empty,
|
||||
["[NAME_SENDER]"] = senderName ?? string.Empty,
|
||||
["[DOCUMENT_TITLE]"] = envelope.Title ?? string.Empty,
|
||||
["[MESSAGE]"] = envelope.Message ?? string.Empty,
|
||||
["[LINK_TO_DOCUMENT]"] = documentLink,
|
||||
["[LINK_TO_DOCUMENT_TEXT]"] = documentLink[..Math.Min(40, documentLink.Length)] + ".."
|
||||
};
|
||||
|
||||
var subject = ReplacePlaceHolders(template.Subject, placeholders);
|
||||
var body = ReplacePlaceHolders(template.Body, placeholders);
|
||||
|
||||
var emailOut = new EmailOut
|
||||
await EnvNtfService.Notify(er.Receiver.EmailAddress, new DocumentReceivedNotification
|
||||
{
|
||||
AdvancedSignature = advancedSignature,
|
||||
DocumentTitle = envelope.Title ?? string.Empty,
|
||||
NameReceiver = er.Name ?? string.Empty,
|
||||
EnvelopeUuid = envelope.Uuid,
|
||||
IsReadAndConfirm = envelope.IsReadAndConfirm(),
|
||||
Message = envelope.Message ?? string.Empty,
|
||||
NameSender = $"{envelope.User.Prename} {envelope.User.Name}",
|
||||
Origin = origin,
|
||||
ReceiverSignature = er.Receiver.Signature,
|
||||
}, cancel);
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
EmailAddress = receiver.EmailAddress,
|
||||
EmailSubj = subject,
|
||||
EmailBody = body,
|
||||
AddedWhen = DateTime.Now,
|
||||
AddedWho = DispatcherParams.AddedWho,
|
||||
SendingProfile = DispatcherParams.SendingProfile,
|
||||
ReminderTypeId = DispatcherParams.ReminderTypeId,
|
||||
EmailAttmt1 = DispatcherParams.EmailAttmt1,
|
||||
WfId = (int)EnvelopeStatus.MessageInvitationSent,
|
||||
ReferenceString = receiver.EmailAddress,
|
||||
ReferenceId = link.ReceiverId
|
||||
};
|
||||
|
||||
await EmailOutRepository.CreateAsync(emailOut, cancel);
|
||||
Logger.LogError(ex, "Failed to send invitation email for receiver {emailAddress} in envelope {envelopeId}.", er.Receiver.EmailAddress, envelope.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReplacePlaceHolders(string text, Dictionary<string, string> placeHolders)
|
||||
{
|
||||
foreach (var placeHolder in placeHolders)
|
||||
text = text.Replace(placeHolder.Key, placeHolder.Value);
|
||||
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using AutoMapper;
|
||||
using EnvelopeGenerator.Application.EnvelopeReceivers.Commands;
|
||||
using EnvelopeGenerator.Application.EnvelopeReceivers.Commands.CreateEnvelopeReceiver;
|
||||
using EnvelopeGenerator.Application.Envelopes.Commands;
|
||||
using EnvelopeGenerator.Domain.Entities;
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
using EnvelopeGenerator.Domain.Constants;
|
||||
using EnvelopeGenerator.Application.Common.Query;
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver;
|
||||
using EnvelopeGenerator.Application.Common.Dto.History;
|
||||
using EnvelopeGenerator.Application.Common.Dto.Receiver;
|
||||
using AutoMapper;
|
||||
using DigitalData.Core.Abstraction.Application.Repository;
|
||||
using EnvelopeGenerator.Domain.Entities;
|
||||
@@ -14,6 +17,11 @@ namespace EnvelopeGenerator.Application.Envelopes.Queries;
|
||||
/// </summary>
|
||||
public record ReadEnvelopeQuery : EnvelopeQueryBase, IRequest<IEnumerable<EnvelopeDto>>
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public bool IncludeDocAndDocRes { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
@@ -148,6 +156,86 @@ public class ReadEnvelopeQueryHandler : IRequestHandler<ReadEnvelopeQuery, IEnum
|
||||
if (request is { OnlyCompleted: true })
|
||||
query = query.Where(e => Status.Completed.Contains(e.Status));
|
||||
|
||||
if (!request.IncludeDocAndDocRes)
|
||||
{
|
||||
return await query
|
||||
.Select(e => new EnvelopeDto
|
||||
{
|
||||
Id = e.Id,
|
||||
UserId = e.UserId,
|
||||
Status = e.Status,
|
||||
StatusName = string.Empty,
|
||||
Uuid = e.Uuid,
|
||||
Message = e.Message,
|
||||
AddedWhen = e.AddedWhen,
|
||||
ChangedWhen = e.ChangedWhen,
|
||||
Title = e.Title ?? string.Empty,
|
||||
ContractType = e.ContractType,
|
||||
Language = e.Language,
|
||||
EnvelopeTypeId = e.EnvelopeTypeId,
|
||||
CertificationType = e.CertificationType,
|
||||
FinalEmailToCreator = e.FinalEmailToCreator,
|
||||
FinalEmailToReceivers = e.FinalEmailToReceivers,
|
||||
SendReminderEmails = e.SendReminderEmails,
|
||||
FirstReminderDays = e.FirstReminderDays,
|
||||
ReminderIntervalDays = e.ReminderIntervalDays,
|
||||
ExpiresWhenDays = e.ExpiresWhenDays,
|
||||
ExpiresWarningWhenDays = e.ExpiresWarningWhenDays,
|
||||
UseAccessCode = e.UseAccessCode ?? true,
|
||||
TFAEnabled = e.TfaEnabled ?? false,
|
||||
EnvelopeTypeTitle = e.Type != null ? e.Type.Title : null,
|
||||
IsAlreadySent = false,
|
||||
DocResult = null,
|
||||
Documents = null,
|
||||
EnvelopeReceivers = e.EnvelopeReceivers.Select(er => new EnvelopeReceiverDto
|
||||
{
|
||||
EnvelopeId = er.EnvelopeId,
|
||||
ReceiverId = er.ReceiverId,
|
||||
Sequence = er.Sequence,
|
||||
Name = er.Name,
|
||||
JobTitle = er.JobTitle,
|
||||
CompanyName = er.CompanyName,
|
||||
PrivateMessage = er.PrivateMessage,
|
||||
AccessCode = er.AccessCode,
|
||||
AddedWhen = er.AddedWhen,
|
||||
ChangedWhen = er.ChangedWhen,
|
||||
HasPhoneNumber = er.HasPhoneNumber,
|
||||
Receiver = er.Receiver == null
|
||||
? null
|
||||
: new ReceiverDto
|
||||
{
|
||||
Id = er.Receiver.Id,
|
||||
EmailAddress = er.Receiver.EmailAddress,
|
||||
Signature = er.Receiver.Signature,
|
||||
AddedWhen = er.Receiver.AddedWhen,
|
||||
TfaRegDeadline = er.Receiver.TfaRegDeadline
|
||||
}
|
||||
}),
|
||||
Histories = e.Histories.Select(h => new HistoryDto
|
||||
{
|
||||
Id = h.Id,
|
||||
EnvelopeId = h.EnvelopeId,
|
||||
UserReference = h.UserReference,
|
||||
Status = h.Status,
|
||||
StatusName = null,
|
||||
AddedWhen = h.AddedWhen,
|
||||
ActionDate = h.ActionDate,
|
||||
Comment = h.Comment,
|
||||
Receiver = h.Receiver == null
|
||||
? null
|
||||
: new ReceiverDto
|
||||
{
|
||||
Id = h.Receiver.Id,
|
||||
EmailAddress = h.Receiver.EmailAddress,
|
||||
Signature = h.Receiver.Signature,
|
||||
AddedWhen = h.Receiver.AddedWhen,
|
||||
TfaRegDeadline = h.Receiver.TfaRegDeadline
|
||||
}
|
||||
})
|
||||
})
|
||||
.ToListAsync(cancel);
|
||||
}
|
||||
|
||||
var envelopes = await query
|
||||
.Include(e => e.Type)
|
||||
.Include(e => e.EnvelopeReceivers).ThenInclude(er => er.Receiver)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
using AutoMapper;
|
||||
using DigitalData.Core.Abstraction.Application.Repository;
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
using EnvelopeGenerator.Domain.Entities;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace EnvelopeGenerator.Application.Envelopes.Queries;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public record ReadSignedEnvelopeQuery : IRequest<IEnumerable<EnvelopeDto>>
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public bool? Burned { get; init; } = null;
|
||||
|
||||
internal int? UserId { get; private set; } = null;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="userId"></param>
|
||||
/// <returns></returns>
|
||||
public ReadSignedEnvelopeQuery Authorized(int userId)
|
||||
{
|
||||
UserId = userId;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="Repo"></param>
|
||||
/// <param name="Mapper"></param>
|
||||
public class ReadSignedEnvelopeQueryHandler(IRepository<Envelope> Repo, IMapper Mapper) : IRequestHandler<ReadSignedEnvelopeQuery, IEnumerable<EnvelopeDto>>
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="request"></param>
|
||||
/// <param name="cancel"></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="NotImplementedException"></exception>
|
||||
public async Task<IEnumerable<EnvelopeDto>> Handle(ReadSignedEnvelopeQuery request, CancellationToken cancel)
|
||||
{
|
||||
var eQuery = Repo.Query.Where(e => e.Status == Domain.Constants.EnvelopeStatus.EnvelopeCompletelySigned);
|
||||
|
||||
if (request.Burned is bool burned)
|
||||
eQuery = burned ? eQuery.Where(e => e.DocResult != null) : eQuery.Where(e => e.DocResult == null);
|
||||
|
||||
if(request.UserId is int userId)
|
||||
eQuery = eQuery.Where(e => e.UserId == userId);
|
||||
|
||||
var envelopes = await eQuery.ToListAsync(cancel);
|
||||
|
||||
return Mapper.Map<IEnumerable<EnvelopeDto>>(envelopes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using DigitalData.Core.Abstraction.Application.Repository;
|
||||
using DigitalData.Core.Exceptions;
|
||||
using EnvelopeGenerator.Application.Common.Interfaces.Services.Notification;
|
||||
using EnvelopeGenerator.Application.Common.Query;
|
||||
using EnvelopeGenerator.Domain.Constants;
|
||||
using EnvelopeGenerator.Domain.Entities;
|
||||
using EnvelopeGenerator.Domain.Interfaces;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace EnvelopeGenerator.Application.PdfSigning;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public record FinalizeEnvelopeCommand : EnvelopeQueryBase, IRequest
|
||||
{
|
||||
internal int? UserId { get; private set; } = null;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="userId"></param>
|
||||
/// <returns></returns>
|
||||
public FinalizeEnvelopeCommand Authorized(int userId)
|
||||
{
|
||||
UserId = userId;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public bool BurnedAllowed { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public bool SaveDocResult { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="Repo"></param>
|
||||
/// <param name="Sender"></param>
|
||||
/// <param name="NtfServer"></param>
|
||||
public class FinalizeEnvelopeCommandHandler(IRepository<Envelope> Repo, ISender Sender, IEnvelopeNotificationService NtfServer) : IRequestHandler<FinalizeEnvelopeCommand>
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="request"></param>
|
||||
/// <param name="cancel"></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="NotFoundException"></exception>
|
||||
public async Task Handle(FinalizeEnvelopeCommand request, CancellationToken cancel)
|
||||
{
|
||||
var envelopeQuery = Repo.Query;
|
||||
|
||||
if (request.Id is int id)
|
||||
envelopeQuery = envelopeQuery.Where(e => e.Id == id);
|
||||
else if (request.Uuid is string uuid)
|
||||
envelopeQuery = envelopeQuery.Where(e => e.Uuid == uuid);
|
||||
|
||||
var envelope = await envelopeQuery
|
||||
.Include(e => e.EnvelopeReceivers).ThenInclude(er => er.Receiver)
|
||||
.Include(e => e.Documents)
|
||||
.SingleOrDefaultAsync(cancel)
|
||||
?? throw new NotFoundException($"Envelope is not found ID: {request.Id} UUID: {request.Uuid}");
|
||||
|
||||
if(request.UserId is int userId && userId != envelope.UserId)
|
||||
throw new InvalidOperationException($"Envelope is not owned by user ID: {request.Id} UUID: {request.Uuid}");
|
||||
|
||||
// already burned
|
||||
if (!request.BurnedAllowed && envelope.IsBurned())
|
||||
throw new InvalidOperationException($"Envelope is already burned ID: {request.Id} UUID: {request.Uuid}");
|
||||
|
||||
// envelope is not completely signed
|
||||
if (envelope.Status!= EnvelopeStatus.EnvelopeCompletelySigned)
|
||||
throw new InvalidOperationException($"Envelope status is not completely signed ID: {request.Id} UUID: {request.Uuid}");
|
||||
|
||||
using var docStream = await Sender.Send(new SignPdfByEnvelopeCommand()
|
||||
{
|
||||
Envelope = envelope
|
||||
}, cancel);
|
||||
|
||||
if (docStream.CanSeek)
|
||||
docStream.Position = 0;
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
docStream.CopyTo(ms);
|
||||
byte[] doc = ms.ToArray();
|
||||
|
||||
foreach (var er in envelope.EnvelopeReceivers)
|
||||
{
|
||||
await NtfServer.Notify(er.Receiver.EmailAddress, new DocumentSignedNotification()
|
||||
{
|
||||
DocumentTitle = envelope.Title,
|
||||
NameReceiver = er.Name,
|
||||
Attachments = [($"unterzeichnetes-dokument.pdf", doc)],
|
||||
IsReadAndConfirm = envelope.IsReadAndConfirm(),
|
||||
}, cancel);
|
||||
}
|
||||
|
||||
if (request.SaveDocResult)
|
||||
await Repo.UpdateAsync(e => e.DocResult = doc, e => e.Id == envelope.Id, cancel);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using DigitalData.Core.Exceptions;
|
||||
using EnvelopeGenerator.Application.Common.Configurations;
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
using EnvelopeGenerator.Application.Common.Enums;
|
||||
using EnvelopeGenerator.Application.Common.Interfaces.Doc;
|
||||
using EnvelopeGenerator.Application.Common.Interfaces.Services;
|
||||
using EnvelopeGenerator.Application.DocReceiverElements.Queries;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace EnvelopeGenerator.Application.PdfSigning;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public record SignPdfByElementCommand : IRequest<Stream>
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public DocReceiverElementDto? Element { get; set; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public int? ElementId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Custom document
|
||||
/// </summary>
|
||||
public Stream? Document { get; set; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public string? Phone { get; set; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public string? Reason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public PdfSignatureCertificationLevel CertificationLevel { get; init; } = PdfSignatureCertificationLevel.NoChangesAllowed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="sender"></param>
|
||||
/// <param name="signService"></param>
|
||||
/// <param name="imgRenderer"></param>
|
||||
/// <param name="options"></param>
|
||||
public class SignPdfCommandHandler(ISender sender, IPdfDigitalSignatureService signService, IPdfPageImageRenderer imgRenderer, IOptionsMonitor<ESignParams> options) : IRequestHandler<SignPdfByElementCommand, Stream>
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="request"></param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<Stream> Handle(SignPdfByElementCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// ensure element
|
||||
if (request.Element is null)
|
||||
{
|
||||
var elements = await sender.Send(new ReadDocReceiverElementQuery()
|
||||
{
|
||||
Id = request.ElementId!,
|
||||
IncludeDocument = request.Document is null
|
||||
}, cancellationToken);
|
||||
|
||||
request.Element = elements.FirstOrDefault() ?? throw new NotFoundException($"Element with ID {request.ElementId} not found."); ;
|
||||
}
|
||||
|
||||
// ensure document
|
||||
if(request.Document is null)
|
||||
{
|
||||
if (request.Element?.Document?.ByteData is not { Length: > 0 })
|
||||
throw new NotFoundException("Document data is missing or empty.");
|
||||
|
||||
request.Document = new MemoryStream(request.Element.Document.ByteData);
|
||||
}
|
||||
|
||||
ValidateAfterDataIsComplete(request);
|
||||
|
||||
// create signature image from element
|
||||
var img = imgRenderer.ComposeSignaturePreviewPng(request.Element);
|
||||
|
||||
// sign pdf
|
||||
var outputPdf = new MemoryStream();
|
||||
|
||||
await signService.SignPdfAsync(
|
||||
inputPdf: request.Document,
|
||||
outputPdf: outputPdf,
|
||||
options: new PdfSignOptionsDto
|
||||
{
|
||||
Name = request.Element.FullName!,
|
||||
Location = request.Element.Position!,
|
||||
Phone = request.Phone,
|
||||
Reason = request.Reason ?? options.CurrentValue.DefaultReason,
|
||||
Email = request.Element.Receiver!.EmailAddress,
|
||||
ApplicationName = options.CurrentValue.ApplicationName,
|
||||
DocumentElementId = request.Element.Id,
|
||||
|
||||
CertificationLevel = request.CertificationLevel,
|
||||
Appearance = new PdfSignatureAppearanceDto(
|
||||
PageNumber: request.Element.Page,
|
||||
Left: request.Element.Left,
|
||||
Bottom: request.Element.Y,
|
||||
Width: request.Element.Width,
|
||||
Height: request.Element.Height,
|
||||
CoordinateUnit: PdfSignatureCoordinateUnit.Inch,
|
||||
CoordinateOrigin: PdfSignatureCoordinateOrigin.TopLeft)
|
||||
},
|
||||
image: img,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
return outputPdf;
|
||||
}
|
||||
|
||||
private static void ValidateAfterDataIsComplete(SignPdfByElementCommand request)
|
||||
{
|
||||
if(request.Element!.FullName is null)
|
||||
throw new BadRequestException($"Document is not signed. FullName is required. Element ID: {request.Element.Id}");
|
||||
else if(request.Element.Position is null)
|
||||
throw new BadRequestException($"Document is not signed. Position is required. Element ID: {request.Element.Id}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace EnvelopeGenerator.Application.PdfSigning;
|
||||
|
||||
public class SignPdfByElementCommandValidator : AbstractValidator<SignPdfByElementCommand>
|
||||
{
|
||||
public SignPdfByElementCommandValidator()
|
||||
{
|
||||
RuleFor(x => x)
|
||||
.Must(x => (x.Element is not null) ^ x.ElementId.HasValue)
|
||||
.WithMessage("Exactly one of Element or ElementId must be provided.");
|
||||
|
||||
RuleFor(x => x.ElementId)
|
||||
.GreaterThan(0)
|
||||
.When(x => x.ElementId.HasValue)
|
||||
.WithMessage("ElementId must be greater than 0.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using DigitalData.Core.Abstraction.Application.Repository;
|
||||
using DigitalData.Core.Exceptions;
|
||||
using EnvelopeGenerator.Application.Common.Query;
|
||||
using EnvelopeGenerator.Domain.Entities;
|
||||
using EnvelopeGenerator.Domain.Exceptions;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace EnvelopeGenerator.Application.PdfSigning;
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public record SignPdfByEnvelopeCommand : EnvelopeQueryBase, IRequest<Stream>
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public int? ReceiverId { get; init; } = null;
|
||||
|
||||
internal int? UserId { get; private set; } = null;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="userId"></param>
|
||||
/// <returns></returns>
|
||||
public SignPdfByEnvelopeCommand Authorized(int userId)
|
||||
{
|
||||
UserId = userId;
|
||||
return this;
|
||||
}
|
||||
|
||||
internal Envelope? Envelope { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public class SignPdfByEnvelopeCommandHandler(IRepository<Envelope> Repo, ISender sender) : IRequestHandler<SignPdfByEnvelopeCommand, Stream>
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="request"></param>
|
||||
/// <param name="cancel"></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="NotImplementedException"></exception>
|
||||
public async Task<Stream> Handle(SignPdfByEnvelopeCommand request, CancellationToken cancel)
|
||||
{
|
||||
if (request.Envelope is null)
|
||||
{
|
||||
var envelopeQuery = Repo.Query;
|
||||
|
||||
if (request.Id is int id)
|
||||
envelopeQuery = envelopeQuery.Where(e => e.Id == id);
|
||||
else if (request.Uuid is string uuid)
|
||||
envelopeQuery = envelopeQuery.Where(e => e.Uuid == uuid);
|
||||
|
||||
request.Envelope = await envelopeQuery.Include(e => e.EnvelopeReceivers).Include(e => e.Documents).SingleOrDefaultAsync(cancel)
|
||||
?? throw new NotFoundException($"Envelope is not found ID: {request.Id} UUID: {request.Uuid}");
|
||||
}
|
||||
|
||||
var envelope = request.Envelope;
|
||||
|
||||
if (request.UserId is int userId && userId != envelope.UserId)
|
||||
throw new AuthorizationException($"Envelope is not authorized for user with Id: {request.UserId} Envelope ID: {request.Id} UUID: {request.Uuid}");
|
||||
|
||||
if (envelope.EnvelopeReceivers.Count == 0)
|
||||
throw new NotFoundException($"Envelope has no receivers to sign ID: {request.Id} UUID: {request.Uuid}");
|
||||
|
||||
if(envelope.Documents.FirstOrDefault() is not Document document || document.ByteData is not byte[] byteData || byteData.Length == 0)
|
||||
throw new NotFoundException($"Envelope has no documents to sign ID: {request.Id} UUID: {request.Uuid}");
|
||||
|
||||
Stream doc = new MemoryStream(byteData);
|
||||
|
||||
var eReceivers = request.ReceiverId is int receiverId
|
||||
? envelope.EnvelopeReceivers.Where(e => e.ReceiverId == receiverId)
|
||||
: envelope.EnvelopeReceivers;
|
||||
|
||||
foreach (var eReceiver in eReceivers)
|
||||
{
|
||||
var signedDoc = await sender.Send(new SignPdfByEnvelopeReceiverCommand()
|
||||
{
|
||||
EnvelopeId = eReceiver.EnvelopeId,
|
||||
ReceiverId = eReceiver.ReceiverId
|
||||
}, cancel);
|
||||
|
||||
await doc.DisposeAsync();
|
||||
doc = signedDoc;
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using AutoMapper;
|
||||
using DigitalData.Core.Abstraction.Application.Repository;
|
||||
using DigitalData.Core.Exceptions;
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
using EnvelopeGenerator.Application.Common.Enums;
|
||||
using EnvelopeGenerator.Application.Documents.Queries;
|
||||
using EnvelopeGenerator.Application.EnvelopeReceivers.Queries;
|
||||
using EnvelopeGenerator.Application.Envelopes.Queries;
|
||||
using EnvelopeGenerator.Domain.Entities;
|
||||
using EnvelopeGenerator.Domain.Exceptions;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace EnvelopeGenerator.Application.PdfSigning;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public record SignPdfByEnvelopeReceiverCommand : IRequest<Stream>
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public int EnvelopeId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public int ReceiverId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public Stream? Document { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public class SignPdfByEnvelopeReceiverCommandHandler(IRepository<DocReceiverElement> Repo, IMapper Mapper, ISender Sender) : IRequestHandler<SignPdfByEnvelopeReceiverCommand, Stream>
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="request"></param>
|
||||
/// <param name="cancel"></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="NotFoundException"></exception>
|
||||
public async Task<Stream> Handle(SignPdfByEnvelopeReceiverCommand request, CancellationToken cancel)
|
||||
{
|
||||
cancel.ThrowIfCancellationRequested();
|
||||
|
||||
// read elements
|
||||
var elements = await Repo.Where(el => el.ReceiverId == request.ReceiverId && el.Document.EnvelopeId == request.EnvelopeId)
|
||||
.Include(e => e.Receiver)
|
||||
.ToListAsync(cancel) ?? throw new NotFoundException($"No elements found for Envelope ID {request.EnvelopeId} and Receiver ID {request.ReceiverId}.");
|
||||
var elementDtos = Mapper.Map<List<DocReceiverElementDto>>(elements)
|
||||
.OrderBy(e => e.Page)
|
||||
.ThenBy(e => e.Id)
|
||||
.ToList();
|
||||
|
||||
// read secrets for phone number
|
||||
var erSecrets = await Sender.Send(new ReadEnvelopeReceiverSecretQuery()
|
||||
{
|
||||
Envelope = new() { Id = request.EnvelopeId },
|
||||
Receiver = new() { Id = request.ReceiverId }
|
||||
}, cancel) ?? throw new DataIntegrityException($"No secrets found for Envelope ID {request.EnvelopeId} and Receiver ID {request.ReceiverId}.");
|
||||
|
||||
// read document
|
||||
if(request.Document is null)
|
||||
{
|
||||
var unsignedDoc = await Sender.Send(new ReadDocumentQuery(EnvelopeId: request.EnvelopeId), cancel);
|
||||
var unsignedDocBytes = unsignedDoc.ByteData ?? throw new NotFoundException($"Document not found for Envelope ID {request.EnvelopeId} and Receiver ID {request.ReceiverId}.");
|
||||
request.Document = new MemoryStream(unsignedDocBytes);
|
||||
}
|
||||
|
||||
Stream doc = request.Document;
|
||||
|
||||
// sign document for each element
|
||||
foreach (var element in elementDtos)
|
||||
{
|
||||
var lastElement = element == elementDtos.Last();
|
||||
|
||||
var signedDoc = await Sender.Send(new SignPdfByElementCommand
|
||||
{
|
||||
Document = doc,
|
||||
Element = element,
|
||||
CertificationLevel = lastElement ? PdfSignatureCertificationLevel.NoChangesAllowed : PdfSignatureCertificationLevel.FillFormsAndAnnotate,
|
||||
Phone = erSecrets.PhoneNumber
|
||||
}, cancel);
|
||||
|
||||
await doc.DisposeAsync();
|
||||
doc = signedDoc;
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
}
|
||||
@@ -123,6 +123,9 @@
|
||||
<data name="Back" xml:space="preserve">
|
||||
<value>Zurück</value>
|
||||
</data>
|
||||
<data name="Next" xml:space="preserve">
|
||||
<value>Weiter</value>
|
||||
</data>
|
||||
<data name="AuthenticatorSetup_Prefix" xml:space="preserve">
|
||||
<value>Klicken Sie auf den</value>
|
||||
</data>
|
||||
@@ -564,6 +567,12 @@
|
||||
<data name="AddReceiver" xml:space="preserve">
|
||||
<value>Empfänger hinzufügen</value>
|
||||
</data>
|
||||
<data name="SenderWizard_RecentReceivers" xml:space="preserve">
|
||||
<value>Zuletzt verwendete Empfänger</value>
|
||||
</data>
|
||||
<data name="Skip" xml:space="preserve">
|
||||
<value>Überspringen</value>
|
||||
</data>
|
||||
<data name="DeleteReceiver" xml:space="preserve">
|
||||
<value>Empfänger löschen</value>
|
||||
</data>
|
||||
|
||||
@@ -123,6 +123,9 @@
|
||||
<data name="Back" xml:space="preserve">
|
||||
<value>Back</value>
|
||||
</data>
|
||||
<data name="Next" xml:space="preserve">
|
||||
<value>Next</value>
|
||||
</data>
|
||||
<data name="AuthenticatorSetup_Prefix" xml:space="preserve">
|
||||
<value>Click the</value>
|
||||
</data>
|
||||
@@ -564,6 +567,12 @@
|
||||
<data name="AddReceiver" xml:space="preserve">
|
||||
<value>Add Receiver</value>
|
||||
</data>
|
||||
<data name="SenderWizard_RecentReceivers" xml:space="preserve">
|
||||
<value>Recently used recipients</value>
|
||||
</data>
|
||||
<data name="Skip" xml:space="preserve">
|
||||
<value>Skip</value>
|
||||
</data>
|
||||
<data name="DeleteReceiver" xml:space="preserve">
|
||||
<value>Delete Receiver</value>
|
||||
</data>
|
||||
|
||||
@@ -123,6 +123,9 @@
|
||||
<data name="Back" xml:space="preserve">
|
||||
<value>Retour</value>
|
||||
</data>
|
||||
<data name="Next" xml:space="preserve">
|
||||
<value>Suivant</value>
|
||||
</data>
|
||||
<data name="AuthenticatorSetup_Prefix" xml:space="preserve">
|
||||
<value>Cliquez sur</value>
|
||||
</data>
|
||||
@@ -564,6 +567,12 @@
|
||||
<data name="AddReceiver" xml:space="preserve">
|
||||
<value>Ajouter un destinataire</value>
|
||||
</data>
|
||||
<data name="SenderWizard_RecentReceivers" xml:space="preserve">
|
||||
<value>Destinataires recents</value>
|
||||
</data>
|
||||
<data name="Skip" xml:space="preserve">
|
||||
<value>Passer</value>
|
||||
</data>
|
||||
<data name="DeleteReceiver" xml:space="preserve">
|
||||
<value>Supprimer le destinataire</value>
|
||||
</data>
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
{
|
||||
public enum CertificationType
|
||||
{
|
||||
AdvancedElectronicSignature = 1
|
||||
// ElectronicSignature = 1
|
||||
// QualifiedSignature = 2
|
||||
AdvancedElectronicSignature = 1,
|
||||
ElectronicSignature = 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ namespace EnvelopeGenerator.Domain.Constants
|
||||
{
|
||||
public static bool IsActive(this EnvelopeStatus status)
|
||||
{
|
||||
return status >= EnvelopeStatus.EnvelopeCreated && status < EnvelopeStatus.EnvelopePartlySigned;
|
||||
return status >= EnvelopeStatus.EnvelopeCreated && status <= EnvelopeStatus.EnvelopePartlySigned;
|
||||
}
|
||||
|
||||
public static bool IsCompleted(this EnvelopeStatus status)
|
||||
|
||||
@@ -11,7 +11,7 @@ using System.Collections.Generic;
|
||||
namespace EnvelopeGenerator.Domain.Entities
|
||||
{
|
||||
[Table("TBSIG_DOCUMENT_RECEIVER_ELEMENT", Schema = "dbo")]
|
||||
public class DocReceiverElement : IDocReceiverElement, IHasReceiver, IHasAddedWhen, IUpdateAuditable
|
||||
public class DocReceiverElement : IDocReceiverElement, ISignatureInkPayload, IHasReceiver, IHasAddedWhen, IUpdateAuditable
|
||||
{
|
||||
public DocReceiverElement()
|
||||
{
|
||||
|
||||
@@ -30,7 +30,7 @@ namespace EnvelopeGenerator.Domain.Entities
|
||||
SendReminderEmails = false;
|
||||
FirstReminderDays = 0;
|
||||
ReminderIntervalDays = 0;
|
||||
CertificationType = (int)Constants.CertificationType.AdvancedElectronicSignature;
|
||||
CertificationType = Constants.CertificationType.AdvancedElectronicSignature;
|
||||
UseAccessCode = false;
|
||||
Documents = Enumerable.Empty<Document>().ToList();
|
||||
Histories = Enumerable.Empty<History>().ToList();
|
||||
@@ -83,7 +83,7 @@ namespace EnvelopeGenerator.Domain.Entities
|
||||
public string Comment { get; set; }
|
||||
|
||||
[Column("CONTRACT_TYPE")]
|
||||
public int? ContractType { get; set; }
|
||||
public ContractType? ContractType { get; set; }
|
||||
|
||||
#if NETFRAMEWORK
|
||||
[NotMapped]
|
||||
@@ -111,7 +111,7 @@ namespace EnvelopeGenerator.Domain.Entities
|
||||
public bool ReadOnly => this.IsReadAndConfirm();
|
||||
|
||||
[Column("CERTIFICATION_TYPE")]
|
||||
public int? CertificationType { get; set; }
|
||||
public CertificationType? CertificationType { get; set; }
|
||||
|
||||
[Column("USE_ACCESS_CODE")]
|
||||
public bool? UseAccessCode { get; set; }
|
||||
@@ -129,7 +129,11 @@ namespace EnvelopeGenerator.Domain.Entities
|
||||
public int? ExpiresWarningWhenDays { get; set; }
|
||||
|
||||
[ForeignKey("UserId")]
|
||||
public User User { get; set; }
|
||||
public User
|
||||
#if nullable
|
||||
?
|
||||
#endif
|
||||
User { get; set; }
|
||||
|
||||
[Column("TFA_ENABLED")]
|
||||
public bool? TfaEnabled { get; set; }
|
||||
@@ -137,7 +141,6 @@ namespace EnvelopeGenerator.Domain.Entities
|
||||
= false;
|
||||
#endif
|
||||
|
||||
[NotMapped]
|
||||
[Column("DOC_RESULT")]
|
||||
public byte[]
|
||||
#if nullable
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using EnvelopeGenerator.Domain.Constants;
|
||||
|
||||
#if NETFRAMEWORK
|
||||
using System;
|
||||
#endif
|
||||
@@ -56,7 +58,7 @@ namespace EnvelopeGenerator.Domain.Entities
|
||||
public int? ReminderIntervalDays { get; set; }
|
||||
|
||||
[Column("CONTRACT_TYPE")]
|
||||
public int? ContractType { get; set; }
|
||||
public ContractType? ContractType { get; set; }
|
||||
|
||||
[NotMapped]
|
||||
public bool TFA_Enabled { get; set; } = false;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net462;net7.0;net8.0;net9.0</TargetFrameworks>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<AssemblyVersion>1.4.0</AssemblyVersion>
|
||||
<FileVersion>1.4.0</FileVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
#if NETFRAMEWORK
|
||||
using System;
|
||||
#endif
|
||||
|
||||
namespace EnvelopeGenerator.Domain.Exceptions
|
||||
{
|
||||
public class AuthorizationException : Exception
|
||||
{
|
||||
public AuthorizationException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public AuthorizationException()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
#if NETFRAMEWORK
|
||||
using System;
|
||||
#endif
|
||||
|
||||
namespace EnvelopeGenerator.Domain.Exceptions
|
||||
{
|
||||
public class DataIntegrityException : Exception
|
||||
{
|
||||
public DataIntegrityException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public DataIntegrityException() : base()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,12 @@
|
||||
public interface IEnvelope
|
||||
{
|
||||
int? EnvelopeTypeId { get; set; }
|
||||
|
||||
byte[]
|
||||
#if nullable
|
||||
?
|
||||
#endif
|
||||
DocResult { get; }
|
||||
}
|
||||
|
||||
public static class EnvelopeExtensions
|
||||
@@ -16,5 +22,10 @@
|
||||
{
|
||||
return envelope.EnvelopeTypeId != 2;
|
||||
}
|
||||
|
||||
public static bool IsBurned(this IEnvelope envelope)
|
||||
{
|
||||
return envelope.DocResult != null;
|
||||
}
|
||||
}
|
||||
}
|
||||
28
EnvelopeGenerator.Domain/Interfaces/ISignatureInkPayload.cs
Normal file
28
EnvelopeGenerator.Domain/Interfaces/ISignatureInkPayload.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
#if NETFRAMEWORK
|
||||
using System;
|
||||
#endif
|
||||
|
||||
namespace EnvelopeGenerator.Domain.Interfaces
|
||||
{
|
||||
public interface ISignatureInkPayload
|
||||
{
|
||||
#if nullable
|
||||
byte[]? Ink { get; }
|
||||
|
||||
string? FullName { get; }
|
||||
|
||||
string? Position { get; }
|
||||
|
||||
string? Place { get; }
|
||||
#else
|
||||
byte[] Ink { get; }
|
||||
|
||||
string FullName { get; }
|
||||
|
||||
string Position { get; }
|
||||
|
||||
string Place { get; }
|
||||
#endif
|
||||
DateTime? ChangedWhen { get; }
|
||||
}
|
||||
}
|
||||
@@ -276,4 +276,61 @@
|
||||
<data name="YesWithAttachment" xml:space="preserve">
|
||||
<value>Yes, with Attachment</value>
|
||||
</data>
|
||||
</root>
|
||||
<data name="ElectronicSignature" xml:space="preserve">
|
||||
<value>Electronic Signature</value>
|
||||
</data>
|
||||
<data name="EnvelopeType" xml:space="preserve">
|
||||
<value>Envelope Type</value>
|
||||
</data>
|
||||
<data name="Language" xml:space="preserve">
|
||||
<value>Language</value>
|
||||
</data>
|
||||
<data name="CertificationType" xml:space="preserve">
|
||||
<value>Certification</value>
|
||||
</data>
|
||||
<data name="TFAEnabled" xml:space="preserve">
|
||||
<value>2FA enabled</value>
|
||||
</data>
|
||||
<data name="UseAccessCode" xml:space="preserve">
|
||||
<value>Use Access Code</value>
|
||||
</data>
|
||||
<data name="FinalEmailToCreator" xml:space="preserve">
|
||||
<value>Final Email to Creator</value>
|
||||
</data>
|
||||
<data name="FinalEmailToReceivers" xml:space="preserve">
|
||||
<value>Final Email to Receivers</value>
|
||||
</data>
|
||||
<data name="SendReminderEmails" xml:space="preserve">
|
||||
<value>Send Reminder Emails</value>
|
||||
</data>
|
||||
<data name="FirstReminderDays" xml:space="preserve">
|
||||
<value>First Reminder (Days)</value>
|
||||
</data>
|
||||
<data name="ReminderIntervalDays" xml:space="preserve">
|
||||
<value>Reminder Interval (Days)</value>
|
||||
</data>
|
||||
<data name="ExpiresWhenDays" xml:space="preserve">
|
||||
<value>Expires After (Days)</value>
|
||||
</data>
|
||||
<data name="ExpiresWarningWhenDays" xml:space="preserve">
|
||||
<value>Expiry Warning (Days)</value>
|
||||
</data>
|
||||
<data name="SenderEditor_EnvelopeSettings" xml:space="preserve">
|
||||
<value>Envelope Settings</value>
|
||||
</data>
|
||||
<data name="SenderEditor_AdvancedOptional" xml:space="preserve">
|
||||
<value>Advanced</value>
|
||||
</data>
|
||||
<data name="SenderEditor_SelectTemplate" xml:space="preserve">
|
||||
<value>Select Template</value>
|
||||
</data>
|
||||
<data name="SenderEditor_Validation_ReminderInterval" xml:space="preserve">
|
||||
<value>Please enter a reminder interval greater than 0 days.</value>
|
||||
</data>
|
||||
<data name="SenderEditor_Validation_FirstReminder" xml:space="preserve">
|
||||
<value>Please enter valid days for the first reminder.</value>
|
||||
</data>
|
||||
<data name="SenderEditor_Validation_ExpiryWarning" xml:space="preserve">
|
||||
<value>The expiry warning cannot be after the expiry date.</value>
|
||||
</data>
|
||||
</root>
|
||||
|
||||
@@ -113,4 +113,61 @@
|
||||
<data name="MessageInvitationSent" xml:space="preserve">
|
||||
<value>Invitation envoyee</value>
|
||||
</data>
|
||||
<data name="ElectronicSignature" xml:space="preserve">
|
||||
<value>Signature electronique</value>
|
||||
</data>
|
||||
<data name="EnvelopeType" xml:space="preserve">
|
||||
<value>Type d'enveloppe</value>
|
||||
</data>
|
||||
<data name="Language" xml:space="preserve">
|
||||
<value>Langue</value>
|
||||
</data>
|
||||
<data name="CertificationType" xml:space="preserve">
|
||||
<value>Certification</value>
|
||||
</data>
|
||||
<data name="TFAEnabled" xml:space="preserve">
|
||||
<value>2FA active</value>
|
||||
</data>
|
||||
<data name="UseAccessCode" xml:space="preserve">
|
||||
<value>Utiliser un code d'acces</value>
|
||||
</data>
|
||||
<data name="FinalEmailToCreator" xml:space="preserve">
|
||||
<value>E-mail final au createur</value>
|
||||
</data>
|
||||
<data name="FinalEmailToReceivers" xml:space="preserve">
|
||||
<value>E-mail final aux destinataires</value>
|
||||
</data>
|
||||
<data name="SendReminderEmails" xml:space="preserve">
|
||||
<value>Envoyer des e-mails de rappel</value>
|
||||
</data>
|
||||
<data name="FirstReminderDays" xml:space="preserve">
|
||||
<value>Premier rappel (jours)</value>
|
||||
</data>
|
||||
<data name="ReminderIntervalDays" xml:space="preserve">
|
||||
<value>Intervalle de rappel (jours)</value>
|
||||
</data>
|
||||
<data name="ExpiresWhenDays" xml:space="preserve">
|
||||
<value>Expire apres (jours)</value>
|
||||
</data>
|
||||
<data name="ExpiresWarningWhenDays" xml:space="preserve">
|
||||
<value>Avertissement d'expiration (jours)</value>
|
||||
</data>
|
||||
<data name="SenderEditor_EnvelopeSettings" xml:space="preserve">
|
||||
<value>Parametres de l'enveloppe</value>
|
||||
</data>
|
||||
<data name="SenderEditor_AdvancedOptional" xml:space="preserve">
|
||||
<value>Avance</value>
|
||||
</data>
|
||||
<data name="SenderEditor_SelectTemplate" xml:space="preserve">
|
||||
<value>Selectionner un modele</value>
|
||||
</data>
|
||||
<data name="SenderEditor_Validation_ReminderInterval" xml:space="preserve">
|
||||
<value>Veuillez saisir un intervalle de rappel superieur a 0 jour.</value>
|
||||
</data>
|
||||
<data name="SenderEditor_Validation_FirstReminder" xml:space="preserve">
|
||||
<value>Veuillez saisir des jours valides pour le premier rappel.</value>
|
||||
</data>
|
||||
<data name="SenderEditor_Validation_ExpiryWarning" xml:space="preserve">
|
||||
<value>L'avertissement d'expiration ne peut pas etre apres la date d'expiration.</value>
|
||||
</data>
|
||||
</root>
|
||||
|
||||
@@ -279,4 +279,61 @@
|
||||
<data name="YesWithAttachment" xml:space="preserve">
|
||||
<value>Ja, mit Anhang</value>
|
||||
</data>
|
||||
</root>
|
||||
<data name="ElectronicSignature" xml:space="preserve">
|
||||
<value>Elektronische Signatur</value>
|
||||
</data>
|
||||
<data name="EnvelopeType" xml:space="preserve">
|
||||
<value>Umschlagtyp</value>
|
||||
</data>
|
||||
<data name="Language" xml:space="preserve">
|
||||
<value>Sprache</value>
|
||||
</data>
|
||||
<data name="CertificationType" xml:space="preserve">
|
||||
<value>Zertifizierung</value>
|
||||
</data>
|
||||
<data name="TFAEnabled" xml:space="preserve">
|
||||
<value>2FA aktiviert</value>
|
||||
</data>
|
||||
<data name="UseAccessCode" xml:space="preserve">
|
||||
<value>Zugriffscode verwenden</value>
|
||||
</data>
|
||||
<data name="FinalEmailToCreator" xml:space="preserve">
|
||||
<value>Finale E-Mail an Ersteller</value>
|
||||
</data>
|
||||
<data name="FinalEmailToReceivers" xml:space="preserve">
|
||||
<value>Finale E-Mail an Empfänger</value>
|
||||
</data>
|
||||
<data name="SendReminderEmails" xml:space="preserve">
|
||||
<value>Erinnerungs-E-Mails senden</value>
|
||||
</data>
|
||||
<data name="FirstReminderDays" xml:space="preserve">
|
||||
<value>Erste Erinnerung (Tage)</value>
|
||||
</data>
|
||||
<data name="ReminderIntervalDays" xml:space="preserve">
|
||||
<value>Erinnerungsintervall (Tage)</value>
|
||||
</data>
|
||||
<data name="ExpiresWhenDays" xml:space="preserve">
|
||||
<value>Läuft ab nach (Tage)</value>
|
||||
</data>
|
||||
<data name="ExpiresWarningWhenDays" xml:space="preserve">
|
||||
<value>Ablaufwarnung (Tage)</value>
|
||||
</data>
|
||||
<data name="SenderEditor_EnvelopeSettings" xml:space="preserve">
|
||||
<value>Envelope-Einstellungen</value>
|
||||
</data>
|
||||
<data name="SenderEditor_AdvancedOptional" xml:space="preserve">
|
||||
<value>Erweitert</value>
|
||||
</data>
|
||||
<data name="SenderEditor_SelectTemplate" xml:space="preserve">
|
||||
<value>Vorlage auswählen</value>
|
||||
</data>
|
||||
<data name="SenderEditor_Validation_ReminderInterval" xml:space="preserve">
|
||||
<value>Bitte geben Sie ein Erinnerungsintervall größer als 0 Tage ein.</value>
|
||||
</data>
|
||||
<data name="SenderEditor_Validation_FirstReminder" xml:space="preserve">
|
||||
<value>Bitte geben Sie gültige Tage für die erste Erinnerung ein.</value>
|
||||
</data>
|
||||
<data name="SenderEditor_Validation_ExpiryWarning" xml:space="preserve">
|
||||
<value>Die Ablaufwarnung darf nicht nach dem Ablaufdatum liegen.</value>
|
||||
</data>
|
||||
</root>
|
||||
|
||||
@@ -105,10 +105,10 @@ Public Class frmEnvelopeMainData
|
||||
End If
|
||||
End Sub
|
||||
Private Function TranslateCertificationType(pType As CertificationType) As String
|
||||
Return Resources.Model.ResourceManager.GetString(pType.ToString())
|
||||
Return CommonServices.My.Resources.Model.ResourceManager.GetString(pType.ToString())
|
||||
End Function
|
||||
Private Function TranslateFinalEmailType(pType As FinalEmailType) As String
|
||||
Return Resources.Model.ResourceManager.GetString(pType.ToString())
|
||||
Return CommonServices.My.Resources.Model.ResourceManager.GetString(pType.ToString())
|
||||
End Function
|
||||
|
||||
Private Sub btOK_Click(sender As Object, e As EventArgs) Handles btOK.Click
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using DocumentService.Client.Configuration;
|
||||
using DocumentService.Client.Extensions;
|
||||
using EnvelopeGenerator.Application.Common.Interfaces.Services;
|
||||
using EnvelopeGenerator.Infrastructure.Crypt.Services;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace EnvelopeGenerator.Infrastructure.Crypt.DependencyInjection;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddCryptInfrastructure(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
var baseUrl = configuration["DocumentService:BaseUrl"]
|
||||
?? throw new InvalidOperationException("DocumentService:BaseUrl is not configured.");
|
||||
|
||||
services.AddDocumentServiceClients(options => options.BaseUrl = baseUrl);
|
||||
|
||||
services.AddSingleton<IPdfDigitalSignatureService, PdfDigitalSignatureService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="DocumentService.Client" Version="1.3.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.10" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\EnvelopeGenerator.Application\EnvelopeGenerator.Application.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
384
EnvelopeGenerator.Infrastructure.Crypt/README.md
Normal file
384
EnvelopeGenerator.Infrastructure.Crypt/README.md
Normal file
@@ -0,0 +1,384 @@
|
||||
# EnvelopeGenerator.Infrastructure.Crypt
|
||||
|
||||
Umfassende technische Dokumentation der PKCS#11/SoftHSM-Integration fuer `EnvelopeGenerator`.
|
||||
|
||||
Dieses Dokument konsolidiert und ersetzt die verteilten Runbooks aus:
|
||||
|
||||
- `pkcs11-proxy-production/README.md`
|
||||
- `pkcs11-proxy-production/SSH_TEMP_ACCESS_MUST_DO.md`
|
||||
- aeltere Abschnitte in diesem README
|
||||
|
||||
Zielgruppe: Senior Developer, Platform Engineers, DevOps, Security Owner.
|
||||
|
||||
---
|
||||
|
||||
## 1. Architekturueberblick
|
||||
|
||||
### 1.1 Ziel
|
||||
|
||||
`EnvelopeGenerator.Infrastructure.Crypt` stellt die kryptographische Infrastruktur fuer PDF-Signaturen bereit:
|
||||
|
||||
- Signaturerzeugung mit Schluesseln in SoftHSM/PKCS#11
|
||||
- Zertifikat-Auslese aus Token
|
||||
- Signaturpruefung auf PDF-Ebene
|
||||
- Health/Startup-Selbsttest fuer Betriebsfaehigkeit
|
||||
|
||||
### 1.2 Topologie (aktueller Betriebsmodus)
|
||||
|
||||
Der aktuell implementierte Produktions-nahe Pfad ist:
|
||||
|
||||
1. Linux hostet SoftHSM + `pkcs11-daemon` (Proxy-Server).
|
||||
2. Windows hostet `EnvelopeGenerator.Server`.
|
||||
3. Windows laedt ein Windows-kompatibles Proxy-PKCS#11-Modul (`libpkcs11-proxy.dll`).
|
||||
4. PKCS#11-Operationen werden ueber Socket (`PKCS11_PROXY_SOCKET`) an Linux weitergeleitet.
|
||||
|
||||
Wichtig:
|
||||
|
||||
- Ein Windows-Prozess kann **kein Linux `.so`** direkt laden.
|
||||
- Private Keys verbleiben im Token (nicht exportierbar).
|
||||
|
||||
### 1.3 Laufzeitpfade in der Anwendung
|
||||
|
||||
`Pkcs11InteropBootstrap` loest Assets relativ zu `Crypt:Pkcs11:AssetsRootPath` auf:
|
||||
|
||||
- Modul: `pkcs11\windows\proxy\libpkcs11-proxy.dll`
|
||||
- Tool-Fallback: `pkcs11\windows\opensc\tools\pkcs11-tool.exe`
|
||||
|
||||
Wenn `AssetsRootPath` leer ist, wird `AppContext.BaseDirectory` verwendet.
|
||||
|
||||
---
|
||||
|
||||
## 2. Linux-Seite: SoftHSM + Proxy Setup
|
||||
|
||||
### 2.1 Basispakete
|
||||
|
||||
Beispiel (Debian/Ubuntu):
|
||||
|
||||
```bash
|
||||
sudo apt-get update && sudo apt-get install -y softhsm2 opensc p11-kit libengine-pkcs11-openssl
|
||||
```
|
||||
|
||||
### 2.2 SoftHSM-Modulpfad pruefen
|
||||
|
||||
```bash
|
||||
ldconfig -p | grep softhsm
|
||||
find /usr -name "libsofthsm2.so" 2>/dev/null
|
||||
```
|
||||
|
||||
### 2.3 Token initialisieren
|
||||
|
||||
```bash
|
||||
softhsm2-util --show-slots
|
||||
softhsm2-util --init-token --slot <uninitialized-slot> --label CompanyProdToken --so-pin <so-pin> --pin <user-pin>
|
||||
softhsm2-util --show-slots
|
||||
```
|
||||
|
||||
Hinweis: Nach `--init-token` wird der Slot i. d. R. neu zugewiesen.
|
||||
|
||||
### 2.4 Schluesselpaar erzeugen
|
||||
|
||||
```bash
|
||||
pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so \
|
||||
--slot <slot-id> --login --pin <user-pin> \
|
||||
--keypairgen --key-type rsa:3072 --label company-sign-key-v1 --id 01
|
||||
```
|
||||
|
||||
### 2.5 Zertifikat importieren
|
||||
|
||||
```bash
|
||||
pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so \
|
||||
--slot <slot-id> --login --pin <user-pin> \
|
||||
--write-object ./company-sign-cert-v1.der --type cert --id 01 --label company-sign-cert-v1
|
||||
```
|
||||
|
||||
### 2.6 Objektzustand verifizieren
|
||||
|
||||
```bash
|
||||
pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so --slot <slot-id> --login --pin <user-pin> --list-objects
|
||||
pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so --slot <slot-id> --login --pin <user-pin> --list-objects --type cert
|
||||
```
|
||||
|
||||
### 2.7 Proxy-Daemon endpoint stabilisieren
|
||||
|
||||
Ein wiederkehrendes Betriebsproblem war ein zufaelliger/local-only Listener. Fix:
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /etc/systemd/system/pkcs11-proxy.service.d
|
||||
printf "[Service]\nEnvironment=PKCS11_DAEMON_SOCKET=tcp://0.0.0.0:5657\n" | sudo tee /etc/systemd/system/pkcs11-proxy.service.d/override.conf
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart pkcs11-proxy.service
|
||||
```
|
||||
|
||||
Pruefung:
|
||||
|
||||
```bash
|
||||
systemctl show pkcs11-proxy.service -p Environment --no-pager
|
||||
ss -ltnp | grep 5657
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Web-API/Server-Seite: Installation und Asset-Bereitstellung
|
||||
|
||||
### 3.1 Bezugsquellen der Windows-Artefakte
|
||||
|
||||
Fuer den Proxy-Betrieb werden auf Windows benoetigt:
|
||||
|
||||
1. Proxy-PKCS#11-DLL (`libpkcs11-proxy.dll`)
|
||||
2. Abhaengige Runtime-DLLs (z. B. MinGW runtime, je nach Build)
|
||||
3. `pkcs11-tool.exe` + zugehoerige OpenSC-DLLs fuer Fallback
|
||||
|
||||
Beschaffung:
|
||||
|
||||
- Build aus derselben `pkcs11-proxy`-Codebasis/Commit wie Linux-Daemon (Kompatibilitaet sicherstellen)
|
||||
- oder kontrolliert bereitgestellte, intern freigegebene Binaries
|
||||
|
||||
### 3.2 Empfohlene Ablage im Repo
|
||||
|
||||
Bundled Assets liegen unter:
|
||||
|
||||
- `EnvelopeGenerator.Infrastructure.Crypt/resources/pkcs11/windows/proxy/`
|
||||
- `EnvelopeGenerator.Infrastructure.Crypt/resources/pkcs11/windows/opensc/tools/`
|
||||
- `EnvelopeGenerator.Infrastructure.Crypt/resources/pkcs11/windows/opensc/pkcs11/`
|
||||
|
||||
Diese werden beim Build in den Output kopiert (siehe `.csproj`-Copy-Regeln).
|
||||
|
||||
### 3.3 Deployment-Muster
|
||||
|
||||
Es gibt zwei gueltige Wege:
|
||||
|
||||
1. **Bundled (Dev/QA, schnelle Reproduzierbarkeit)**
|
||||
- Assets werden mit dem Build ausgeliefert.
|
||||
- `AssetsRootPath = null` -> Aufloesung via `AppContext.BaseDirectory`.
|
||||
|
||||
2. **Zentraler Asset-Root (Prod, betriebliche Kontrolle)**
|
||||
- Assets in zentralem Pfad (z. B. `E:\DataFiles\Digital Data\EnvelopeGenerator`).
|
||||
- `AssetsRootPath` in `appsettings.json` auf diesen Root setzen.
|
||||
|
||||
### 3.4 Anwendungskonfiguration (`Crypt:Pkcs11`)
|
||||
|
||||
Beispiel:
|
||||
|
||||
```json
|
||||
{
|
||||
"Crypt": {
|
||||
"Pkcs11": {
|
||||
"AssetsRootPath": "E:\\DataFiles\\Digital Data\\EnvelopeGenerator",
|
||||
"SlotId": 2021549410,
|
||||
"TokenLabel": "CompanyProdToken",
|
||||
"ProxySocket": "tcp://172.24.12.64:5657",
|
||||
"UserPin": "<secret>",
|
||||
"PrivateKeyLabel": "company-sign-key-v1",
|
||||
"CertificateLabel": "company-sign-cert-v1",
|
||||
"ApplicationName": "signFLOW",
|
||||
"DefaultReason": null,
|
||||
"UseUtcSigningTime": true,
|
||||
"DefaultCertificationLevel": "NoCertification"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Sicherheitsregel:
|
||||
|
||||
- `UserPin` niemals in versionierte Dateien einchecken.
|
||||
- Secrets nur in Secret Store / nicht versionierte `appsettings.Secrets.json` / Vault.
|
||||
|
||||
---
|
||||
|
||||
## 4. Implementierte Services, Modelle, DTOs
|
||||
|
||||
### 4.1 DI und Startverhalten
|
||||
|
||||
- `AddCryptInfrastructure(...)` in `DependencyInjection/ServiceCollectionExtensions.cs`
|
||||
- bindet `Pkcs11Options`
|
||||
- registriert `IPdfDigitalSignatureService`
|
||||
- registriert Startup-Self-Test Hosted Service
|
||||
|
||||
### 4.2 Konfigurationsmodell
|
||||
|
||||
- `Pkcs11Options` (`EnvelopeGenerator.Application/Common/Configurations/Pkcs11Options.cs`)
|
||||
- `AssetsRootPath`, `SlotId`, `TokenLabel`, `ProxySocket`
|
||||
- `UserPin`, `PrivateKeyLabel`, `CertificateLabel`
|
||||
- `ApplicationName`, `DefaultReason`, `UseUtcSigningTime`, `DefaultCertificationLevel`
|
||||
- `ResolvedLibraryPath`, `ResolvedToolPath` (Runtime-resolved)
|
||||
|
||||
### 4.3 Kernservices
|
||||
|
||||
1. `PdfDigitalSignatureService`
|
||||
- Signiert PDF via DevExpress `PdfDeferredSignatureBuilder`
|
||||
- Nutzt `Pkcs11ExternalSigner` fuer externe Signatur
|
||||
- Erzwingt `ApplicationName` aus Config (nicht client-overridable)
|
||||
- Erweitert `Reason` um strukturiertes Metaformat:
|
||||
- `SFMETA:v1;doc=<uuid>;sha256=<hash>`
|
||||
- Parse beim Verify und Mapping in DTO
|
||||
|
||||
2. `Pkcs11InteropBootstrap`
|
||||
- Loest Modul-/Toolpfade auf
|
||||
- Setzt `PKCS11_PROXY_SOCKET`/`PKCS11_DAEMON_SOCKET`
|
||||
- PATH-Ergaenzung fuer native Abhaengigkeiten
|
||||
- Warmup via `pkcs11-tool -L`
|
||||
|
||||
3. `Pkcs11SessionRunner`
|
||||
- Interop Library-Load mit mehreren Init-Modi (Fallback-Reihenfolge)
|
||||
- Slot-Resolution (`SlotId`/`TokenLabel`)
|
||||
- Session Login/Logout
|
||||
|
||||
4. `Pkcs11ToolBridge`
|
||||
- Fallback via Prozessaufruf von `pkcs11-tool`
|
||||
- Cert read und DigestInfo-Signatur
|
||||
|
||||
5. `Pkcs11StartupSelfTestHostedService`
|
||||
- Fuehrt beim Start Interop-Probe aus
|
||||
- Fallback-Probe bei Interop-Fehler
|
||||
- Schreibt strukturierten Status
|
||||
|
||||
### 4.4 Wichtige Interfaces/DTOs
|
||||
|
||||
- `IPdfDigitalSignatureService`
|
||||
- `SignPdfAsync(...)`, `CheckPdfSignatureAsync(...)`, `ExtractOriginalPdfAsync(...)`
|
||||
- `IPkcs11SelfTestStatusProvider`
|
||||
- `PdfSignOptionsDto`
|
||||
- `UUID`, `Reason`, `Name`, `Location`, `Email`, `Phone`, `CertificationLevel`
|
||||
- `PdfSignatureCheckResultDto` / `PdfSignatureCheckItemDto`
|
||||
- inklusive `BusinessReason`, `UUID`, `OriginalDocumentSha256`
|
||||
- `Pkcs11SelfTestStatusDto`
|
||||
|
||||
---
|
||||
|
||||
## 5. Betriebserfahrungen und Challenges
|
||||
|
||||
### 5.1 Interop vs Tool-Verhalten
|
||||
|
||||
Beobachtetes Muster in der realen Umgebung:
|
||||
|
||||
- `pkcs11-tool` konnte erfolgreich auf Token zugreifen und signieren.
|
||||
- `Pkcs11Interop` scheiterte mit `CKR_FUNCTION_NOT_SUPPORTED` bzw. fehlendem `C_Initialize` Pointer in bestimmten Modi.
|
||||
|
||||
Konsequenz:
|
||||
|
||||
- Architektur wurde auf **interop-first + controlled tool-fallback** erweitert.
|
||||
- Health-Endpunkt differenziert Modus (`interop`, `pkcs11-tool`, `failed`).
|
||||
|
||||
### 5.2 Typische Fehlbilder
|
||||
|
||||
- `CKR_DEVICE_ERROR` bei Socket-/Proxy-Problemen
|
||||
- `CRYPT_PROVIDER_UNAVAILABLE` bei Pfad/ABI/Dependency-Mismatch
|
||||
- `CRYPT_SLOT_NOT_FOUND` bei Slot-/Label-Mismatch
|
||||
- `CKR_PIN_INCORRECT` bei PIN-Fehler
|
||||
- 307 Redirects bei HTTP/HTTPS-Mix in manuellen Tests
|
||||
|
||||
### 5.3 Plattformfallen
|
||||
|
||||
- MSVC vs MinGW Build-Unterschiede fuer Proxy-Client
|
||||
- fehlende Laufzeit-DLLs (`PATH`/Side-by-side)
|
||||
- Architektur-Mismatch x86 vs x64
|
||||
- Daemon-Bindings nicht stabil (random localhost port statt fixed endpoint)
|
||||
|
||||
---
|
||||
|
||||
## 6. Health, Monitoring und Verifikation
|
||||
|
||||
### 6.1 API Health
|
||||
|
||||
- Endpoint: `GET /api/health/crypt`
|
||||
- Liefert:
|
||||
- `success`, `mode`, `summary`, `interopError`, `toolError`, `checkedAtUtc`
|
||||
|
||||
Empfehlung:
|
||||
|
||||
- In Monitoring aufnehmen und bei `mode=pkcs11-tool` ueber laengere Zeit Warnung triggern.
|
||||
|
||||
### 6.2 Smoke-Test (empfohlen vor Release)
|
||||
|
||||
1. `GET /api/health/crypt` -> `success=true`
|
||||
2. `POST /api/PdfSignature/sign` mit Test-PDF
|
||||
3. `POST /api/PdfSignature/is-signed`
|
||||
4. Pruefen:
|
||||
- `signed=true`
|
||||
- `allSignaturesCryptographicallyValid=true`
|
||||
- `UUID`/`OriginalDocumentSha256` korrekt gemappt
|
||||
|
||||
---
|
||||
|
||||
## 7. Sicherheits- und Betriebsrichtlinien
|
||||
|
||||
### 7.1 Geheimnisse
|
||||
|
||||
- PIN, SO-PIN, Root-Creds niemals in Doku/Screenshots/Repo.
|
||||
- Nach Troubleshooting: Credentials rotieren.
|
||||
|
||||
### 7.2 Temporare SSH-Freigaben zwingend rueckbauen
|
||||
|
||||
Wenn SSH temporaer geoeffnet wurde, MUSS danach Rollback erfolgen:
|
||||
|
||||
```bash
|
||||
sudo systemctl disable --now ssh && sudo ufw delete allow from 172.24.10.11 to any port 22 proto tcp && sudo ufw status verbose && sudo systemctl status ssh --no-pager
|
||||
```
|
||||
|
||||
Optional harte Bereinigung:
|
||||
|
||||
```bash
|
||||
sudo apt-get purge -y openssh-server && sudo apt-get autoremove -y
|
||||
```
|
||||
|
||||
### 7.3 Schluesselrotation
|
||||
|
||||
- Neue Labels (`...-v2`) anlegen
|
||||
- App umkonfigurieren
|
||||
- Sign/Verify Regression testen
|
||||
- Altmaterial erst nach Freigabe entfernen
|
||||
|
||||
---
|
||||
|
||||
## 8. API-Verhalten bei Signaturmetadaten
|
||||
|
||||
Aktueller Standard:
|
||||
|
||||
- `UUID` kommt aus Sign-Input (nullable string). Wenn ungeltig/leer -> neue Guid wird erzeugt.
|
||||
- `Reason` wird intern angereichert mit strukturiertem Meta (`SFMETA:v1`).
|
||||
- `ApplicationName` ist rein serverseitig aus `appsettings` gesetzt.
|
||||
- `SigningTime` ist rein serverseitig und wird automatisch gesetzt (`UseUtcSigningTime=true` => `UtcNow`, sonst lokale Zeit).
|
||||
- `CertificationLevel` wird im Controller serverseitig gesetzt (`FormFillingAndAnnotations`), nicht vom Client.
|
||||
|
||||
Damit ist das Verhalten deterministisch und manipulationsaermer fuer Integrationsclients.
|
||||
|
||||
---
|
||||
|
||||
## 9. Troubleshooting-Checkliste (praxisnah)
|
||||
|
||||
1. Erreichbarkeit Linux Proxy:
|
||||
- `Test-NetConnection <linux-host> -Port 5657`
|
||||
2. Token sichtbar?
|
||||
- `pkcs11-tool --module <dll> -L -v`
|
||||
3. Zertifikat lesbar?
|
||||
- `--read-object --type cert --label <cert-label>`
|
||||
4. Signatur technisch moeglich?
|
||||
- `--sign -m SHA256-RSA-PKCS ...`
|
||||
5. App-Health check:
|
||||
- `/api/health/crypt`
|
||||
6. App-sign/verify end-to-end:
|
||||
- `/api/PdfSignature/sign` + `/api/PdfSignature/is-signed`
|
||||
|
||||
Wenn Interop scheitert, aber Tool funktioniert:
|
||||
|
||||
- Betrieb bleibt verfuegbar (Fallback),
|
||||
- aber Incident/Bug fuer Interop-Kompatibilitaet erfassen.
|
||||
|
||||
---
|
||||
|
||||
## 10. Ausblick / empfohlene Verbesserungen
|
||||
|
||||
1. Binary Transport statt Base64 bei grossen PDFs in Render/Sign-Pipelines.
|
||||
2. Persistente Metriken fuer Fallback-Quote (`interop` vs `tool`).
|
||||
3. Zertifikatskette/OCSP/CRL Validierung als optionales Policy-Modul.
|
||||
4. CI-Pruefung auf unsichere SQL-Muster (parallel zum Security-Workstream).
|
||||
5. Standardisierte Toast/UX-Rueckmeldungen fuer Signierfehler in UI.
|
||||
|
||||
---
|
||||
|
||||
## 11. Referenzen
|
||||
|
||||
- SoftHSMv2: `https://github.com/softhsm/SoftHSMv2`
|
||||
- OpenSC / pkcs11-tool: `https://github.com/OpenSC/OpenSC/wiki/Using-pkcs11-tool-and-OpenSSL`
|
||||
- PKCS#11 Standard: `https://www.oasis-open.org/committees/pkcs11/`
|
||||
@@ -0,0 +1,77 @@
|
||||
using DocumentService.Client.Interfaces;
|
||||
using DocumentService.Client.Models.Requests;
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
using EnvelopeGenerator.Application.Common.Interfaces.Services;
|
||||
using ClientCertLevel = DocumentService.Client.Models.Requests.PdfSignatureCertificationLevel;
|
||||
using ClientCoordUnit = DocumentService.Client.Models.Requests.PdfSignatureCoordinateUnit;
|
||||
using ClientCoordOrigin = DocumentService.Client.Models.Requests.PdfSignatureCoordinateOrigin;
|
||||
|
||||
namespace EnvelopeGenerator.Infrastructure.Crypt.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Proxy that forwards PDF signing to the remote DocumentService API via <see cref="IPdfSignatureClient"/>.
|
||||
/// </summary>
|
||||
public sealed class PdfDigitalSignatureService(IPdfSignatureClient signatureClient) : IPdfDigitalSignatureService
|
||||
{
|
||||
public async Task SignPdfAsync(Stream inputPdf, Stream outputPdf, PdfSignOptionsDto? options = null, Stream? image = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var pdfBytes = await ReadAllBytesAsync(inputPdf, cancellationToken);
|
||||
var imageBytes = image is null ? null : await ReadAllBytesAsync(image, cancellationToken);
|
||||
|
||||
using var signed = await signatureClient.SignAsync(pdfBytes, Map(options), imageBytes, cancellationToken);
|
||||
await signed.CopyToAsync(outputPdf, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<PdfSignatureCheckResultDto> CheckPdfSignatureAsync(Stream inputPdf, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException("Signature checking is handled by DocumentService and is not available through this proxy.");
|
||||
|
||||
private static SignPdfOptions Map(PdfSignOptionsDto? o) => new()
|
||||
{
|
||||
DocumentElementId = o?.DocumentElementId ?? 0,
|
||||
Name = o?.Name ?? string.Empty,
|
||||
Reason = o?.Reason,
|
||||
Location = o?.Location,
|
||||
Email = o?.Email,
|
||||
Phone = o?.Phone,
|
||||
ApplicationName = o?.ApplicationName,
|
||||
CertificationLevel = (ClientCertLevel)(o?.CertificationLevel ?? EnvelopeGenerator.Application.Common.Enums.PdfSignatureCertificationLevel.NoCertification),
|
||||
Appearance = o?.Appearance is null ? null : MapAppearance(o.Appearance)
|
||||
};
|
||||
|
||||
private static PdfSignatureAppearance MapAppearance(PdfSignatureAppearanceDto a) => new()
|
||||
{
|
||||
PageNumber = a.PageNumber,
|
||||
Left = a.Left,
|
||||
Bottom = a.Bottom,
|
||||
Width = a.Width,
|
||||
Height = a.Height,
|
||||
ShowLabels = a.ShowLabels,
|
||||
ShowName = a.ShowName,
|
||||
ShowDate = a.ShowDate,
|
||||
ShowReason = a.ShowReason,
|
||||
ShowLocation = a.ShowLocation,
|
||||
ShowDistinguishedName = a.ShowDistinguishedName,
|
||||
DateTimeFormat = a.DateTimeFormat,
|
||||
CoordinateUnit = (ClientCoordUnit)a.CoordinateUnit,
|
||||
CoordinateOrigin = (ClientCoordOrigin)a.CoordinateOrigin,
|
||||
Font = new PdfSignatureFont
|
||||
{
|
||||
FamilyName = a.Font.FamilyName,
|
||||
Size = a.Font.Size,
|
||||
IsBold = a.Font.IsBold,
|
||||
IsItalic = a.Font.IsItalic,
|
||||
IsUnderline = a.Font.IsUnderline,
|
||||
Color = new PdfSignatureColor { R = a.Font.Color.R, G = a.Font.Color.G, B = a.Font.Color.B }
|
||||
}
|
||||
};
|
||||
|
||||
private static async Task<byte[]> ReadAllBytesAsync(Stream stream, CancellationToken cancellationToken)
|
||||
{
|
||||
if (stream is MemoryStream memory && memory.TryGetBuffer(out var buffer))
|
||||
return buffer.AsSpan(0, (int)memory.Length).ToArray();
|
||||
|
||||
using var copy = new MemoryStream();
|
||||
await stream.CopyToAsync(copy, cancellationToken);
|
||||
return copy.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
using DevExpress.Drawing;
|
||||
using DevExpress.Pdf;
|
||||
using DigitalData.Core.Exceptions;
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
using EnvelopeGenerator.Application.Common.Interfaces.Doc;
|
||||
using EnvelopeGenerator.Domain.Interfaces;
|
||||
|
||||
namespace EnvelopeGenerator.Infrastructure.Doc;
|
||||
|
||||
@@ -54,4 +56,125 @@ public class PdfPageImageRenderer : IPdfPageImageRenderer
|
||||
|
||||
return Task.FromResult<IReadOnlyList<PdfPageImageDto>>(results);
|
||||
}
|
||||
|
||||
public Stream ComposeSignaturePreviewPng(ISignatureInkPayload element)
|
||||
{
|
||||
const double sigW = 1.77 * 72;
|
||||
const double sigH = 1.96 * 72;
|
||||
const double imgRatio = 0.52;
|
||||
const double lineH = 11.5;
|
||||
const double bgPad = 3.0;
|
||||
const double padding = 3.0;
|
||||
|
||||
var date = element.ChangedWhen!.Value.ToString("dd.MM.yyyy");
|
||||
var position = string.IsNullOrWhiteSpace(element.Position) ? null : element.Position.Trim();
|
||||
|
||||
var x = bgPad;
|
||||
var y = bgPad;
|
||||
var imgH = sigH * imgRatio;
|
||||
var lineY = y + imgH + 1.0;
|
||||
var textY = lineY + 1.5;
|
||||
var row1Y = textY;
|
||||
var row2Y = row1Y + lineH;
|
||||
var row3Y = position is null ? row2Y : row2Y + lineH;
|
||||
var contentBottom = row3Y + lineH;
|
||||
var pageWidth = sigW + (bgPad * 2);
|
||||
var pageHeight = contentBottom + bgPad;
|
||||
|
||||
var inkBytes = this.EnsureSupportedImageBytes(element.Ink!);
|
||||
|
||||
using var processor = new PdfDocumentProcessor();
|
||||
processor.CreateEmptyDocument();
|
||||
var page = processor.AddNewPage(new PdfRectangle(0, 0, pageWidth, pageHeight));
|
||||
|
||||
using var graphics = processor.CreateGraphics();
|
||||
|
||||
using var bgBrush = new DXSolidBrush(System.Drawing.Color.FromArgb(255, 255, 253, 240));
|
||||
using var linePen = new DXPen(System.Drawing.Color.FromArgb(180, 100, 100, 120), 0.5f);
|
||||
using var nameBrush = new DXSolidBrush(System.Drawing.Color.FromArgb(255, 20, 20, 20));
|
||||
using var infoBrush = new DXSolidBrush(System.Drawing.Color.FromArgb(255, 80, 80, 80));
|
||||
|
||||
var fontBold = new DXFont("Arial", 7.5f, DXFontStyle.Bold);
|
||||
var fontNormal = new DXFont("Arial", 6.5f, DXFontStyle.Regular);
|
||||
|
||||
graphics.FillRectangle(bgBrush, new System.Drawing.RectangleF(0, 0, (float)pageWidth, (float)pageHeight));
|
||||
|
||||
var imageArea = new System.Drawing.RectangleF((float)x, (float)y, (float)sigW, (float)imgH);
|
||||
var imageRect = FitImageContain(imageArea, inkBytes);
|
||||
graphics.DrawImage(inkBytes, imageRect);
|
||||
|
||||
graphics.DrawLine(linePen, (float)(x + 2), (float)lineY, (float)(x + sigW - 2), (float)lineY);
|
||||
|
||||
graphics.DrawString(
|
||||
element.FullName!.Trim(),
|
||||
fontBold,
|
||||
nameBrush,
|
||||
new System.Drawing.RectangleF((float)(x + padding), (float)row1Y, (float)(sigW - (padding * 2)), (float)lineH));
|
||||
|
||||
if (position is not null)
|
||||
{
|
||||
graphics.DrawString(
|
||||
position,
|
||||
fontNormal,
|
||||
infoBrush,
|
||||
new System.Drawing.RectangleF((float)(x + padding), (float)row2Y, (float)(sigW - (padding * 2)), (float)lineH));
|
||||
}
|
||||
|
||||
graphics.DrawString(
|
||||
$"{element.Place!.Trim()}, {date}",
|
||||
fontNormal,
|
||||
infoBrush,
|
||||
new System.Drawing.RectangleF((float)(x + padding), (float)row3Y, (float)(sigW - (padding * 2)), (float)lineH));
|
||||
|
||||
graphics.AddToPageForeground(page, 72, 72);
|
||||
|
||||
using var pdfStream = new MemoryStream();
|
||||
processor.SaveDocument(pdfStream);
|
||||
pdfStream.Position = 0;
|
||||
|
||||
using var rasterProcessor = new PdfDocumentProcessor();
|
||||
rasterProcessor.LoadDocument(pdfStream);
|
||||
|
||||
var renderOptions = PdfPageRenderingParameters.CreateWithResolution(400f);
|
||||
using DXBitmap bitmap = rasterProcessor.CreateDXBitmap(1, renderOptions);
|
||||
var pngStream = new MemoryStream();
|
||||
bitmap.Save(pngStream, DXImageFormat.Png);
|
||||
pngStream.Position = 0;
|
||||
return pngStream;
|
||||
}
|
||||
|
||||
private static System.Drawing.RectangleF FitImageContain(System.Drawing.RectangleF area, byte[] imageBytes)
|
||||
{
|
||||
using var imageStream = new MemoryStream(imageBytes, writable: false);
|
||||
using var image = DXImage.FromStream(imageStream);
|
||||
|
||||
if (image.Width <= 0 || image.Height <= 0)
|
||||
return area;
|
||||
|
||||
var scale = Math.Min(area.Width / image.Width, area.Height / image.Height);
|
||||
var drawW = image.Width * scale;
|
||||
var drawH = image.Height * scale;
|
||||
var drawX = area.X + ((area.Width - drawW) / 2f);
|
||||
var drawY = area.Y + ((area.Height - drawH) / 2f);
|
||||
|
||||
return new System.Drawing.RectangleF(drawX, drawY, drawW, drawH);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="bytes"></param>
|
||||
/// <returns></returns>
|
||||
public bool IsSupportedImageFormat(byte[] bytes)
|
||||
{
|
||||
var isPng = bytes.Length >= 4 &&
|
||||
bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47;
|
||||
if (isPng)
|
||||
return true;
|
||||
|
||||
var isJpeg = bytes.Length >= 3 &&
|
||||
bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF;
|
||||
return isJpeg;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace EnvelopeGenerator.Infrastructure.Notification.Configuration;
|
||||
|
||||
public enum ConsumerType
|
||||
{
|
||||
API,
|
||||
RabbitMQ
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using DigitalData.MessagingService.RabbitMQ;
|
||||
|
||||
namespace EnvelopeGenerator.Infrastructure.Notification.Configuration;
|
||||
|
||||
public class NotificationOptions
|
||||
{
|
||||
public const string SectionName = "Notification";
|
||||
|
||||
public ConsumerType ConsumerType { get; set; }
|
||||
|
||||
public string? ApiUrl { get; set; }
|
||||
|
||||
public string SenderEmail { get; set; } = null!;
|
||||
|
||||
public RabbitMqConnectionConfiguration? RabbitMQ { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using DigitalData.MessagingService.Client;
|
||||
using EnvelopeGenerator.Application.Common.Interfaces.Services.Notification;
|
||||
using EnvelopeGenerator.Infrastructure.Notification.Configuration;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace EnvelopeGenerator.Infrastructure.Notification;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static IServiceCollection AddNotificationInfrastructure(this IServiceCollection services, IConfiguration configuration, string sectionName = NotificationOptions.SectionName)
|
||||
{
|
||||
var ntfConfiguration = configuration.GetSection(sectionName);
|
||||
services.Configure<NotificationOptions>(ntfConfiguration);
|
||||
|
||||
var options = ntfConfiguration.Get<NotificationOptions>()
|
||||
?? throw new InvalidOperationException(
|
||||
$"Failed to bind configuration section '{sectionName}' to {nameof(NotificationOptions)}. Ensure the section exists and is correctly configured.");
|
||||
|
||||
switch (options.ConsumerType)
|
||||
{
|
||||
case ConsumerType.API:
|
||||
if (options.ApiUrl is string apiUrl)
|
||||
services.AddMessagingServiceHttpClient(opt =>
|
||||
{
|
||||
opt.BaseAddress = new Uri(apiUrl);
|
||||
// Additional configuration for the MessagingServiceHttpClient can be added here if needed
|
||||
});
|
||||
else
|
||||
throw new InvalidOperationException(
|
||||
$"MessagingServiceApiUrl must be provided when ClientType is set to Api in the '{sectionName}' configuration section.");
|
||||
break;
|
||||
case ConsumerType.RabbitMQ:
|
||||
if(options.RabbitMQ is not null)
|
||||
services.AddMessagingServiceClient(ntfConfiguration, nameof(options.RabbitMQ));
|
||||
else
|
||||
throw new InvalidOperationException(
|
||||
$"Missing RabbitMQ connection configuration." +
|
||||
$"When ClientType is set to RabbitMQ, a valid RabbitMQ configuration must be provided in the '{sectionName}' configuration section.");
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException(
|
||||
$"Unsupported ConsumerType '{options.ConsumerType}' in the '{sectionName}' configuration section.");
|
||||
}
|
||||
|
||||
services.AddScoped<IEnvelopeNotificationService, EnvelopeNotificationService>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net8.0;</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="DigitalData.MessagingService.Client" Version="3.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.10" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\EnvelopeGenerator.Application\EnvelopeGenerator.Application.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,15 @@
|
||||
using DigitalData.MessagingService.Application.Common.Dto;
|
||||
|
||||
namespace EnvelopeGenerator.Infrastructure.Notification;
|
||||
|
||||
public static class EnvelopeNotificationExtensions
|
||||
{
|
||||
public static EmailAttachmentDto ToEmailAttachmentDto(this (string FileName, byte[] Content) attachment) => new()
|
||||
{
|
||||
FileName = attachment.FileName,
|
||||
Content = attachment.Content
|
||||
};
|
||||
|
||||
public static IEnumerable<EmailAttachmentDto> ToEmailAttachmentDto(this IEnumerable<(string FileName, byte[] Content)> attachments)
|
||||
=> attachments.Select(ToEmailAttachmentDto);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using DigitalData.Core.Abstraction.Application.Repository;
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
using DigitalData.MessagingService.Application.EmailSending.Commands;
|
||||
using EnvelopeGenerator.Application.Common.Interfaces.Services.Notification;
|
||||
using EnvelopeGenerator.Domain.Constants;
|
||||
using EnvelopeGenerator.Domain.Entities;
|
||||
using EnvelopeGenerator.Domain.Exceptions;
|
||||
using EnvelopeGenerator.Infrastructure.Notification.Configuration;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace EnvelopeGenerator.Infrastructure.Notification;
|
||||
|
||||
public class EnvelopeNotificationService(IRepository<EmailTemplate> TempRepo, IMessagingServiceClient MsgClient, IOptions<NotificationOptions> ntfOpt) : IEnvelopeNotificationService
|
||||
{
|
||||
private readonly NotificationOptions NtfOpt = ntfOpt.Value;
|
||||
|
||||
private async Task<EmailTemplate> GetTemplateAsync(EmailTemplateType templateType, CancellationToken cancel = default)
|
||||
{
|
||||
return await TempRepo.Where(t => t.Name == templateType.ToString()).FirstOrDefaultAsync(cancel)
|
||||
?? throw new DataIntegrityException($"{templateType} email template not found");
|
||||
}
|
||||
|
||||
public async Task Notify<TEnvelopeNotificationBase>(string receiverEmail, TEnvelopeNotificationBase notification, CancellationToken cancel = default)
|
||||
where TEnvelopeNotificationBase : EnvelopeNotificationBase
|
||||
{
|
||||
var template = await GetTemplateAsync(notification.GetTemplateType(), cancel);
|
||||
|
||||
// create email command
|
||||
var mail = new PublishEmailViaSmtpCommand()
|
||||
{
|
||||
Body = template.Body.ReplacePlaceholders(notification),
|
||||
Subject = template.Subject.ReplacePlaceholders(notification),
|
||||
Recipients = [receiverEmail],
|
||||
Sender = NtfOpt.SenderEmail.ToSender(),
|
||||
IsHtml = true
|
||||
};
|
||||
|
||||
// add attachments if any
|
||||
if(notification.Attachments?.Any() ?? false)
|
||||
mail = mail.WithAttachments(notification.Attachments.ToEmailAttachmentDto());
|
||||
|
||||
await MsgClient.SendAsync(mail, cancel);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Reflection;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace EnvelopeGenerator.Infrastructure.Notification;
|
||||
|
||||
public static class ReplacePlaceholderExtensions
|
||||
{
|
||||
private sealed record PlaceholderProperty(string Placeholder, PropertyInfo Property);
|
||||
|
||||
private static readonly ConcurrentDictionary<Type, PlaceholderProperty[]> PropertyCache = new();
|
||||
|
||||
public static string ReplacePlaceholders(this string template, object data)
|
||||
{
|
||||
var properties = PropertyCache.GetOrAdd(data.GetType(), CreatePlaceholderProperties);
|
||||
|
||||
foreach (var item in properties)
|
||||
{
|
||||
var value = item.Property.GetValue(data)?.ToString() ?? string.Empty;
|
||||
template = template.Replace(
|
||||
item.Placeholder,
|
||||
value,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
return template;
|
||||
}
|
||||
|
||||
private static PlaceholderProperty[] CreatePlaceholderProperties(
|
||||
Type type)
|
||||
{
|
||||
return [.. type
|
||||
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Where(p => p.CanRead && p.GetIndexParameters().Length == 0)
|
||||
.Select(p => new PlaceholderProperty(
|
||||
$"[{ToCapitalSnakeCase(p.Name)}]",
|
||||
p))];
|
||||
}
|
||||
|
||||
private static string ToCapitalSnakeCase(string name)
|
||||
{
|
||||
return Regex.Replace(
|
||||
Regex.Replace(name, @"([A-Z]+)([A-Z][a-z])", "$1_$2"),
|
||||
@"([a-z0-9])([A-Z])",
|
||||
"$1_$2").ToUpperInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Original email template: DocumentAccessCodeReceived (GUID 5)
|
||||
-- Backed up before redesign: 2026-10-29 16:32:15
|
||||
-- Subject: Zugriffscode für Dokument erhalten: ''[DOCUMENT_TITLE]''
|
||||
-- Restore by running this script against DD_ECM:
|
||||
UPDATE dbo.TBSIG_EMAIL_TEMPLATE
|
||||
SET BODY = N'Guten Tag [NAME_RECEIVER],<br />
|
||||
<br /><B><I>
|
||||
[NAME_SENDER]</I></B> hat Ihnen ein Dokument zum [SIGNATURE_TYPE] gesendet. <br />
|
||||
<br />
|
||||
Verwenden Sie den folgenden Zugriffscode, um das Dokument einzusehen:<br />
|
||||
<br />
|
||||
[DOCUMENT_ACCESS_CODE]<br />
|
||||
<br />
|
||||
Mit freundlichen Grüßen<br />
|
||||
<br />
|
||||
[NAME_PORTAL]'
|
||||
WHERE GUID = 5;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Original email template: DocumentCompleted (GUID 4)
|
||||
-- Backed up before redesign: 2026-10-29 16:29:55
|
||||
-- Subject: Umschlag abgeschlossen: ''[DOCUMENT_TITLE]''
|
||||
-- Restore by running this script against DD_ECM:
|
||||
UPDATE dbo.TBSIG_EMAIL_TEMPLATE
|
||||
SET BODY = N'Guten Tag [NAME_RECEIVER],<br />
|
||||
<br />
|
||||
Der Vorgang <B><I>''[DOCUMENT_TITLE]''</I></B> wurde erfolgreich abgeschlossen.<br />
|
||||
<br />
|
||||
Sie erhalten das Dokument mit einem detaillierten Ergebnisbericht als Anhang zu dieser Email.<br />
|
||||
<br />
|
||||
Mit freundlichen Grüßen<br />
|
||||
<br />
|
||||
[NAME_PORTAL]'
|
||||
WHERE GUID = 4;
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Original email template: DocumentDeleted (GUID 2)
|
||||
-- Backed up before redesign: 2026-10-29 16:23:45
|
||||
-- Subject: Umschlag zurückgezogen: ''[DOCUMENT_TITLE]''
|
||||
-- Restore by running this script against DD_ECM:
|
||||
UPDATE dbo.TBSIG_EMAIL_TEMPLATE
|
||||
SET BODY = N'Guten Tag [NAME_RECEIVER],<br />
|
||||
<br /><B><I>
|
||||
[NAME_SENDER]</I></B> hat den Umschlag <B><I>''[DOCUMENT_TITLE]''</I></B> gelöscht/zurückgezogen.<br /><p>
|
||||
Begründung: <br /> <I>[REASON]</I> <p>
|
||||
<br />
|
||||
Mit freundlichen Grüßen<br />
|
||||
<br />
|
||||
[NAME_PORTAL]'
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Original email template: DocumentReceived (GUID 1, de-DE)
|
||||
-- Backed up before redesign: 2026-10-29 16:06:07
|
||||
-- Subject: Dokument erhalten: ''[DOCUMENT_TITLE]''
|
||||
-- Restore by running this script against DD_ECM:
|
||||
UPDATE dbo.TBSIG_EMAIL_TEMPLATE
|
||||
SET BODY = N'Guten Tag [NAME_RECEIVER],<br />
|
||||
<br /><B><I>
|
||||
[NAME_SENDER]</I></B> hat Ihnen ein Dokument zum [SIGNATURE_TYPE] gesendet.<br />
|
||||
<br />
|
||||
Über den folgenden Link können Sie das Dokument einsehen[DOCUMENT_PROCESS]: <a href="[LINK_TO_DOCUMENT]">[LINK_TO_DOCUMENT_TEXT]</a><br />
|
||||
<br />
|
||||
[MESSAGE]<br />
|
||||
<br />
|
||||
Mit freundlichen Grüßen<br />
|
||||
<br />
|
||||
[NAME_PORTAL]'
|
||||
WHERE GUID = 1;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Original email template: DocumentRejected_ADM (GUID 6)
|
||||
-- Backed up before redesign: 2026-10-29 16:33:34
|
||||
-- Subject: ''[DOCUMENT_TITLE]'' - Vorgang zurückgezogen
|
||||
-- Restore by running this script against DD_ECM:
|
||||
UPDATE dbo.TBSIG_EMAIL_TEMPLATE
|
||||
SET BODY = N'Guten Tag [NAME_SENDER],<p><B><I>[NAME_RECEIVER]</I></B> hat den Umschlag <B><I>''[DOCUMENT_TITLE]''</I></B> mit folgendem Grund abgelehnt: <p>
|
||||
[REASON]
|
||||
<p>Der Umschlag wurde auf den Status Rejected gesetzt. <p>
|
||||
Mit freundlichen Grüßen<br />
|
||||
<br />
|
||||
[NAME_PORTAL]'
|
||||
WHERE GUID = 6;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Original email template: DocumentRejected_REC_2 (GUID 10)
|
||||
-- Backed up before redesign: 2026-10-29 16:37:12
|
||||
-- Subject: ''[DOCUMENT_TITLE]'' - Vorgang abgelehnt.
|
||||
-- Restore by running this script against DD_ECM:
|
||||
UPDATE dbo.TBSIG_EMAIL_TEMPLATE
|
||||
SET BODY = N'Guten Tag [NAME_RECEIVER],
|
||||
<p>Der Vorgang <B><I>''[DOCUMENT_TITLE]''</I></B> wurde durch einen anderen Vertragspartner abgelehnt.<p> Der Absender <B><I>[NAME_SENDER]</I></B> wird sich bei Bedarf mit Ihnen in Verbindung setzen. <p>
|
||||
Mit freundlichen Grüßen<br />
|
||||
<br />
|
||||
[NAME_PORTAL]'
|
||||
WHERE GUID = 10;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Original email template: DocumentRejected_REC (GUID 9)
|
||||
-- Backed up before redesign: 2026-10-29 16:34:21
|
||||
-- Subject: ''[DOCUMENT_TITLE]'' - Bestätigung der Ablehnung
|
||||
-- Restore by running this script against DD_ECM:
|
||||
UPDATE dbo.TBSIG_EMAIL_TEMPLATE
|
||||
SET BODY = N'Guten Tag [NAME_RECEIVER],
|
||||
<p>Hiermit bestätigen wir Ihnen die Ablehnung des Vorgangs <B><I>''[DOCUMENT_TITLE]''</I></B>!<p>Der Absender <B><I>[NAME_SENDER]</I></B> wurde über die Ablehnung informiert. <p>
|
||||
Mit freundlichen Grüßen<br />
|
||||
<br />
|
||||
[NAME_PORTAL]'
|
||||
WHERE GUID = 9;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Original email template: DocumentShared (GUID 11)
|
||||
-- Backed up before redesign: 2026-10-29 16:42:51
|
||||
-- Subject: Dokument geteilt: ''[DOCUMENT_TITLE]''
|
||||
-- Restore by running this script against DD_ECM:
|
||||
UPDATE dbo.TBSIG_EMAIL_TEMPLATE
|
||||
SET BODY = N'Guten Tag,<br /> <br /><B><I> [NAME_RECEIVER]</I></B> hat Ihnen ein Dokument zum Ansehen gesendet.<br /> <br /> Über den folgenden Link können Sie das Dokument einsehen: <a href="[LINK_TO_DOCUMENT]">[LINK_TO_DOCUMENT_TEXT]</a><br /> <br /> <br /> Mit freundlichen Grüßen<br /> <br /> [NAME_PORTAL]'
|
||||
WHERE GUID = 11;
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Original email template: DocumentSigned (GUID 3)
|
||||
-- Backed up before redesign: 2026-10-29 16:25:45
|
||||
-- Subject: Dokument [RECEIVER_ACTION]: ''[DOCUMENT_TITLE]''
|
||||
-- Restore by running this script against DD_ECM:
|
||||
UPDATE dbo.TBSIG_EMAIL_TEMPLATE
|
||||
SET BODY = N'Guten Tag [NAME_RECEIVER],<br />
|
||||
<br />
|
||||
hiermit bestätigen wir Ihnen die erfolgreiche [FINAL_STATUS] für den Vorgang <B><I>''[DOCUMENT_TITLE]''</I></B>.<br />
|
||||
Wenn alle [FINAL_ACTION] haben, erhalten Sie ebenfalls per E-Mail ein abgeschlossenes Exemplar mit entsprechendem Zertifikat!
|
||||
<br />
|
||||
Mit freundlichen Grüßen<br />
|
||||
<br />
|
||||
[NAME_PORTAL]'
|
||||
WHERE GUID = 3;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Original email template: TotpSecret (GUID 12)
|
||||
-- Backed up before redesign: 2026-10-29 16:44:44
|
||||
-- Subject: 2-Faktor-Authentifizierung QR-Code
|
||||
-- Restore by running this script against DD_ECM:
|
||||
UPDATE dbo.TBSIG_EMAIL_TEMPLATE
|
||||
SET BODY = N'Guten Tag,<br /> <br />Sie können auf Ihren Zwei-Faktor-Authentifizierungscode zugreifen, indem Sie den unten stehenden QR-Code mit einer beliebigen Authentifizierungs-App auf Ihrem Telefon scannen (Google Authenticator, Microsoft Authenticator usw.). Dieser Code ist bis zum [TFA_EXPIRATION] gültig.<br /> <br /> <img src="data:image/png;base64,[TFA_QR_CODE]" style="width: 13rem; height: 13rem;"><br /> <br />
|
||||
<br /> Mit freundlichen Grüßen<br /> <br /> [NAME_PORTAL]'
|
||||
WHERE GUID = 12;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
using EnvelopeGenerator.Domain.Constants;
|
||||
|
||||
namespace EnvelopeGenerator.Server.Client.Components.Sender;
|
||||
|
||||
public sealed record CreateEnvelopeWizardResult(
|
||||
string Title,
|
||||
string Message,
|
||||
int? EnvelopeTypeId,
|
||||
string Language,
|
||||
bool UseAccessCode,
|
||||
bool TFAEnabled,
|
||||
CertificationType CertificationType,
|
||||
int FinalEmailToCreator,
|
||||
int FinalEmailToReceivers,
|
||||
bool SendReminderEmails,
|
||||
int FirstReminderDays,
|
||||
int ReminderIntervalDays,
|
||||
int ExpiresWhenDays,
|
||||
int ExpiresWarningWhenDays,
|
||||
IReadOnlyList<CreateEnvelopeWizardReceiver> Receivers,
|
||||
byte[]? DocumentBytes,
|
||||
string? DocumentFileName);
|
||||
|
||||
public sealed record CreateEnvelopeWizardReceiver(string FullName, string EmailAddress);
|
||||
@@ -0,0 +1,104 @@
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
|
||||
<div class="sender-pdf-dropzone @DropZoneClass">
|
||||
<div class="sender-pdf-dropzone__content">
|
||||
<div class="sender-pdf-dropzone__title">@CurrentActionText</div>
|
||||
@if (!string.IsNullOrWhiteSpace(DragDropHintText))
|
||||
{
|
||||
<div class="sender-pdf-dropzone__hint">@DragDropHintText</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<InputFile OnChange="OnFileSelectedAsync"
|
||||
accept="@Accept"
|
||||
disabled="@Disabled"
|
||||
@ondragenter="OnDragEnter"
|
||||
@ondragleave="OnDragLeave"
|
||||
@ondragover="OnDragOver"
|
||||
@ondrop="OnDrop"
|
||||
class="sender-pdf-dropzone__input" />
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public bool Disabled { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool HasDocument { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string Accept { get; set; } = ".pdf";
|
||||
|
||||
[Parameter]
|
||||
public string UploadText { get; set; } = "PDF hochladen";
|
||||
|
||||
[Parameter]
|
||||
public string ReplaceText { get; set; } = "PDF ersetzen";
|
||||
|
||||
[Parameter]
|
||||
public string DragDropHintText { get; set; } = "Datei hierhin ziehen oder klicken";
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<InputFileChangeEventArgs> FileSelected { get; set; }
|
||||
|
||||
private int _dragDepth;
|
||||
private bool _isDragActive;
|
||||
|
||||
private string CurrentActionText => HasDocument ? ReplaceText : UploadText;
|
||||
|
||||
private string DropZoneClass
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Disabled)
|
||||
return "sender-pdf-dropzone--disabled";
|
||||
|
||||
return _isDragActive ? "sender-pdf-dropzone--drag-active" : string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDragEnter(DragEventArgs _)
|
||||
{
|
||||
if (Disabled)
|
||||
return;
|
||||
|
||||
_dragDepth++;
|
||||
_isDragActive = true;
|
||||
}
|
||||
|
||||
private void OnDragLeave(DragEventArgs _)
|
||||
{
|
||||
if (Disabled)
|
||||
return;
|
||||
|
||||
_dragDepth = Math.Max(0, _dragDepth - 1);
|
||||
if (_dragDepth == 0)
|
||||
_isDragActive = false;
|
||||
}
|
||||
|
||||
private void OnDragOver(DragEventArgs _)
|
||||
{
|
||||
if (Disabled)
|
||||
return;
|
||||
|
||||
_isDragActive = true;
|
||||
}
|
||||
|
||||
private void OnDrop(DragEventArgs _)
|
||||
{
|
||||
_dragDepth = 0;
|
||||
_isDragActive = false;
|
||||
}
|
||||
|
||||
private async Task OnFileSelectedAsync(InputFileChangeEventArgs args)
|
||||
{
|
||||
_dragDepth = 0;
|
||||
_isDragActive = false;
|
||||
|
||||
if (Disabled)
|
||||
return;
|
||||
|
||||
if (FileSelected.HasDelegate)
|
||||
await FileSelected.InvokeAsync(args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace EnvelopeGenerator.Server.Client.Components.Sender.ReceiverSuggestionFilter;
|
||||
|
||||
public readonly record struct ReceiverFilterCriteria
|
||||
{
|
||||
public readonly string FullName;
|
||||
public readonly string EmailAddress;
|
||||
|
||||
public ReceiverFilterCriteria(string? fullName = "", string? emailAddress = "")
|
||||
{
|
||||
FullName = Normalize(fullName);
|
||||
EmailAddress = Normalize(emailAddress);
|
||||
}
|
||||
|
||||
public readonly bool Matches(ReceiverFilterCriteria criteria)
|
||||
=> FullName.Contains(criteria.FullName, StringComparison.Ordinal)
|
||||
&& EmailAddress.Contains(criteria.EmailAddress, StringComparison.Ordinal);
|
||||
|
||||
private static string Normalize(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return string.Empty;
|
||||
|
||||
return string.Concat(text.Where(c => !char.IsWhiteSpace(c))).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using EnvelopeGenerator.Application.Common.Dto.Receiver;
|
||||
|
||||
namespace EnvelopeGenerator.Server.Client.Components.Sender.ReceiverSuggestionFilter;
|
||||
|
||||
public class ReceiverSuggestionFilterHandler(IEnumerable<RecentReceiverSuggestionDto> suggestions)
|
||||
{
|
||||
private readonly IReadOnlyList<Entry> _entries = suggestions
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x.FullName) && !string.IsNullOrWhiteSpace(x.EmailAddress))
|
||||
.Select(x => new Entry(new ReceiverFilterCriteria(x.FullName, x.EmailAddress), x))
|
||||
.ToList();
|
||||
|
||||
private readonly record struct Entry(ReceiverFilterCriteria Criteria, RecentReceiverSuggestionDto Suggestion);
|
||||
|
||||
public IEnumerable<RecentReceiverSuggestionDto> Filter(ReceiverFilterCriteria criteria)
|
||||
{
|
||||
return _entries
|
||||
.Where(x => x.Criteria.Matches(criteria))
|
||||
.Select(x => x.Suggestion);
|
||||
}
|
||||
|
||||
public IEnumerable<RecentReceiverSuggestionDto> Filter(string fullName, string emailAddress)
|
||||
{
|
||||
var criteria = new ReceiverFilterCriteria(fullName, emailAddress);
|
||||
return Filter(criteria);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MudBlazor" Version="8.0.0" />
|
||||
<PackageReference Include="DevExpress.Blazor.PdfViewer" Version="25.2.3" />
|
||||
<PackageReference Include="DevExpress.Blazor.Reporting.JSBasedControls" Version="25.2.3" />
|
||||
<PackageReference Include="DevExpress.Blazor.Reporting.Viewer" Version="25.2.3" />
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
_selected = option;
|
||||
_isOpen = false;
|
||||
await LocalizationService.SetLanguageAsync(option.Code);
|
||||
Navigation.NavigateTo(Navigation.Uri, forceLoad: false);
|
||||
Navigation.NavigateTo(Navigation.Uri, forceLoad: true);
|
||||
}
|
||||
|
||||
private sealed record LanguageOption(string Code, string FlagClass, string ShortName);
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
</main>
|
||||
<footer class="receiver-footer">
|
||||
<div class="receiver-footer__left">
|
||||
<span>© SignFlow 2023-2024 <a href="https://digitaldata.works" target="_blank" rel="noopener">Digital Data GmbH</a></span>
|
||||
<span>© signFLOW 2023-2024 <a href="https://digitaldata.works" target="_blank" rel="noopener">Digital Data GmbH</a></span>
|
||||
<span class="receiver-footer__sep">|</span>
|
||||
<a href="@GetPrivacyPolicyPath()" target="_blank" rel="noopener">@L("PrivacyPolicy", "Datenschutz")</a>
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
</svg>
|
||||
}
|
||||
<div>
|
||||
<h1 class="home-hero-header__title">SignFlow</h1>
|
||||
<h1 class="home-hero-header__title">signFLOW</h1>
|
||||
<p class="home-hero-header__subtitle">@L("Index_Subtitle", "Willkommen im eSign-Portal")</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,11 +6,13 @@
|
||||
@using EnvelopeGenerator.Application.Envelopes.Commands
|
||||
@using EnvelopeGenerator.Application.Resources
|
||||
@using EnvelopeGenerator.Domain.Constants
|
||||
@using EnvelopeGenerator.Domain.Resources
|
||||
@using EnvelopeGenerator.Application.Common.Configurations
|
||||
@using EnvelopeGenerator.Server.Client.Components.PdfEditor
|
||||
@using EnvelopeGenerator.Server.Client.Services
|
||||
@using Microsoft.AspNetCore.WebUtilities
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.Extensions.Localization
|
||||
@using Microsoft.Extensions.Options
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject AppVersionService AppVersion
|
||||
@@ -18,10 +20,13 @@
|
||||
@inject DocumentService DocumentService
|
||||
@inject PdfRenderService PdfRenderService
|
||||
@inject ReceiverService ReceiverService
|
||||
@inject EnvelopeReceiverService EnvelopeReceiverService
|
||||
@inject EnvelopeService EnvelopeService
|
||||
@inject EnvelopeTypeService EnvelopeTypeService
|
||||
@inject AuthService AuthService
|
||||
@inject IStringLocalizer<Resource> UiLocalizer
|
||||
@inject IStringLocalizer<Model> ModelLocalizer
|
||||
@inject IOptionsMonitor<SignatureFieldParams> SignatureFieldOptions
|
||||
|
||||
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
|
||||
<link href="_content/DevExpress.Blazor.Viewer/css/dx-blazor-viewer-components.bs5.css" rel="stylesheet" />
|
||||
@@ -32,64 +37,52 @@
|
||||
|
||||
@* ── Action Bar ── *@
|
||||
<div class="envelope-action-bar">
|
||||
<div class="envelope-action-bar__inner"
|
||||
style="flex-direction: row; align-items: center; padding: 0.35rem 1.5rem; gap: 0.75rem;">
|
||||
<div class="envelope-action-bar__inner sender-editor-action-inner">
|
||||
|
||||
@* Left: Title + meta inputs + receivers *@
|
||||
<div style="flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: stretch; gap: 0.5rem;">
|
||||
<div class="sender-editor-main-column">
|
||||
|
||||
@* ── Title & Message inputs ── *@
|
||||
<div style="display: flex; align-items: flex-start; gap: 0.5rem; flex-wrap: wrap;">
|
||||
<div class="sender-editor-meta-row">
|
||||
|
||||
@* Titel *@
|
||||
<div style="display: flex; flex-direction: column; gap: 0.2rem; flex: 1 1 180px; min-width: 140px;">
|
||||
<label style="font-size: 0.65rem; font-weight: 600; color: #6b7280;
|
||||
text-transform: uppercase; letter-spacing: 0.05em;">
|
||||
@L("Title", "Titel") <span style="color:#dc2626;">*</span>
|
||||
<div class="sender-editor-input-field sender-editor-input-field--title">
|
||||
<label class="sender-editor-label">
|
||||
@L("Title", "Titel") <span class="sender-editor-required-asterisk">*</span>
|
||||
</label>
|
||||
<input type="text"
|
||||
value="@_envelopeTitle"
|
||||
@oninput="e => _envelopeTitle = e.Value?.ToString() ?? string.Empty"
|
||||
placeholder='@L("SenderEditor_Title_Placeholder", "z.B. Arbeitsvertrag 2025")'
|
||||
maxlength="120"
|
||||
style="font-size: 0.78rem; font-weight: 500; color: #1f2937;
|
||||
border: 1px solid #d1d5db; border-radius: 5px;
|
||||
padding: 0.3rem 0.55rem; outline: none; width: 100%;
|
||||
background: #fff;
|
||||
@(_titleTouched && string.IsNullOrWhiteSpace(_envelopeTitle) ? "border-color:#dc2626;" : "")" />
|
||||
class="sender-editor-text-input @(_titleTouched && string.IsNullOrWhiteSpace(_envelopeTitle) ? "sender-editor-text-input--invalid" : string.Empty)" />
|
||||
@if (_titleTouched && string.IsNullOrWhiteSpace(_envelopeTitle))
|
||||
{
|
||||
<span style="font-size:0.6rem;color:#dc2626;">@L("RequiredField", "Pflichtfeld")</span>
|
||||
<span class="sender-editor-required-text">@L("RequiredField", "Pflichtfeld")</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@* Nachricht *@
|
||||
<div style="display: flex; flex-direction: column; gap: 0.2rem; flex: 2 1 260px; min-width: 180px;">
|
||||
<label style="font-size: 0.65rem; font-weight: 600; color: #6b7280;
|
||||
text-transform: uppercase; letter-spacing: 0.05em;">
|
||||
@L("Message", "Nachricht") <span style="color:#6b7280;font-weight:400;">(@L("Optional", "optional"))</span>
|
||||
<div class="sender-editor-input-field sender-editor-input-field--message">
|
||||
<label class="sender-editor-label">
|
||||
@L("Message", "Nachricht") <span class="sender-editor-optional">(@L("Optional", "optional"))</span>
|
||||
</label>
|
||||
<input type="text"
|
||||
value="@_envelopeMessage"
|
||||
@oninput="e => _envelopeMessage = e.Value?.ToString() ?? string.Empty"
|
||||
placeholder='@L("SenderEditor_Message_Placeholder", "Bitte unterzeichnen Sie das beigefügte Dokument.")'
|
||||
maxlength="500"
|
||||
style="font-size: 0.78rem; color: #1f2937;
|
||||
border: 1px solid #d1d5db; border-radius: 5px;
|
||||
padding: 0.3rem 0.55rem; outline: none; width: 100%;
|
||||
background: #fff;" />
|
||||
class="sender-editor-text-input" />
|
||||
</div>
|
||||
|
||||
@* PDF filename badge *@
|
||||
@if (_pdfLoaded)
|
||||
{
|
||||
<div style="display:flex;align-items:flex-end;gap:0.4rem;padding-bottom:0.05rem;flex-shrink:0;">
|
||||
<span style="font-size: 0.7rem; color: #6b7280;">@_fileName</span>
|
||||
<div class="sender-editor-file-meta">
|
||||
<span class="sender-editor-file-name">@_fileName</span>
|
||||
@if (_signatureFields.Count > 0)
|
||||
{
|
||||
<span style="display: inline-flex; align-items: center; padding: 0.125rem 0.4rem;
|
||||
background: #ede9fe; border-radius: 0.25rem; color: #6d28d9;
|
||||
font-weight: 500; font-size: 0.7rem; white-space: nowrap;">
|
||||
<span class="sender-editor-signature-count">
|
||||
@_signatureFields.Count @L("SenderEditor_SignatureField", "Signaturfeld")@(_signatureFields.Count != 1 ? "er" : "")
|
||||
</span>
|
||||
}
|
||||
@@ -97,122 +90,145 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
<details style="border:1px solid #dbe5ef;border-radius:8px;background:#f9fbfd;">
|
||||
<summary style="cursor:pointer;list-style:none;display:flex;align-items:center;justify-content:space-between;padding:0.55rem 0.7rem;font-size:0.74rem;font-weight:700;color:#1f2937;text-transform:uppercase;letter-spacing:0.04em;">
|
||||
<span>@L("SenderEditor_EnvelopeSettings", "Envelope-Einstellungen")</span>
|
||||
<span style="font-size:0.66rem;color:#64748b;font-weight:600;">@L("SenderEditor_AdvancedOptional", "Erweitert")</span>
|
||||
</summary>
|
||||
<div class="sender-settings-shell">
|
||||
<DxAccordion ExpandMode="AccordionExpandMode.SingleOrNone" CssClass="sender-settings-accordion">
|
||||
<Items>
|
||||
<DxAccordionItem Text='@L("SenderEditor_EnvelopeSettings", "Envelope-Einstellungen")' Expanded="false">
|
||||
<ContentTemplate>
|
||||
<div class="sender-settings-body">
|
||||
<div class="sender-settings-grid">
|
||||
<div class="sender-settings-field">
|
||||
<label class="sender-settings-label">@L("EnvelopeType", "Umschlagtyp")</label>
|
||||
<select value="@(_selectedEnvelopeTypeId?.ToString() ?? string.Empty)"
|
||||
@onchange="OnEnvelopeTypeChanged"
|
||||
class="sender-settings-select">
|
||||
<option value="">@L("SenderEditor_SelectTemplate", "Vorlage auswählen")</option>
|
||||
@foreach (var type in _envelopeTypes)
|
||||
{
|
||||
<option value="@type.Id">@type.Title</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style="padding:0 0.7rem 0.7rem;display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:0.45rem;">
|
||||
<div style="display:flex;flex-direction:column;gap:0.2rem;">
|
||||
<label style="font-size:0.65rem;font-weight:600;color:#64748b;">@L("EnvelopeType", "Umschlagtyp")</label>
|
||||
<select value="@(_selectedEnvelopeTypeId?.ToString() ?? string.Empty)"
|
||||
@onchange="OnEnvelopeTypeChanged"
|
||||
style="font-size:0.78rem;border:1px solid #d1d5db;border-radius:5px;padding:0.3rem 0.45rem;background:#fff;">
|
||||
<option value="">@L("SenderEditor_SelectTemplate", "Vorlage auswählen")</option>
|
||||
@foreach (var type in _envelopeTypes)
|
||||
{
|
||||
<option value="@type.Id">@type.Title</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="sender-settings-field">
|
||||
<label class="sender-settings-label">@L("Language", "Sprache")</label>
|
||||
<select @bind="_language" class="sender-settings-select">
|
||||
<option value="de">Deutsch (de)</option>
|
||||
<option value="en">English (en)</option>
|
||||
<option value="de-DE">Deutsch (de-DE)</option>
|
||||
<option value="en-US">English (en-US)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:0.2rem;">
|
||||
<label style="font-size:0.65rem;font-weight:600;color:#64748b;">@L("Language", "Sprache")</label>
|
||||
<select @bind="_language"
|
||||
style="font-size:0.78rem;border:1px solid #d1d5db;border-radius:5px;padding:0.3rem 0.45rem;background:#fff;">
|
||||
<option value="de">Deutsch (de)</option>
|
||||
<option value="en">English (en)</option>
|
||||
<option value="de-DE">Deutsch (de-DE)</option>
|
||||
<option value="en-US">English (en-US)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="sender-settings-field">
|
||||
<label class="sender-settings-label">@L("CertificationType", "Zertifizierung")</label>
|
||||
<select @bind="_certificationType" class="sender-settings-select">
|
||||
<option value="@CertificationType.AdvancedElectronicSignature">@L("AdvancedElectronicSignature", "Advanced Electronic Signature")</option>
|
||||
<option value="@CertificationType.ElectronicSignature">@L("ElectronicSignature", "Electronic Signature")</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:0.2rem;">
|
||||
<label style="font-size:0.65rem;font-weight:600;color:#64748b;">@L("CertificationType", "Zertifizierung")</label>
|
||||
<select @bind="_certificationType"
|
||||
style="font-size:0.78rem;border:1px solid #d1d5db;border-radius:5px;padding:0.3rem 0.45rem;background:#fff;">
|
||||
<option value="1">@L("AdvancedElectronicSignature", "Advanced Electronic Signature")</option>
|
||||
<option value="2">@L("ElectronicSignature", "Electronic Signature")</option>
|
||||
<option value="3">@L("QualifiedSignature", "Qualified Signature")</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="sender-settings-toggles">
|
||||
<label class="sender-settings-toggle">
|
||||
<input type="checkbox" checked="@_tfaEnabled" @onchange="OnTfaChanged" />
|
||||
@L("TFAEnabled", "2FA aktiviert")
|
||||
</label>
|
||||
<label class="sender-settings-toggle">
|
||||
<input type="checkbox" checked="@_useAccessCode" @onchange="OnUseAccessCodeChanged" disabled="@_tfaEnabled" />
|
||||
@L("UseAccessCode", "Zugriffscode verwenden")
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:0.3rem;justify-content:flex-end;padding-bottom:0.25rem;">
|
||||
<label style="display:flex;align-items:center;gap:0.45rem;font-size:0.74rem;color:#1f2937;">
|
||||
<input type="checkbox" checked="@_tfaEnabled" @onchange="OnTfaChanged" />
|
||||
@L("TFAEnabled", "2FA aktiviert")
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:0.45rem;font-size:0.74rem;color:#1f2937;">
|
||||
<input type="checkbox" checked="@_useAccessCode" @onchange="OnUseAccessCodeChanged" disabled="@_tfaEnabled" />
|
||||
@L("UseAccessCode", "Zugriffscode verwenden")
|
||||
</label>
|
||||
</div>
|
||||
<div class="sender-settings-field">
|
||||
<label class="sender-settings-label">@L("FinalEmailToCreator", "Finale E-Mail an Ersteller")</label>
|
||||
<select @bind="_finalEmailToCreator" class="sender-settings-select">
|
||||
<option value="0">@L("No", "Nein")</option>
|
||||
<option value="1">@L("Yes", "Ja")</option>
|
||||
<option value="2">@L("YesWithAttachment", "Ja, mit Anhang")</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:0.2rem;">
|
||||
<label style="font-size:0.65rem;font-weight:600;color:#64748b;">@L("FinalEmailToCreator", "Finale E-Mail an Ersteller")</label>
|
||||
<select @bind="_finalEmailToCreator"
|
||||
style="font-size:0.78rem;border:1px solid #d1d5db;border-radius:5px;padding:0.3rem 0.45rem;background:#fff;">
|
||||
<option value="0">@L("No", "Nein")</option>
|
||||
<option value="1">@L("Yes", "Ja")</option>
|
||||
<option value="2">@L("YesWithAttachment", "Ja, mit Anhang")</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="sender-settings-field">
|
||||
<label class="sender-settings-label">@L("FinalEmailToReceivers", "Finale E-Mail an Empfänger")</label>
|
||||
<select @bind="_finalEmailToReceivers" class="sender-settings-select">
|
||||
<option value="0">@L("No", "Nein")</option>
|
||||
<option value="1">@L("Yes", "Ja")</option>
|
||||
<option value="2">@L("YesWithAttachment", "Ja, mit Anhang")</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:0.2rem;">
|
||||
<label style="font-size:0.65rem;font-weight:600;color:#64748b;">@L("FinalEmailToReceivers", "Finale E-Mail an Empfänger")</label>
|
||||
<select @bind="_finalEmailToReceivers"
|
||||
style="font-size:0.78rem;border:1px solid #d1d5db;border-radius:5px;padding:0.3rem 0.45rem;background:#fff;">
|
||||
<option value="0">@L("No", "Nein")</option>
|
||||
<option value="1">@L("Yes", "Ja")</option>
|
||||
<option value="2">@L("YesWithAttachment", "Ja, mit Anhang")</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="sender-settings-toggles sender-settings-toggles--single">
|
||||
<label class="sender-settings-toggle">
|
||||
<input type="checkbox" checked="@_sendReminderEmails" @onchange="OnSendReminderEmailsChanged" />
|
||||
@L("SendReminderEmails", "Erinnerungs-E-Mails senden")
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:0.3rem;justify-content:flex-end;padding-bottom:0.25rem;">
|
||||
<label style="display:flex;align-items:center;gap:0.45rem;font-size:0.74rem;color:#1f2937;">
|
||||
<input type="checkbox" checked="@_sendReminderEmails" @onchange="OnSendReminderEmailsChanged" />
|
||||
@L("SendReminderEmails", "Erinnerungs-E-Mails senden")
|
||||
</label>
|
||||
</div>
|
||||
<div class="sender-settings-field">
|
||||
<label class="sender-settings-label">@L("FirstReminderDays", "Erste Erinnerung (Tage)")</label>
|
||||
<InputNumber @bind-Value="_firstReminderDays" class="form-control form-control-sm sender-settings-number" disabled="@(!_sendReminderEmails)" />
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:0.2rem;">
|
||||
<label style="font-size:0.65rem;font-weight:600;color:#64748b;">@L("FirstReminderDays", "Erste Erinnerung (Tage)")</label>
|
||||
<InputNumber @bind-Value="_firstReminderDays" class="form-control form-control-sm" disabled="@(!_sendReminderEmails)" />
|
||||
</div>
|
||||
<div class="sender-settings-field">
|
||||
<label class="sender-settings-label">@L("ReminderIntervalDays", "Erinnerungsintervall (Tage)")</label>
|
||||
<InputNumber @bind-Value="_reminderIntervalDays" class="form-control form-control-sm sender-settings-number" disabled="@(!_sendReminderEmails)" />
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:0.2rem;">
|
||||
<label style="font-size:0.65rem;font-weight:600;color:#64748b;">@L("ReminderIntervalDays", "Erinnerungsintervall (Tage)")</label>
|
||||
<InputNumber @bind-Value="_reminderIntervalDays" class="form-control form-control-sm" disabled="@(!_sendReminderEmails)" />
|
||||
</div>
|
||||
<div class="sender-settings-field">
|
||||
<label class="sender-settings-label">@L("ExpiresWhenDays", "Läuft ab nach (Tage)")</label>
|
||||
<InputNumber @bind-Value="_expiresWhenDays" class="form-control form-control-sm sender-settings-number" />
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:0.2rem;">
|
||||
<label style="font-size:0.65rem;font-weight:600;color:#64748b;">@L("ExpiresWhenDays", "Läuft ab nach (Tage)")</label>
|
||||
<InputNumber @bind-Value="_expiresWhenDays" class="form-control form-control-sm" />
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:0.2rem;">
|
||||
<label style="font-size:0.65rem;font-weight:600;color:#64748b;">@L("ExpiresWarningWhenDays", "Ablaufwarnung (Tage)")</label>
|
||||
<InputNumber @bind-Value="_expiresWarningWhenDays" class="form-control form-control-sm" />
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<div class="sender-settings-field">
|
||||
<label class="sender-settings-label">@L("ExpiresWarningWhenDays", "Ablaufwarnung (Tage)")</label>
|
||||
<InputNumber @bind-Value="_expiresWarningWhenDays" class="form-control form-control-sm sender-settings-number" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ContentTemplate>
|
||||
</DxAccordionItem>
|
||||
</Items>
|
||||
</DxAccordion>
|
||||
</div>
|
||||
|
||||
<div class="sender-receivers-panel">
|
||||
<div class="sender-receivers-panel__header">
|
||||
<div class="sender-receivers-panel__title-wrap">
|
||||
<span class="sender-receivers-panel__title">@L("Receiver", "Empfänger")</span>
|
||||
<span class="sender-receivers-panel__count">@_receivers.Count</span>
|
||||
</div>
|
||||
|
||||
<button class="pdf-toolbar__btn pdf-toolbar__btn--signature-change sender-toolbar-action-btn"
|
||||
<div class="sender-receivers-list">
|
||||
<button class="pdf-toolbar__btn pdf-toolbar__btn--signature-change sender-toolbar-action-btn sender-receivers-list__add-btn"
|
||||
@onclick="OpenAddReceiverPopup"
|
||||
title='@L("AddReceiver", "Empfänger hinzufügen")'>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M8 1a.5.5 0 0 1 .5.5v6h6a.5.5 0 0 1 0 1h-6v6a.5.5 0 0 1-1 0v-6h-6a.5.5 0 0 1 0-1h6v-6A.5.5 0 0 1 8 1z" />
|
||||
</svg>
|
||||
<span class="pdf-toolbar__btn-text">@L("AddReceiver", "Empfänger hinzufügen")</span>
|
||||
<span class="sender-receivers-list__add-text">@L("AddReceiver", "Empfänger hinzufügen")</span>
|
||||
<span class="badge rounded-pill text-bg-primary sender-receivers-list__count">@_receivers.Count</span>
|
||||
</button>
|
||||
|
||||
@foreach (var receiver in _receivers)
|
||||
{
|
||||
<div class="sender-receiver-chip" style="--receiver-color:@receiver.Color;">
|
||||
<div class="sender-receiver-chip__body">
|
||||
<div class="sender-receiver-chip__email" title="@receiver.FullName">@receiver.Email</div>
|
||||
</div>
|
||||
|
||||
<button class="pdf-toolbar__btn pdf-toolbar__btn--reset sender-toolbar-action-btn sender-toolbar-action-btn--compact sender-receiver-chip__action sender-receiver-chip__remove-btn"
|
||||
@onclick="() => RemoveReceiverAsync(receiver)"
|
||||
title='@L("Delete", "Loeschen")'>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M4 4L12 12M12 4L4 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button class="pdf-toolbar__btn pdf-toolbar__btn--signature-change sender-toolbar-action-btn sender-toolbar-action-btn--compact sender-receiver-chip__action sender-receiver-chip__place-btn
|
||||
@(_pendingReceiverForPlacement?.Id == receiver.Id ? "sender-receiver-chip__place-btn--active" : "sender-receiver-chip__place-btn--idle")"
|
||||
@onclick="() => ActivatePlacementForReceiver(receiver)"
|
||||
style="--receiver-color:@receiver.Color;"
|
||||
title='@L("SenderEditor_PlaceSignatureField", "Signaturfeld platzieren")'>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (_receivers.Count == 0)
|
||||
@@ -221,41 +237,11 @@
|
||||
@L("SenderEditor_NoReceiversAdded", "Noch keine Empfänger hinzugefügt.")
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="sender-receivers-list">
|
||||
@foreach (var receiver in _receivers)
|
||||
{
|
||||
<div class="sender-receiver-chip"
|
||||
style="border-left: 3px solid @receiver.Color;">
|
||||
<div class="sender-receiver-chip__body">
|
||||
<div class="sender-receiver-chip__name">@receiver.FullName</div>
|
||||
<div class="sender-receiver-chip__email">@receiver.Email</div>
|
||||
@if (!string.IsNullOrWhiteSpace(receiver.PhoneNumber))
|
||||
{
|
||||
<div class="sender-receiver-chip__phone">@receiver.PhoneNumber</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<button class="pdf-toolbar__btn pdf-toolbar__btn--signature-change sender-toolbar-action-btn sender-toolbar-action-btn--compact
|
||||
@(_pendingReceiverForPlacement?.Id == receiver.Id ? "pdf-toolbar__btn--signature-change-active" : "")"
|
||||
@onclick="() => ActivatePlacementForReceiver(receiver)"
|
||||
style="@(_pendingReceiverForPlacement?.Id == receiver.Id ? $"background:{receiver.Color};color:#fff;border-color:{receiver.Color};" : $"color:{receiver.Color};border-color:{receiver.Color};")"
|
||||
title='@L("SenderEditor_PlaceSignatureField", "Signaturfeld platzieren")'>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10z" />
|
||||
</svg>
|
||||
<span class="pdf-toolbar__btn-text">@L("SenderEditor_AddSignature", "Signatur hinzufügen")</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Right: Buttons *@
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0;">
|
||||
<div class="sender-editor-actions">
|
||||
|
||||
<button class="pdf-toolbar__btn pdf-toolbar__btn--reset"
|
||||
@onclick="Cancel"
|
||||
@@ -264,14 +250,12 @@
|
||||
</button>
|
||||
|
||||
@* PDF Upload *@
|
||||
<label class="pdf-toolbar__btn"
|
||||
<label class="pdf-toolbar__btn sender-editor-upload-btn @((!_isPageReady || _isPdfUploadInProgress) ? "sender-editor-upload-btn--disabled" : string.Empty)"
|
||||
title='@L("UploadPdf", "PDF hochladen")'
|
||||
style="cursor: @((_isPageReady && !_isPdfUploadInProgress) ? "pointer" : "not-allowed");
|
||||
opacity:@((_isPageReady && !_isPdfUploadInProgress) ? "1" : "0.6");
|
||||
display: inline-flex; align-items: center; gap: 0.3rem; padding: 0.3rem 0.75rem; font-size: 0.75rem; font-weight: 500;">
|
||||
>
|
||||
@if (_isPdfUploadInProgress)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm" role="status" style="width:0.8rem;height:0.8rem;border-width:2px;"></span>
|
||||
<span class="spinner-border spinner-border-sm sender-editor-spinner" role="status"></span>
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -299,7 +283,7 @@
|
||||
<InputFile OnChange="OnPdfFileSelectedAsync"
|
||||
accept=".pdf"
|
||||
disabled="@(!_isPageReady || _isPdfUploadInProgress)"
|
||||
style="display: none;" />
|
||||
class="sender-editor-hidden-input" />
|
||||
</label>
|
||||
|
||||
@if (_pdfLoaded && _signatureFields.Count > 0)
|
||||
@@ -320,8 +304,7 @@
|
||||
title='@L("Save", "Speichern")'>
|
||||
@if (_isSaving)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-1" role="status"
|
||||
style="width:0.8rem;height:0.8rem;border-width:2px;"></span>
|
||||
<span class="spinner-border spinner-border-sm me-1 sender-editor-spinner" role="status"></span>
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -336,12 +319,10 @@
|
||||
<button class="pdf-toolbar__btn pdf-toolbar__btn--success"
|
||||
@onclick="SendAsync"
|
||||
disabled="@_isSaving"
|
||||
title='@L("SendEnvelope", "Umschlag senden")'
|
||||
style="background: linear-gradient(135deg,#059669,#047857); color:#fff; border-radius:6px; padding:0.3rem 0.75rem; font-size:0.75rem; font-weight:600; border:none;">
|
||||
title='@L("SendEnvelope", "Umschlag senden")'>
|
||||
@if (_isSaving)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-1" role="status"
|
||||
style="width:0.8rem;height:0.8rem;border-width:2px;"></span>
|
||||
<span class="spinner-border spinner-border-sm me-1 sender-editor-spinner" role="status"></span>
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -357,11 +338,9 @@
|
||||
@* Placement mode hint bar *@
|
||||
@if (_pendingReceiverForPlacement is not null)
|
||||
{
|
||||
<div style="background: @_pendingReceiverForPlacement.Color; color: white; font-size: 0.75rem; font-weight: 500;
|
||||
padding: 0.3rem 1.5rem; text-align: center; letter-spacing: 0.01em;">
|
||||
<div class="sender-editor-placement-hint" style="--placement-color:@_pendingReceiverForPlacement.Color;">
|
||||
📌 @L("SenderEditor_ClickDocumentFor", "Klicken Sie auf die Stelle im Dokument für") <strong>@_pendingReceiverForPlacement.FullName</strong>.
|
||||
<button @onclick="CancelPlacement"
|
||||
style="background:none;border:none;color:white;cursor:pointer;font-size:0.75rem;text-decoration:underline;padding:0;">
|
||||
<button @onclick="CancelPlacement" class="sender-editor-placement-cancel">
|
||||
@L("Cancel", "Abbrechen")
|
||||
</button>
|
||||
</div>
|
||||
@@ -369,30 +348,25 @@
|
||||
</div>
|
||||
|
||||
@* ── Content ── *@
|
||||
<div class="envelope-content" style="padding: 0; overflow: hidden;">
|
||||
<div class="envelope-content sender-editor-content">
|
||||
@if (!_pdfLoaded)
|
||||
{
|
||||
@* Empty state *@
|
||||
<div class="d-flex justify-content-center align-items-center h-100">
|
||||
<div class="text-center" style="max-width: 420px;">
|
||||
<div style="width: 72px; height: 72px; background: rgba(255,255,255,0.15);
|
||||
border-radius: 50%; display: flex; align-items: center;
|
||||
justify-content: center; margin: 0 auto 1.25rem;">
|
||||
<div class="text-center sender-editor-empty-state">
|
||||
<div class="sender-editor-empty-state__icon-wrap">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" fill="white" viewBox="0 0 16 16">
|
||||
<path d="M14 4.5V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h5.5L14 4.5zm-3 0A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4.5h-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h5 class="text-white mb-2">@L("SenderEditor_NoDocumentLoaded", "Kein Dokument geladen")</h5>
|
||||
<p class="text-white mb-4" style="opacity: 0.75; font-size: 0.85rem;">
|
||||
<p class="text-white mb-4 sender-editor-empty-state__description">
|
||||
@L("SenderEditor_UploadPdfToPlaceFields", "Laden Sie eine PDF-Datei hoch, um Signaturfelder zu platzieren.")
|
||||
</p>
|
||||
<label class="btn btn-light btn-sm"
|
||||
style="cursor: @((_isPageReady && !_isPdfUploadInProgress) ? "pointer" : "not-allowed");
|
||||
opacity:@((_isPageReady && !_isPdfUploadInProgress) ? "1" : "0.8");
|
||||
font-weight: 500;">
|
||||
<label class="btn btn-light btn-sm sender-editor-empty-state__upload @((!_isPageReady || _isPdfUploadInProgress) ? "sender-editor-empty-state__upload--disabled" : string.Empty)">
|
||||
@if (_isPdfUploadInProgress)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-1" role="status" style="width:0.8rem;height:0.8rem;border-width:2px;"></span>
|
||||
<span class="spinner-border spinner-border-sm me-1 sender-editor-spinner" role="status"></span>
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -420,7 +394,7 @@
|
||||
<InputFile OnChange="OnPdfFileSelectedAsync"
|
||||
accept=".pdf"
|
||||
disabled="@(!_isPageReady || _isPdfUploadInProgress)"
|
||||
style="display: none;" />
|
||||
class="sender-editor-hidden-input" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -544,18 +518,17 @@
|
||||
<BodyContentTemplate>
|
||||
@if (_saveErrorMessage is null)
|
||||
{
|
||||
<div style="display:flex;align-items:flex-start;gap:1rem;padding:0.5rem 0;">
|
||||
<div style="flex-shrink:0;width:40px;height:40px;background:#d1fae5;border-radius:50%;
|
||||
display:flex;align-items:center;justify-content:center;">
|
||||
<div class="sender-editor-save-result">
|
||||
<div class="sender-editor-save-result__icon sender-editor-save-result__icon--success">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#065f46" viewBox="0 0 16 16">
|
||||
<path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p style="margin:0 0 0.4rem;font-weight:600;color:#1f2937;font-size:0.95rem;">
|
||||
<p class="sender-editor-save-result__title">
|
||||
@_saveSuccessTitle
|
||||
</p>
|
||||
<p style="margin:0;color:#6b7280;font-size:0.85rem;line-height:1.5;">
|
||||
<p class="sender-editor-save-result__message">
|
||||
@_saveSuccessBody
|
||||
</p>
|
||||
</div>
|
||||
@@ -563,19 +536,18 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<div style="display:flex;align-items:flex-start;gap:1rem;padding:0.5rem 0;">
|
||||
<div style="flex-shrink:0;width:40px;height:40px;background:#fee2e2;border-radius:50%;
|
||||
display:flex;align-items:center;justify-content:center;">
|
||||
<div class="sender-editor-save-result">
|
||||
<div class="sender-editor-save-result__icon sender-editor-save-result__icon--error">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#991b1b" viewBox="0 0 16 16">
|
||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
|
||||
<path d="M7.002 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p style="margin:0 0 0.4rem;font-weight:600;color:#1f2937;font-size:0.95rem;">
|
||||
<p class="sender-editor-save-result__title">
|
||||
@L("SenderEditor_ErrorCreatingEnvelope", "Fehler beim Erstellen des Umschlags")
|
||||
</p>
|
||||
<p style="margin:0;color:#6b7280;font-size:0.85rem;line-height:1.5;word-break:break-word;">
|
||||
<p class="sender-editor-save-result__message sender-editor-save-result__message--break">
|
||||
@_saveErrorMessage
|
||||
</p>
|
||||
</div>
|
||||
@@ -583,30 +555,25 @@
|
||||
}
|
||||
</BodyContentTemplate>
|
||||
<FooterContentTemplate>
|
||||
<div style="display:flex;gap:0.5rem;justify-content:flex-end;width:100%;padding:0.5rem 0;">
|
||||
<div class="sender-editor-save-actions">
|
||||
@if (_saveErrorMessage is null)
|
||||
{
|
||||
<button class="btn btn-outline-secondary"
|
||||
@onclick="() => _savePopupVisible = false"
|
||||
style="border-radius:6px;padding:0.5rem 1.25rem;font-weight:500;">
|
||||
<button class="btn btn-outline-secondary sender-editor-save-actions__secondary"
|
||||
@onclick="() => _savePopupVisible = false">
|
||||
@L("Close", "Schließen")
|
||||
</button>
|
||||
|
||||
@if (_saveNavigateToDashboard)
|
||||
{
|
||||
<button class="btn btn-primary"
|
||||
@onclick="GoToDashboard"
|
||||
style="background:linear-gradient(135deg,#059669,#047857);border:none;border-radius:6px;
|
||||
padding:0.5rem 1.5rem;font-weight:600;color:#fff;">
|
||||
<button class="btn btn-primary sender-editor-save-actions__primary" @onclick="GoToDashboard">
|
||||
@L("SenderEditor_ToOverview", "Zur Übersicht")
|
||||
</button>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<button class="btn btn-outline-secondary"
|
||||
@onclick="() => _savePopupVisible = false"
|
||||
style="border-radius:6px;padding:0.5rem 1.25rem;font-weight:500;">
|
||||
<button class="btn btn-outline-secondary sender-editor-save-actions__secondary"
|
||||
@onclick="() => _savePopupVisible = false">
|
||||
@L("Close", "Schließen")
|
||||
</button>
|
||||
}
|
||||
@@ -618,10 +585,11 @@
|
||||
[SupplyParameterFromQuery(Name = "envelopeId")]
|
||||
public int? EnvelopeId { get; set; }
|
||||
|
||||
// ── Constants ──
|
||||
// Signature field size in PDF points (fixed): 1.77" × 1.96"
|
||||
const double SigWidthPt = 1.77 * 72; // 127.44 pt
|
||||
const double SigHeightPt = 1.96 * 72; // 141.12 pt
|
||||
// ── Signature field size (configurable) ──
|
||||
double SigWidthInches => SignatureFieldOptions.CurrentValue.Width;
|
||||
double SigHeightInches => SignatureFieldOptions.CurrentValue.Height;
|
||||
double SigWidthPt => SigWidthInches * 72.0;
|
||||
double SigHeightPt => SigHeightInches * 72.0;
|
||||
|
||||
// CssClass for JS click coordinate helper
|
||||
const string ViewerCssClass = "sender-editor-pdf-viewer";
|
||||
@@ -636,7 +604,6 @@
|
||||
int _pageCount;
|
||||
List<PdfPageImageDto> _pagePreviews = [];
|
||||
int? _loadedEnvelopeId;
|
||||
int? _editingEnvelopeId;
|
||||
bool _isLoadingEnvelopeForEdit;
|
||||
|
||||
List<SignatureFieldDraft> _signatureFields = [];
|
||||
@@ -669,7 +636,7 @@
|
||||
string _language = "de-DE";
|
||||
bool _useAccessCode;
|
||||
bool _tfaEnabled;
|
||||
int _certificationType = (int)CertificationType.AdvancedElectronicSignature;
|
||||
CertificationType _certificationType = CertificationType.AdvancedElectronicSignature;
|
||||
int _finalEmailToCreator = (int)FinalEmailType.No;
|
||||
int _finalEmailToReceivers = (int)FinalEmailType.No;
|
||||
bool _sendReminderEmails;
|
||||
@@ -691,8 +658,6 @@
|
||||
|
||||
static readonly System.ComponentModel.DataAnnotations.EmailAddressAttribute ReceiverEmailValidator = new();
|
||||
|
||||
bool IsEditMode => EnvelopeId.HasValue && EnvelopeId.Value > 0;
|
||||
|
||||
// ── Lifecycle ──
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
@@ -707,12 +672,6 @@
|
||||
try
|
||||
{
|
||||
_envelopeTypes = (await EnvelopeTypeService.GetAsync()).ToList();
|
||||
|
||||
if (!IsEditMode && _selectedEnvelopeTypeId is null && _envelopeTypes.Count > 0)
|
||||
{
|
||||
_selectedEnvelopeTypeId = _envelopeTypes[0].Id;
|
||||
ApplyEnvelopeTypeDefaults(_envelopeTypes[0]);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -723,28 +682,23 @@
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (EnvelopeId.HasValue && EnvelopeId.Value > 0)
|
||||
_editingEnvelopeId = EnvelopeId.Value;
|
||||
|
||||
if (IsEditMode && _loadedEnvelopeId != EnvelopeId)
|
||||
if (!EnvelopeId.HasValue || EnvelopeId.Value <= 0)
|
||||
{
|
||||
await LoadEnvelopeForEditAsync(EnvelopeId!.Value);
|
||||
NavigationManager.NavigateTo("/sender", replace: true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsEditMode)
|
||||
return;
|
||||
if (_loadedEnvelopeId != EnvelopeId && !_isLoadingEnvelopeForEdit)
|
||||
await LoadEnvelopeForEditAsync(EnvelopeId.Value);
|
||||
}
|
||||
|
||||
protected override Task OnAfterRenderAsync(bool firstRender)
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
_isPageReady = true;
|
||||
StateHasChanged();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
async Task LoadEnvelopeForEditAsync(int envelopeId)
|
||||
@@ -772,7 +726,7 @@
|
||||
if (_tfaEnabled)
|
||||
_useAccessCode = true;
|
||||
|
||||
_certificationType = envelope.CertificationType ?? (int)CertificationType.AdvancedElectronicSignature;
|
||||
_certificationType = envelope.CertificationType ?? CertificationType.AdvancedElectronicSignature;
|
||||
_finalEmailToCreator = envelope.FinalEmailToCreator ?? (int)FinalEmailType.No;
|
||||
_finalEmailToReceivers = envelope.FinalEmailToReceivers ?? (int)FinalEmailType.No;
|
||||
|
||||
@@ -788,12 +742,17 @@
|
||||
_receivers = envelopeReceivers
|
||||
.Select((er, index) => new ReceiverDraft(
|
||||
Guid.NewGuid(),
|
||||
er.ReceiverId,
|
||||
er.Name ?? er.Receiver?.EmailAddress ?? $"Receiver {er.ReceiverId}",
|
||||
er.Receiver?.EmailAddress ?? string.Empty,
|
||||
string.Empty,
|
||||
ReceiverPalette[index % ReceiverPalette.Length]))
|
||||
.ToList();
|
||||
|
||||
var receiverDraftByReceiverId = _receivers
|
||||
.Where(r => r.ReceiverId.HasValue)
|
||||
.ToDictionary(r => r.ReceiverId!.Value, r => r);
|
||||
|
||||
var receiverById = envelopeReceivers.ToDictionary(er => er.ReceiverId, er => er);
|
||||
var colorByEmail = _receivers
|
||||
.Where(r => !string.IsNullOrWhiteSpace(r.Email))
|
||||
@@ -815,10 +774,17 @@
|
||||
? receiverColor
|
||||
: ReceiverPalette[Math.Abs(element.ReceiverId) % ReceiverPalette.Length];
|
||||
|
||||
var receiverDraft = receiverDraftByReceiverId.TryGetValue(element.ReceiverId, out var draft)
|
||||
? draft
|
||||
: _receivers.FirstOrDefault(r =>
|
||||
(!string.IsNullOrWhiteSpace(receiverEmail) && string.Equals(r.Email, receiverEmail, StringComparison.OrdinalIgnoreCase))
|
||||
|| string.Equals(r.FullName, receiverName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
return new SignatureFieldDraft(
|
||||
XPt: element.X * 72.0,
|
||||
YPt: element.Y * 72.0,
|
||||
Page: element.Page,
|
||||
ReceiverDraftId: receiverDraft?.Id ?? Guid.Empty,
|
||||
ReceiverName: receiverName,
|
||||
Color: color);
|
||||
})
|
||||
@@ -854,7 +820,6 @@
|
||||
_fileName = $"Envelope-{envelopeId}.pdf";
|
||||
_pendingReceiverForPlacement = null;
|
||||
_loadedEnvelopeId = envelopeId;
|
||||
_editingEnvelopeId = envelopeId;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -1054,6 +1019,7 @@
|
||||
XPt: xPt,
|
||||
YPt: yPt,
|
||||
Page: page1Based,
|
||||
ReceiverDraftId: _pendingReceiverForPlacement.Id,
|
||||
ReceiverName: _pendingReceiverForPlacement.FullName,
|
||||
Color: _pendingReceiverForPlacement.Color);
|
||||
|
||||
@@ -1137,12 +1103,17 @@
|
||||
}
|
||||
|
||||
async Task SaveDraftAsync()
|
||||
{
|
||||
await SaveDraftCoreAsync(navigateToDashboard: true);
|
||||
}
|
||||
|
||||
async Task<int?> SaveDraftCoreAsync(bool navigateToDashboard)
|
||||
{
|
||||
if (!ValidateSettings(out var settingsError))
|
||||
{
|
||||
_saveErrorMessage = settingsError;
|
||||
_savePopupVisible = true;
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
_isSaving = true;
|
||||
@@ -1151,9 +1122,8 @@
|
||||
|
||||
try
|
||||
{
|
||||
var previousEnvelopeId = EnvelopeId;
|
||||
var command = BuildCreateEnvelopeCommand(send: false);
|
||||
Logger.LogInformation("[SenderEditor] Saving draft. EnvelopeId={EnvelopeId} IsEditMode={IsEditMode}", command.EnvelopeId, IsEditMode);
|
||||
Logger.LogInformation("[SenderEditor] Saving draft. EnvelopeId={EnvelopeId}", command.EnvelopeId);
|
||||
var result = await EnvelopeService.UpsertAsync(command);
|
||||
|
||||
if (result is null)
|
||||
@@ -1161,24 +1131,23 @@
|
||||
|
||||
EnvelopeId = result.Id;
|
||||
_loadedEnvelopeId = result.Id;
|
||||
_editingEnvelopeId = result.Id;
|
||||
|
||||
if (previousEnvelopeId != result.Id)
|
||||
{
|
||||
NavigationManager.NavigateTo($"/sender/editor?envelopeId={result.Id}", forceLoad: false);
|
||||
}
|
||||
if (navigateToDashboard)
|
||||
NavigationManager.NavigateTo("/sender", forceLoad: false);
|
||||
|
||||
NavigationManager.NavigateTo("/sender", forceLoad: false);
|
||||
return result.Id;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "[SenderEditor] Failed to save draft");
|
||||
_saveErrorMessage = ex.Message;
|
||||
_savePopupVisible = true;
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isSaving = false;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1221,7 +1190,7 @@
|
||||
|
||||
// Warn if any receiver has no signature field, but don't block
|
||||
var receiversWithoutField = _receivers
|
||||
.Where(r => !_signatureFields.Any(f => f.ReceiverName == r.FullName))
|
||||
.Where(r => !_signatureFields.Any(f => f.ReceiverDraftId == r.Id))
|
||||
.ToList();
|
||||
if (receiversWithoutField.Count > 0)
|
||||
{
|
||||
@@ -1236,21 +1205,10 @@
|
||||
|
||||
try
|
||||
{
|
||||
var envelopeId = _editingEnvelopeId
|
||||
?? EnvelopeId
|
||||
?? _loadedEnvelopeId
|
||||
?? GetEnvelopeIdFromCurrentUri();
|
||||
var envelopeId = EnvelopeId ?? _loadedEnvelopeId;
|
||||
|
||||
if (!envelopeId.HasValue || envelopeId.Value <= 0)
|
||||
{
|
||||
var saveResult = await EnvelopeService.UpsertAsync(BuildCreateEnvelopeCommand(send: false))
|
||||
?? throw new InvalidOperationException("Draft save did not return a result.");
|
||||
|
||||
envelopeId = saveResult.Id;
|
||||
EnvelopeId = saveResult.Id;
|
||||
_loadedEnvelopeId = saveResult.Id;
|
||||
_editingEnvelopeId = saveResult.Id;
|
||||
}
|
||||
throw new InvalidOperationException("Envelope id is missing for send.");
|
||||
|
||||
var sendResult = await EnvelopeService.UpsertAsync(BuildCreateEnvelopeCommand(send: true) with { EnvelopeId = envelopeId })
|
||||
?? throw new InvalidOperationException("Send did not return a result.");
|
||||
@@ -1281,13 +1239,10 @@
|
||||
{
|
||||
NormalizeSettings();
|
||||
|
||||
var envelopeId = _editingEnvelopeId
|
||||
?? EnvelopeId
|
||||
?? _loadedEnvelopeId
|
||||
?? GetEnvelopeIdFromCurrentUri();
|
||||
var envelopeId = EnvelopeId ?? _loadedEnvelopeId;
|
||||
|
||||
if (IsEditMode && envelopeId is null)
|
||||
throw new InvalidOperationException("Edit mode envelope id is missing.");
|
||||
if (envelopeId is null)
|
||||
throw new InvalidOperationException("Envelope id is missing.");
|
||||
|
||||
var docBase64 = _originalPdfBytes is { Length: > 0 }
|
||||
? Convert.ToBase64String(_originalPdfBytes)
|
||||
@@ -1296,7 +1251,7 @@
|
||||
var receiverCommands = _receivers.Select(receiver =>
|
||||
{
|
||||
var fields = _signatureFields
|
||||
.Where(f => f.ReceiverName == receiver.FullName)
|
||||
.Where(f => f.ReceiverDraftId == receiver.Id)
|
||||
.Select(f => new CreateEnvelopeFieldDto(
|
||||
X: f.XPt / 72.0,
|
||||
Y: f.YPt / 72.0,
|
||||
@@ -1375,28 +1330,6 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
int? GetEnvelopeIdFromCurrentUri()
|
||||
{
|
||||
try
|
||||
{
|
||||
var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri);
|
||||
if (string.IsNullOrWhiteSpace(uri.Query))
|
||||
return null;
|
||||
|
||||
var query = QueryHelpers.ParseQuery(uri.Query);
|
||||
if (!query.TryGetValue("envelopeId", out var raw))
|
||||
return null;
|
||||
|
||||
return int.TryParse(raw.ToString(), out var id) && id > 0
|
||||
? id
|
||||
: null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Receiver popup ──
|
||||
void OpenAddReceiverPopup()
|
||||
{
|
||||
@@ -1558,18 +1491,49 @@
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
_receivers.Add(new ReceiverDraft(Guid.NewGuid(), fullName, email, phoneNumber,
|
||||
_receivers.Add(new ReceiverDraft(Guid.NewGuid(), null, fullName, email, phoneNumber,
|
||||
ReceiverPalette[_receivers.Count % ReceiverPalette.Length]));
|
||||
CloseAddReceiverPopup();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
async Task RemoveReceiverAsync(ReceiverDraft receiver)
|
||||
{
|
||||
if (_isSaving)
|
||||
return;
|
||||
|
||||
var envelopeId = EnvelopeId ?? _loadedEnvelopeId;
|
||||
|
||||
if (envelopeId is int persistedEnvelopeId && persistedEnvelopeId > 0 && receiver.ReceiverId is int persistedReceiverId && persistedReceiverId > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
await EnvelopeReceiverService.DeleteAsync(persistedEnvelopeId, persistedReceiverId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "[SenderEditor] Failed to remove receiver {ReceiverId} from envelope {EnvelopeId}", persistedReceiverId, persistedEnvelopeId);
|
||||
_saveErrorMessage = string.Format(
|
||||
L("SenderEditor_RemoveReceiverError", "Empfaenger konnte nicht entfernt werden: {0}"),
|
||||
ex.Message);
|
||||
_savePopupVisible = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_receivers.RemoveAll(r => r.Id == receiver.Id);
|
||||
_signatureFields.RemoveAll(f => f.ReceiverDraftId == receiver.Id);
|
||||
|
||||
if (_pendingReceiverForPlacement?.Id == receiver.Id)
|
||||
_pendingReceiverForPlacement = null;
|
||||
}
|
||||
|
||||
// ── Models ──
|
||||
record SignatureFieldDraft(double XPt, double YPt, int Page, string ReceiverName, string Color);
|
||||
record SignatureFieldDraft(double XPt, double YPt, int Page, Guid ReceiverDraftId, string ReceiverName, string Color);
|
||||
|
||||
record NormalisedCoords(double NormX, double NormY, int PageIndex);
|
||||
|
||||
record ReceiverDraft(Guid Id, string FullName, string Email, string PhoneNumber, string Color);
|
||||
record ReceiverDraft(Guid Id, int? ReceiverId, string FullName, string Email, string PhoneNumber, string Color);
|
||||
|
||||
// ── Receiver colour palette (cycles when > 8 receivers) ──
|
||||
static readonly string[] ReceiverPalette =
|
||||
@@ -1586,7 +1550,11 @@
|
||||
|
||||
string L(string key, string fallback)
|
||||
{
|
||||
var value = UiLocalizer[key];
|
||||
return value.ResourceNotFound ? fallback : value.Value;
|
||||
var modelValue = ModelLocalizer[key];
|
||||
if (!modelValue.ResourceNotFound)
|
||||
return modelValue.Value;
|
||||
|
||||
var uiValue = UiLocalizer[key];
|
||||
return uiValue.ResourceNotFound ? fallback : uiValue.Value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,17 +7,22 @@
|
||||
@using EnvelopeGenerator.Server.Client.Services
|
||||
@using EnvelopeGenerator.Application.Common.Dto
|
||||
@using EnvelopeGenerator.Application.Common.Dto.History
|
||||
@using EnvelopeGenerator.Application.Envelopes.Commands
|
||||
@using EnvelopeGenerator.Application.Resources
|
||||
@using EnvelopeGenerator.Domain.Resources
|
||||
@using EnvelopeGenerator.Server.Client.Components.Sender
|
||||
@using Microsoft.Extensions.Localization
|
||||
@inject EnvelopeGenerator.Server.Client.Services.EnvelopeService EnvelopeService
|
||||
@inject EnvelopeGenerator.Server.Client.Services.DocumentService DocumentService
|
||||
@inject EnvelopeGenerator.Server.Client.Services.PdfRenderService PdfRenderService
|
||||
@inject EnvelopeGenerator.Server.Client.Services.EnvelopeTypeService EnvelopeTypeService
|
||||
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
|
||||
@inject EnvelopeGenerator.Server.Client.Services.ThemeService ThemeService
|
||||
@inject EnvelopeGenerator.Server.Client.Services.UserPreferencesService UserPreferencesService
|
||||
@inject EnvelopeGenerator.Server.Client.Services.CustomImagesService CustomImagesService
|
||||
@inject ILogger<EnvelopeSenderPage> Logger
|
||||
@inject EnvelopeGenerator.Server.Client.Services.PdfSignatureService PdfSignatureService
|
||||
@inject EnvelopeGenerator.Server.Client.Services.EnvelopeReceiverService EnvelopeReceiverService
|
||||
@inject ILogger<SenderPage> Logger
|
||||
@inject NavigationManager Navigation
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject AppVersionService AppVersion
|
||||
@@ -311,12 +316,13 @@
|
||||
@foreach (var receiver in envelope.EnvelopeReceivers)
|
||||
{
|
||||
<div style="display: flex; align-items: center; gap: 1rem; padding: 0.5rem; background: white; border-radius: 6px; border: 1px solid #e5e7eb;">
|
||||
@{
|
||||
var isRejected = IsReceiverRejected(envelope, receiver);
|
||||
var isSigned = IsReceiverSigned(envelope, receiver);
|
||||
var accessCode = receiver.AccessCode;
|
||||
var hasAccessCode = !string.IsNullOrWhiteSpace(accessCode);
|
||||
}
|
||||
@{
|
||||
var isRejected = IsReceiverRejected(envelope, receiver);
|
||||
var isSigned = IsReceiverSigned(envelope, receiver);
|
||||
var accessCode = receiver.AccessCode;
|
||||
var hasAccessCode = !string.IsNullOrWhiteSpace(accessCode);
|
||||
var hasReceiverEnvelopeUrl = CanOpenReceiverEnvelopeUrl(envelope, receiver);
|
||||
}
|
||||
<span class="receiver-badge receiver-badge--@(isRejected ? "unsigned" : (isSigned ? "signed" : "unsigned"))" style="min-width: 100px;">
|
||||
@if (isRejected)
|
||||
{
|
||||
@@ -345,27 +351,40 @@
|
||||
<strong style="color: #1f2937;">@receiver.Name</strong>
|
||||
<span style="color: #6b7280; margin-left: 0.5rem;">@receiver.Receiver?.EmailAddress</span>
|
||||
</div>
|
||||
<div class="sender-access-code">
|
||||
<span class="sender-access-code__label">@L("AccessCode", "Access code")</span>
|
||||
<div class="sender-access-code__row">
|
||||
<input class="sender-access-code__value"
|
||||
<div class="sender-access-code">
|
||||
<span class="sender-access-code__label">@L("AccessCode", "Access code")</span>
|
||||
<div class="sender-access-code__row">
|
||||
<input class="sender-access-code__value"
|
||||
value="@(hasAccessCode ? accessCode : "-")"
|
||||
readonly
|
||||
title="@L("AccessCode", "Access code")" />
|
||||
<button type="button"
|
||||
class="sender-access-code__copy"
|
||||
disabled="@(!hasAccessCode)"
|
||||
@onclick='() => CopyAccessCodeAsync(accessCode)'
|
||||
title="@L("Copy", "Copy")">
|
||||
<button type="button"
|
||||
class="sender-access-code__copy"
|
||||
disabled="@(!hasAccessCode)"
|
||||
@onclick='() => CopyAccessCodeAsync(accessCode)'
|
||||
title="@L("Copy", "Copy")">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M10 1.5A1.5 1.5 0 0 1 11.5 3v1H13A1.5 1.5 0 0 1 14.5 5.5v8A1.5 1.5 0 0 1 13 15H5A1.5 1.5 0 0 1 3.5 13.5V12H3A1.5 1.5 0 0 1 1.5 10.5v-8A1.5 1.5 0 0 1 3 1h7zm.5 2A.5.5 0 0 0 10 3H3a.5.5 0 0 0-.5.5v7A.5.5 0 0 0 3 11h.5V5.5A1.5 1.5 0 0 1 5 4h5.5v-.5zM5 5a.5.5 0 0 0-.5.5v8a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5v-8A.5.5 0 0 0 13 5H5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<path d="M10 1.5A1.5 1.5 0 0 1 11.5 3v1H13A1.5 1.5 0 0 1 14.5 5.5v8A1.5 1.5 0 0 1 13 15H5A1.5 1.5 0 0 1 3.5 13.5V12H3A1.5 1.5 0 0 1 1.5 10.5v-8A1.5 1.5 0 0 1 3 1h7zm.5 2A.5.5 0 0 0 10 3H3a.5.5 0 0 0-.5.5v7A.5.5 0 0 0 3 11h.5V5.5A1.5 1.5 0 0 1 5 4h5.5v-.5zM5 5a.5.5 0 0 0-.5.5v8a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5v-8A.5.5 0 0 0 13 5H5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
@if (_canUseReceiverEnvelopeUrl)
|
||||
{
|
||||
<button type="button"
|
||||
class="sender-access-code__copy"
|
||||
disabled="@(!hasReceiverEnvelopeUrl)"
|
||||
@onclick='() => OpenReceiverEnvelopeUrlAsync(envelope, receiver)'
|
||||
title="@L("Open", "Open")">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M6.364 13.5a1.5 1.5 0 0 1-1.5-1.5V4a1.5 1.5 0 0 1 1.5-1.5h4a.5.5 0 0 1 0 1h-4a.5.5 0 0 0-.5.5v8a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5v-4a.5.5 0 0 1 1 0v4a1.5 1.5 0 0 1-1.5 1.5h-8z"/>
|
||||
<path fill-rule="evenodd" d="M8.5 1a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 .5.5v5a.5.5 0 0 1-1 0V2.707L8.854 7.354a.5.5 0 1 1-.708-.708L12.793 2H9a.5.5 0 0 1-.5-.5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -414,9 +433,14 @@
|
||||
|
||||
</div>
|
||||
|
||||
<CreateEnvelopeWizardPopup Visible="@_createWizardVisible"
|
||||
VisibleChanged="OnCreateWizardVisibleChangedAsync"
|
||||
EnvelopeTypes="@_envelopeTypes"
|
||||
StartEditorRequested="StartEditorFromWizardAsync" />
|
||||
|
||||
@* ── PDF Vorschau Popup ────────────────────────────────────────────────── *@
|
||||
<DxPopup @bind-Visible="@_previewVisible"
|
||||
HeaderText="@($"{L("Preview", "Vorschau")} — {_selectedEnvelope?.Title} (#{_selectedEnvelope?.Id})")"
|
||||
HeaderText="@($"{(_activeTab == "completed" && _selectedEnvelope?.Status == EnvelopeStatus.EnvelopeCompletelySigned ? L("SignedPdf", "Signiertes PDF") : L("Preview", "Vorschau"))} — {_selectedEnvelope?.Title} (#{_selectedEnvelope?.Id})")"
|
||||
Width="80vw"
|
||||
MaxWidth="80vw"
|
||||
Height="90vh"
|
||||
@@ -535,6 +559,7 @@
|
||||
private string _activeTab = "active";
|
||||
private bool _isLoading = true;
|
||||
private bool _isLoggingOut = false;
|
||||
private bool _canUseReceiverEnvelopeUrl = false;
|
||||
private string? _errorMessage;
|
||||
private DxGrid? _grid;
|
||||
|
||||
@@ -567,6 +592,9 @@
|
||||
private string _deleteReason = string.Empty;
|
||||
private string? _deleteErrorMessage;
|
||||
|
||||
private bool _createWizardVisible = false;
|
||||
private IReadOnlyList<EnvelopeTypeDto> _envelopeTypes = [];
|
||||
|
||||
// Stable keys used to store/restore each tab's layout in UserPreferences.GridLayouts
|
||||
private const string GridKeyActive = "sender.active-envelopes";
|
||||
private const string GridKeyCompleted = "sender.completed-envelopes";
|
||||
@@ -591,11 +619,23 @@
|
||||
try { await ThemeService.LoadFromServerAsync(); }
|
||||
catch { /* swallow — default theme remains active */ }
|
||||
|
||||
_canUseReceiverEnvelopeUrl = await EnvelopeReceiverService.GetReceiverEnvelopeUrlFeatureEnabledAsync();
|
||||
|
||||
// Load logo config — authenticated, so this should succeed
|
||||
var logos = await CustomImagesService.GetAsync();
|
||||
_appLogo = logos.App;
|
||||
_companyLogo = logos.Company;
|
||||
|
||||
try
|
||||
{
|
||||
_envelopeTypes = (await EnvelopeTypeService.GetAsync()).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Failed to load envelope types for create wizard.");
|
||||
_envelopeTypes = [];
|
||||
}
|
||||
|
||||
await LoadEnvelopesAsync();
|
||||
}
|
||||
|
||||
@@ -607,7 +647,7 @@
|
||||
|
||||
try
|
||||
{
|
||||
_allEnvelopes = await EnvelopeService.GetAsync() ?? [];
|
||||
_allEnvelopes = await EnvelopeService.GetAsync(includeDocAndDocRes: false) ?? [];
|
||||
|
||||
// Split into active and completed based on effective status
|
||||
// (history-aware: rejected envelopes should not stay in active tab).
|
||||
@@ -693,7 +733,55 @@
|
||||
|
||||
void CreateEnvelope()
|
||||
{
|
||||
Navigation.NavigateTo("/sender/editor");
|
||||
_createWizardVisible = true;
|
||||
}
|
||||
|
||||
Task OnCreateWizardVisibleChangedAsync(bool visible)
|
||||
{
|
||||
_createWizardVisible = visible;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
async Task StartEditorFromWizardAsync(CreateEnvelopeWizardResult result)
|
||||
{
|
||||
var docBase64 = result.DocumentBytes is { Length: > 0 }
|
||||
? Convert.ToBase64String(result.DocumentBytes)
|
||||
: null;
|
||||
|
||||
var command = new CreateEnvelopeCommand
|
||||
{
|
||||
EnvelopeTypeId = result.EnvelopeTypeId,
|
||||
Title = result.Title,
|
||||
Message = result.Message,
|
||||
Language = string.IsNullOrWhiteSpace(result.Language) ? "de-DE" : result.Language,
|
||||
UseAccessCode = result.UseAccessCode,
|
||||
TFAEnabled = result.TFAEnabled,
|
||||
CertificationType = result.CertificationType,
|
||||
FinalEmailToCreator = result.FinalEmailToCreator,
|
||||
FinalEmailToReceivers = result.FinalEmailToReceivers,
|
||||
SendReminderEmails = result.SendReminderEmails,
|
||||
FirstReminderDays = result.FirstReminderDays,
|
||||
ReminderIntervalDays = result.ReminderIntervalDays,
|
||||
ExpiresWhenDays = result.ExpiresWhenDays,
|
||||
ExpiresWarningWhenDays = result.ExpiresWarningWhenDays,
|
||||
Send = false,
|
||||
Document = string.IsNullOrWhiteSpace(docBase64) ? null : new CreateEnvelopeDocumentDto(docBase64),
|
||||
Receivers = result.Receivers
|
||||
.Select(x => new CreateEnvelopeReceiverDto(x.EmailAddress, x.FullName, null, []))
|
||||
.ToList()
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var envelope = await EnvelopeService.UpsertAsync(command)
|
||||
?? throw new InvalidOperationException("Envelope creation returned no result.");
|
||||
Navigation.NavigateTo($"/sender/editor?envelopeId={envelope.Id}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Failed to create envelope from wizard.");
|
||||
_errorMessage = ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
void EditEnvelope()
|
||||
@@ -932,6 +1020,35 @@
|
||||
}
|
||||
}
|
||||
|
||||
async Task OpenReceiverEnvelopeUrlAsync(EnvelopeDto envelope, EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto receiver)
|
||||
{
|
||||
if (!_canUseReceiverEnvelopeUrl)
|
||||
return;
|
||||
|
||||
if (!CanOpenReceiverEnvelopeUrl(envelope, receiver))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var receiverEnvelopeUrl = await EnvelopeReceiverService.GetReceiverEnvelopeUrlAsync(
|
||||
envelope.Uuid,
|
||||
receiver.Receiver!.Signature!);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(receiverEnvelopeUrl))
|
||||
return;
|
||||
|
||||
await JSRuntime.InvokeVoidAsync("open", receiverEnvelopeUrl, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
catch
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("console.warn", "[Sender] Failed to open receiver envelope URL.");
|
||||
}
|
||||
}
|
||||
|
||||
static bool CanOpenReceiverEnvelopeUrl(EnvelopeDto envelope, EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto receiver)
|
||||
=> !string.IsNullOrWhiteSpace(envelope.Uuid)
|
||||
&& !string.IsNullOrWhiteSpace(receiver.Receiver?.Signature);
|
||||
|
||||
void OnCustomizeElement(GridCustomizeElementEventArgs e)
|
||||
{
|
||||
// Future: Add custom row coloring based on status if needed
|
||||
@@ -970,12 +1087,14 @@
|
||||
|
||||
private async Task OpenPreviewAsync(EnvelopeDto envelope)
|
||||
{
|
||||
var showSigned = _activeTab == "completed" && envelope.Status == EnvelopeStatus.EnvelopeCompletelySigned;
|
||||
|
||||
// Unmount the viewer first — let the previous instance fully dispose
|
||||
_previewViewerMounted = false;
|
||||
_previewPdfBytes = null;
|
||||
_previewPageCount = 0;
|
||||
_previewPagePreviews = [];
|
||||
_previewFileName = $"envelope-{envelope.Id}.pdf";
|
||||
_previewFileName = showSigned ? $"envelope-{envelope.Id}-signed.pdf" : $"envelope-{envelope.Id}.pdf";
|
||||
_previewErrorMessage = null;
|
||||
_previewLoading = true;
|
||||
_previewVisible = true;
|
||||
@@ -983,7 +1102,10 @@
|
||||
|
||||
try
|
||||
{
|
||||
_previewPdfBytes = await DocumentService.GetSenderDocumentBytesByEnvelopeIdAsync(envelope.Id);
|
||||
_previewPdfBytes = showSigned
|
||||
? await PdfSignatureService.GetSignedPdfAsync(envelope.Id)
|
||||
: await DocumentService.GetSenderDocumentBytesByEnvelopeIdAsync(envelope.Id);
|
||||
|
||||
if (_previewPdfBytes is null || _previewPdfBytes.Length == 0)
|
||||
{
|
||||
_previewErrorMessage = L("DocumentCouldNotBeLoaded", "Dokument konnte nicht geladen werden");
|
||||
@@ -1,11 +1,13 @@
|
||||
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
||||
using EnvelopeGenerator.Server.Client.Services;
|
||||
using EnvelopeGenerator.Server.Client.Options;
|
||||
using MudBlazor.Services;
|
||||
using DevExpress.Blazor.Reporting;
|
||||
using DevExpress.XtraReports.Web.Extensions;
|
||||
using DevExpress.DataAccess.Web;
|
||||
using DevExpress.XtraReports.Services;
|
||||
using System.Globalization;
|
||||
using EnvelopeGenerator.Application.Common.Configurations;
|
||||
|
||||
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
||||
|
||||
@@ -15,6 +17,7 @@ CultureInfo.DefaultThreadCurrentCulture = cultureInfo;
|
||||
CultureInfo.DefaultThreadCurrentUICulture = cultureInfo;
|
||||
|
||||
builder.Services.AddLocalization();
|
||||
builder.Services.AddMudServices();
|
||||
|
||||
// Named HttpClient for API calls (both for services and DevExpress components)
|
||||
builder.Services.AddHttpClient("EnvelopeGenerator.Server", client =>
|
||||
@@ -31,6 +34,8 @@ builder.Services.AddScoped(sp => new HttpClient
|
||||
// Configuration Options
|
||||
builder.Services.Configure<PdfViewerOptions>(opts =>
|
||||
builder.Configuration.GetSection(PdfViewerOptions.SectionName).Bind(opts));
|
||||
builder.Services.Configure<SignatureFieldParams>(opts =>
|
||||
builder.Configuration.GetSection(nameof(SignatureFieldParams)).Bind(opts));
|
||||
|
||||
// Business Services
|
||||
builder.Services.AddScoped<DocumentService>();
|
||||
@@ -45,6 +50,7 @@ builder.Services.AddScoped<EnvelopeTypeService>();
|
||||
builder.Services.AddScoped<LocalizationService>();
|
||||
builder.Services.AddScoped<ReceiverService>();
|
||||
builder.Services.AddScoped<PdfRenderService>();
|
||||
builder.Services.AddScoped<CreateEnvelopeBootstrapStorageService>();
|
||||
|
||||
// UI Preferences & Theme
|
||||
builder.Services.AddScoped<UserPreferencesService>();
|
||||
@@ -52,6 +58,7 @@ builder.Services.AddScoped<ThemeService>();
|
||||
|
||||
// Logo configuration (loaded once after login, in-memory cached)
|
||||
builder.Services.AddScoped<CustomImagesService>();
|
||||
builder.Services.AddScoped<PdfSignatureService>();
|
||||
|
||||
// DevExpress WASM
|
||||
builder.Services.AddDevExpressWebAssemblyBlazorPdfViewer();
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Text.Json;
|
||||
using EnvelopeGenerator.Domain.Constants;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace EnvelopeGenerator.Server.Client.Services;
|
||||
|
||||
public sealed class CreateEnvelopeBootstrapStorageService(IJSRuntime jsRuntime)
|
||||
{
|
||||
private const string StorageKeyPrefix = "sender.createEnvelope.bootstrap.";
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public async Task<string> StoreAsync(CreateEnvelopeBootstrapPayload payload)
|
||||
{
|
||||
var token = Guid.NewGuid().ToString("N");
|
||||
var key = BuildStorageKey(token);
|
||||
var json = JsonSerializer.Serialize(payload, JsonOptions);
|
||||
await jsRuntime.InvokeVoidAsync("sessionStorage.setItem", key, json);
|
||||
return token;
|
||||
}
|
||||
|
||||
public async Task<CreateEnvelopeBootstrapPayload?> ConsumeAsync(string? token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
return null;
|
||||
|
||||
var key = BuildStorageKey(token);
|
||||
var raw = await jsRuntime.InvokeAsync<string?>("sessionStorage.getItem", key);
|
||||
await jsRuntime.InvokeVoidAsync("sessionStorage.removeItem", key);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<CreateEnvelopeBootstrapPayload>(raw, JsonOptions);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildStorageKey(string token) => $"{StorageKeyPrefix}{token}";
|
||||
}
|
||||
|
||||
public sealed record CreateEnvelopeBootstrapPayload(
|
||||
string Title,
|
||||
string Message,
|
||||
int? EnvelopeTypeId,
|
||||
string Language,
|
||||
bool UseAccessCode,
|
||||
bool TFAEnabled,
|
||||
CertificationType CertificationType,
|
||||
int FinalEmailToCreator,
|
||||
int FinalEmailToReceivers,
|
||||
bool SendReminderEmails,
|
||||
int FirstReminderDays,
|
||||
int ReminderIntervalDays,
|
||||
int ExpiresWhenDays,
|
||||
int ExpiresWarningWhenDays,
|
||||
IReadOnlyList<CreateEnvelopeBootstrapReceiver> Receivers,
|
||||
byte[]? DocumentBytes,
|
||||
string? DocumentFileName);
|
||||
|
||||
public sealed record CreateEnvelopeBootstrapReceiver(string FullName, string EmailAddress);
|
||||
@@ -2,8 +2,9 @@ using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using EnvelopeGenerator.Application.EnvelopeReceivers.Commands;
|
||||
using EnvelopeGenerator.Application.EnvelopeReceivers.Commands.CreateEnvelopeReceiver;
|
||||
using EnvelopeGenerator.Server.Client.Models;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
|
||||
namespace EnvelopeGenerator.Server.Client.Services;
|
||||
|
||||
@@ -15,6 +16,8 @@ namespace EnvelopeGenerator.Server.Client.Services;
|
||||
public class EnvelopeReceiverService(IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
|
||||
private sealed record ReceiverEnvelopeUrlResponse(string Url);
|
||||
private sealed record ReceiverEnvelopeUrlEnabledResponse(bool Enabled);
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the envelope receiver data for the given envelope key from the API.
|
||||
@@ -63,4 +66,59 @@ public class EnvelopeReceiverService(IHttpClientFactory httpClientFactory)
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<CreateEnvelopeReceiverResponse>(_jsonOptions, cancel);
|
||||
}
|
||||
|
||||
public async Task<string?> GetReceiverEnvelopeUrlAsync(string envelopeUuid, string receiverSignature, CancellationToken cancel = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(envelopeUuid) || string.IsNullOrWhiteSpace(receiverSignature))
|
||||
return null;
|
||||
|
||||
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||
var url = $"/api/EnvelopeReceiver/receiver-envelope-url?envelopeUuid={Uri.EscapeDataString(envelopeUuid)}&receiverSignature={Uri.EscapeDataString(receiverSignature)}";
|
||||
var response = await http.GetAsync(url, cancel);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return null;
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<ReceiverEnvelopeUrlResponse>(_jsonOptions, cancel);
|
||||
return payload?.Url;
|
||||
}
|
||||
|
||||
public async Task<bool> GetReceiverEnvelopeUrlFeatureEnabledAsync(CancellationToken cancel = default)
|
||||
{
|
||||
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||
var response = await http.GetAsync("/api/EnvelopeReceiver/receiver-envelope-url/enabled", cancel);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return false;
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<ReceiverEnvelopeUrlEnabledResponse>(_jsonOptions, cancel);
|
||||
return payload?.Enabled == true;
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int envelopeId, int receiverId, CancellationToken cancel = default)
|
||||
{
|
||||
if (envelopeId <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(envelopeId));
|
||||
if (receiverId <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(receiverId));
|
||||
|
||||
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||
var url = QueryHelpers.AddQueryString("/api/EnvelopeReceiver", new Dictionary<string, string?>
|
||||
{
|
||||
["EnvelopeId"] = envelopeId.ToString(),
|
||||
["ReceiverId"] = receiverId.ToString()
|
||||
});
|
||||
|
||||
var response = await http.DeleteAsync(url, cancel);
|
||||
if (response.IsSuccessStatusCode)
|
||||
return;
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync(cancel);
|
||||
var statusCode = (int)response.StatusCode;
|
||||
var reasonPhrase = response.ReasonPhrase ?? "Unknown error";
|
||||
throw new HttpRequestException(
|
||||
$"Failed to remove receiver. Status: {statusCode} ({reasonPhrase}){(string.IsNullOrWhiteSpace(body) ? string.Empty : $" - {body}")}",
|
||||
null,
|
||||
response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ public class EnvelopeService(IHttpClientFactory clientFactory)
|
||||
string? uuid = null,
|
||||
bool? onlyActive = null,
|
||||
bool? onlyCompleted = null,
|
||||
bool? includeDocAndDocRes = null,
|
||||
CancellationToken cancel = default)
|
||||
{
|
||||
var baseUrl = $"/api/Envelope";
|
||||
@@ -46,6 +47,10 @@ public class EnvelopeService(IHttpClientFactory clientFactory)
|
||||
{
|
||||
queryParams["OnlyCompleted"] = onlyCompleted.Value.ToString();
|
||||
}
|
||||
if (includeDocAndDocRes.HasValue)
|
||||
{
|
||||
queryParams["IncludeDocAndDocRes"] = includeDocAndDocRes.Value.ToString();
|
||||
}
|
||||
|
||||
var url = QueryHelpers.AddQueryString(baseUrl, queryParams);
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace EnvelopeGenerator.Server.Client.Services;
|
||||
|
||||
public class PdfSignatureService(IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
/// <summary>
|
||||
/// Calls POST /api/PdfSignature with the envelope id and returns the fully signed PDF bytes.
|
||||
/// </summary>
|
||||
public async Task<byte[]> GetSignedPdfAsync(int envelopeId, CancellationToken cancel = default)
|
||||
{
|
||||
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||
var response = await http.PostAsJsonAsync("/api/PdfSignature", new { Id = envelopeId }, cancel);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new HttpRequestException(
|
||||
$"Failed to load signed PDF. Status: {(int)response.StatusCode} ({response.ReasonPhrase ?? "Unknown"})",
|
||||
null,
|
||||
response.StatusCode);
|
||||
|
||||
return await response.Content.ReadAsByteArrayAsync(cancel);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using EnvelopeGenerator.Application.Common.Dto.Receiver;
|
||||
|
||||
namespace EnvelopeGenerator.Server.Client.Services;
|
||||
|
||||
@@ -26,4 +27,21 @@ public class ReceiverService(IHttpClientFactory httpClientFactory)
|
||||
var emails = await response.Content.ReadFromJsonAsync<List<string>>(JsonOptions, cancel);
|
||||
return emails ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches recent receiver suggestions for the authenticated sender.
|
||||
/// Calls GET /api/EnvelopeReceiver/recent-suggestions?take={take}.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<RecentReceiverSuggestionDto>> GetRecentReceiverSuggestionsAsync(int take = 20, CancellationToken cancel = default)
|
||||
{
|
||||
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||
var url = $"/api/EnvelopeReceiver/recent-suggestions?take={take}";
|
||||
var response = await http.GetAsync(url, cancel);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return [];
|
||||
|
||||
var suggestions = await response.Content.ReadFromJsonAsync<List<RecentReceiverSuggestionDto>>(JsonOptions, cancel);
|
||||
return suggestions ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user