Compare commits
174 Commits
feat/edit-
...
feat/wizar
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| d0c3d79fbc | |||
| e81c7896a1 | |||
| 127e2b253e | |||
| 6d0b78072e | |||
| 3721ea9a1f | |||
| f86588551c | |||
| 0eaa9ff08f | |||
| ec79d7622e | |||
| f558d03bdf | |||
| db79053c62 | |||
| 85dd85aa92 | |||
| c7bc9fbcca | |||
| 1e8e5c96c4 | |||
| 61d04ca51a | |||
| af674a811d | |||
| 123889451f | |||
| 3b5075fe44 | |||
| 48302a374f | |||
| b2c245d6bd | |||
| e9c0ec139a | |||
| 819bdcfcfb | |||
| ad7686b7c2 | |||
| ade542ded0 | |||
| 01c1283622 | |||
| d969dbe843 | |||
| 41779a4f58 | |||
| bf739c1360 | |||
| 0f167d731e | |||
| 021d7cfe29 | |||
| e2322b41cd | |||
| 618451f0c1 | |||
| 96c7cd6e8a | |||
| 4047ba7c6b | |||
| 8386b85592 | |||
| 640f0b95fe | |||
| 181f7f4dec | |||
| 95961a296f | |||
| db613358d5 | |||
| 7a126050cf | |||
| 7081c5f64e | |||
| 12ec5b28fa | |||
| 925e60a45c | |||
| 897860b2bf | |||
| 8933cff558 | |||
| b0b9ba7212 | |||
| 33c75ea7e1 | |||
| 513efa0833 | |||
| 0c81d79a9b | |||
| 696cd1b623 | |||
| 2434f67627 | |||
| 776700b247 | |||
| 7a114f47a6 | |||
| ed44d841e7 | |||
| 4bd0cc2e94 | |||
| 8a4963472c | |||
| 4d3f72a3ed | |||
| 1ebd43e3b0 | |||
| 19aeda5aef | |||
| 940d129c11 | |||
| 44bbdbb835 | |||
| 223017006b | |||
| ad9250c7b1 | |||
| 5cc47ca9c7 | |||
| 156988403a | |||
| 380a5f4c0c | |||
| 0f53d0509e |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -370,3 +370,4 @@ FodyWeavers.xsd
|
|||||||
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/publish-output
|
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/publish-output
|
||||||
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/tekh_softHSM_test.md
|
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/tekh_softHSM_test.md
|
||||||
/ignore/EnvelopeGenerator.Form
|
/ignore/EnvelopeGenerator.Form
|
||||||
|
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/appsettings.Secrets.json
|
||||||
|
|||||||
102
AGENTS.md
102
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
|
- Implementing sender-side PDF.js overlay (signature field placement) — the receiver side is the reference implementation
|
||||||
**Maintenance:** Update when `pdf-viewer.js` API changes, new JS interop functions are added, or the signing flow is modified.
|
**Maintenance:** Update when `pdf-viewer.js` API changes, new JS interop functions are added, or the signing flow is modified.
|
||||||
|
|
||||||
|
#### `DATABASE_STRUCTURE_CONTEXT.md`
|
||||||
|
**What:** Deep DB documentation for the envelope domain. Includes table/column snapshot, entity-to-table mapping, EF relationship/constraint map, stored procedure behavior + call sites, and trigger behavior + wiring path.
|
||||||
|
**Why it exists:** DB is centralized and can evolve independently; this file preserves migration-critical data semantics when historical DB context is hard to recover.
|
||||||
|
**When you need it:**
|
||||||
|
- Planning or reviewing DB/schema changes
|
||||||
|
- Debugging status transitions/history side effects
|
||||||
|
- Tracing where `PRSIG_*` procedures or DB triggers affect app behavior
|
||||||
|
- Reconciling Domain entities with physical SQL schema
|
||||||
|
**Maintenance:** Refresh on each DB migration, trigger/procedure change, or significant Domain/EGDbContext model change.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Deployment Docs
|
### Deployment Docs
|
||||||
@@ -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)
|
## Active Architecture (Post-Migration)
|
||||||
|
|
||||||
**Frontend:** Blazor Auto (Server+WASM hybrid)
|
**Frontend:** Blazor Auto (Server+WASM hybrid)
|
||||||
@@ -236,6 +276,24 @@ Manual testing workflow:
|
|||||||
3. Test sender login at `/sender/login`
|
3. Test sender login at `/sender/login`
|
||||||
4. Test receiver flow at `/envelope/login/{envelopeKey}`
|
4. Test receiver flow at `/envelope/login/{envelopeKey}`
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- Apply a modular page architecture across Server pages (starting with `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeSenderPage.razor`): split large pages into smaller subcomponents/partial modules, eliminate duplicated code paths, and centralize shared UI/domain logic for maintainability.
|
||||||
|
- Perform a senior-level page performance review across sender/receiver flows before further UX changes: verify route transitions, component mount/unmount behavior, loading-state correctness, duplicate API calls, and perceived latency (first meaningful paint and return-navigation responsiveness). Treat loading logic defects as architecture/performance issues first, not styling problems.
|
||||||
|
- Audit `EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/` for unnecessary request/payload remapping and remove redundant mappings where direct request DTO usage is safe; review client HTTP methods end-to-end for similar avoidable transformation layers.
|
||||||
|
- Centralize dependency injection registrations into dedicated extension methods/modules (especially server + client service registrations in `Program.cs`) to reduce startup composition sprawl and improve maintainability.
|
||||||
|
- Refactor `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/PdfRenderController.cs` + matching client service contract to remove JSON/base64 `byte[]` transport for PDF rendering; prefer binary streaming (`application/octet-stream` or multipart), avoid `data:` URL bloat where possible, and validate end-to-end memory/latency behavior with large PDFs.
|
||||||
|
- Establish a DevExpress toast-based notification architecture using `DxToastProvider` + `IToastNotificationService` (tab/page-scoped provider strategy, standardized `ToastOptions` factory, and severity-to-style mapping) so sender/receiver flows show consistent, non-duplicated, actionable feedback.
|
||||||
|
- Eliminate manual property-by-property request→domain mapping drift (example: `CreateEnvelopeCommandHandler`) by introducing a standardized mapping policy (AutoMapper/profile conventions or reflection/source-generator based mapper) and enforcing it across envelope workflows; this needs to be solved in the shared company Core library level so all services get the same guardrails by default.
|
||||||
|
- P1 security workstream: remove dynamic SQL string composition/interpolation from active save/create/update paths (`string.Format`, `$"...{input}..."`, `ToSqlParam`) and migrate to parameterized queries only; treat this as a potential SQL injection vulnerability (stability + security risk), execute repo-wide audit, and add CI guardrails to block reintroduction.
|
||||||
|
- Alternative UX design (Server component): in `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeSenderPage.razor`, enforce deterministic row double-click behavior as exactly one action (preview OR edit), and keep row action buttons/tooltips aligned with the same rule to eliminate ambiguous navigation.
|
||||||
|
- 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
|
## Database
|
||||||
|
|
||||||
**SQL Server** (DD_ECM)
|
**SQL Server** (DD_ECM)
|
||||||
@@ -246,11 +304,51 @@ Manual testing workflow:
|
|||||||
**Key Tables:**
|
**Key Tables:**
|
||||||
- `TBSIG_ENVELOPE` - Envelope metadata
|
- `TBSIG_ENVELOPE` - Envelope metadata
|
||||||
- `TBSIG_ENVELOPE_RECEIVER` - Receiver assignments
|
- `TBSIG_ENVELOPE_RECEIVER` - Receiver assignments
|
||||||
- `TBSIG_DOC_RECEIVER_ELEMENT` - Signature fields (X, Y in INCHES)
|
- `TBSIG_DOCUMENT_RECEIVER_ELEMENT` - Signature fields (X, Y in INCHES)
|
||||||
- `TBSIG_RECEIVER` - Receiver registry
|
- `TBSIG_RECEIVER` - Receiver registry
|
||||||
- `TBSIG_DOCUMENT` - PDF binary data
|
- `TBSIG_ENVELOPE_DOCUMENT` - PDF binary data
|
||||||
- `TBSIG_ENVELOPE_HISTORY` - Audit trail
|
- `TBSIG_ENVELOPE_HISTORY` - Audit trail
|
||||||
|
|
||||||
|
### Data Structure (Domain + EGDbContext)
|
||||||
|
|
||||||
|
Use this as the canonical quick map before touching sender/receiver workflow code.
|
||||||
|
|
||||||
|
#### Core Aggregate Shape
|
||||||
|
- `Envelope` (`TBSIG_ENVELOPE`) is the root aggregate for sending workflow.
|
||||||
|
- `Envelope -> Documents` is `1:N` via `Document.EnvelopeId` (`TBSIG_ENVELOPE_DOCUMENT`).
|
||||||
|
- `Envelope -> Histories` is `1:N` via `History.EnvelopeId` (`TBSIG_ENVELOPE_HISTORY`).
|
||||||
|
- `Envelope <-> Receiver` is `N:N` through `EnvelopeReceiver` (`TBSIG_ENVELOPE_RECEIVER`, composite PK `{EnvelopeId, ReceiverId}`).
|
||||||
|
- `Document -> DocReceiverElement` is `1:N` via `DocReceiverElement.DocumentId` (`TBSIG_DOCUMENT_RECEIVER_ELEMENT`).
|
||||||
|
- `DocReceiverElement -> ElementAnnotation` is `1:N` via `ElementAnnotation.ElementId` (`TBSIG_DOCUMENT_RECEIVER_ELEMENT_ANNOTATION`).
|
||||||
|
|
||||||
|
#### Status and Tracking Entities
|
||||||
|
- `DocumentStatus` (`TBSIG_DOCUMENT_STATUS`) links `EnvelopeId + ReceiverId` and stores receiver-level signing state.
|
||||||
|
- `History` stores timeline events (`EnvelopeStatus`) with polymorphic `UserReference` lookup semantics.
|
||||||
|
- `EnvelopeReceiverReadOnly` (`TBSIG_ENVELOPE_RECEIVER_READ_ONLY`) stores temporary read-only delegation with validity window (`DateValid`).
|
||||||
|
|
||||||
|
#### Important Constraints From `EGDbContext`
|
||||||
|
- `Envelope.Uuid` has a unique index.
|
||||||
|
- `Receiver.Signature` has a unique index.
|
||||||
|
- `Receiver.EmailAddress` has a unique index.
|
||||||
|
- `EnvelopeReceiver` uses composite primary key: `{ EnvelopeId, ReceiverId }`.
|
||||||
|
- `History.UserReference` is mapped conditionally (`IsMigration == false`) to:
|
||||||
|
- `Receiver.EmailAddress` (`History.Receiver`)
|
||||||
|
- `User.Email` from UserManager (`History.Sender`)
|
||||||
|
- `EnvelopeReceiverReadOnly.AddedWho` maps to `Receiver.EmailAddress` (principal key mapping, not receiver GUID).
|
||||||
|
|
||||||
|
#### Coordinate-Critical Fields
|
||||||
|
- Signature placement is persisted in `DocReceiverElement` as `X`, `Y`, `Width`, `Height`, `Page`.
|
||||||
|
- These coordinates are in INCHES (not PDF points, not pixels).
|
||||||
|
|
||||||
|
#### Cross-Module Tables in Same Context
|
||||||
|
- `EGDbContext` also includes UserManager entities: `User`, `Group`, `Module`, `GroupOfUser`, `ModuleOfUser`, `UserRep`, and `ClientUser`.
|
||||||
|
- Mail dispatcher entity `EmailOut` is also registered under `#if NET` and trigger-configurable.
|
||||||
|
|
||||||
|
#### Trigger-Aware Model Notes
|
||||||
|
- Trigger names are injected from `DbTriggerParams` in appsettings and applied via `ToTable(tb => tb.HasTrigger(...))`.
|
||||||
|
- Current trigger registration is explicit (`AddTrigger<T>()` calls), not attribute/reflection driven.
|
||||||
|
- Do not remove trigger mappings casually; this project depends on DB-side automation/audit behavior.
|
||||||
|
|
||||||
## DevExpress
|
## DevExpress
|
||||||
|
|
||||||
**License:** Commercial (v25.2.3)
|
**License:** Commercial (v25.2.3)
|
||||||
|
|||||||
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,40 @@
|
|||||||
|
using DigitalData.Core.Exceptions;
|
||||||
|
using FluentValidation;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Application.Common.Behaviors;
|
||||||
|
|
||||||
|
public class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
|
||||||
|
where TRequest : notnull
|
||||||
|
{
|
||||||
|
private readonly IEnumerable<IValidator<TRequest>> _validators;
|
||||||
|
|
||||||
|
public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
|
||||||
|
{
|
||||||
|
_validators = validators;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TResponse> Handle(
|
||||||
|
TRequest request,
|
||||||
|
RequestHandlerDelegate<TResponse> next,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (_validators.Any())
|
||||||
|
{
|
||||||
|
var context = new ValidationContext<TRequest>(request);
|
||||||
|
var failures = _validators
|
||||||
|
.Select(v => v.Validate(context))
|
||||||
|
.SelectMany(r => r.Errors)
|
||||||
|
.Where(f => f is not null)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (failures.Count > 0)
|
||||||
|
{
|
||||||
|
var message = string.Join("; ", failures.Select(f => f.ErrorMessage).Distinct());
|
||||||
|
throw new BadRequestException(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await next();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
using EnvelopeGenerator.Domain.Interfaces;
|
||||||
|
|
||||||
namespace EnvelopeGenerator.Application.Common.Dto;
|
namespace EnvelopeGenerator.Application.Common.Dto;
|
||||||
|
|
||||||
|
// TODO: Add the “Reason” property and integrate with UI and the digital signature process.
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Data Transfer Object representing a positioned element assigned to a document receiver.
|
/// Data Transfer Object representing a positioned element assigned to a document receiver.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class DocReceiverElementDto : IDocReceiverElement
|
public class DocReceiverElementDto : IDocReceiverElement, ISignatureInkPayload
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the unique identifier of the element.
|
/// Gets or sets the unique identifier of the element.
|
||||||
@@ -18,11 +20,21 @@ public class DocReceiverElementDto : IDocReceiverElement
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public int DocumentId { get; set; }
|
public int DocumentId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
public virtual DocumentDto? Document { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the identifier of the receiver.
|
/// Gets or sets the identifier of the receiver.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int ReceiverId { get; set; }
|
public int ReceiverId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
public virtual ReceiverDto? Receiver { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the type of the element.
|
/// Gets or sets the type of the element.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -122,4 +134,41 @@ public class DocReceiverElementDto : IDocReceiverElement
|
|||||||
///
|
///
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public byte[]? Ink { get; set; }
|
public byte[]? Ink { get; set; }
|
||||||
}
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the stored signature bytes as an image payload with detected MIME type.
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="InvalidOperationException">Thrown when signature image bytes do not exist.</exception>
|
||||||
|
public (byte[] Bytes, string ContentType, string FileExtension) ToImage()
|
||||||
|
{
|
||||||
|
if (Ink is not { Length: > 0 })
|
||||||
|
throw new InvalidOperationException("Signature image (Ink) does not exist.");
|
||||||
|
|
||||||
|
if (HasPrefix(Ink, 0x89, 0x50, 0x4E, 0x47))
|
||||||
|
return (Ink, "image/png", ".png");
|
||||||
|
|
||||||
|
if (HasPrefix(Ink, 0xFF, 0xD8, 0xFF))
|
||||||
|
return (Ink, "image/jpeg", ".jpg");
|
||||||
|
|
||||||
|
if (HasPrefix(Ink, 0x52, 0x49, 0x46, 0x46) &&
|
||||||
|
Ink.Length >= 12 &&
|
||||||
|
Ink[8] == 0x57 && Ink[9] == 0x45 && Ink[10] == 0x42 && Ink[11] == 0x50)
|
||||||
|
return (Ink, "image/webp", ".webp");
|
||||||
|
|
||||||
|
return (Ink, "application/octet-stream", ".bin");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasPrefix(byte[] data, params byte[] prefix)
|
||||||
|
{
|
||||||
|
if (data.Length < prefix.Length)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
for (var i = 0; i < prefix.Length; i++)
|
||||||
|
{
|
||||||
|
if (data[i] != prefix[i])
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
namespace EnvelopeGenerator.Application.Common.Dto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Data Transfer Object representing document metadata and elements without binary payload.
|
||||||
|
/// </summary>
|
||||||
|
public record DocumentDetailsDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the unique identifier of the document.
|
||||||
|
/// </summary>
|
||||||
|
public int Id { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the envelope ID to which the document belongs.
|
||||||
|
/// </summary>
|
||||||
|
public int EnvelopeId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the date and time when the document was added.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime AddedWhen { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the collection of elements associated with the document for receiver interactions, if any.
|
||||||
|
/// </summary>
|
||||||
|
public IEnumerable<DocReceiverElementDto>? Elements { get; init; }
|
||||||
|
}
|
||||||
@@ -3,30 +3,25 @@
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Data Transfer Object representing a document within an envelope, including optional binary data and form elements.
|
/// Data Transfer Object representing a document within an envelope, including optional binary data and form elements.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class DocumentDto
|
public record DocumentDto : DocumentDetailsDto
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the unique identifier of the document.
|
///
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int Id { get; set; }
|
public DocumentDto() { }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the envelope ID to which the document belongs.
|
///
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int EnvelopeId { get; set; }
|
/// <param name="byteData"></param>
|
||||||
|
/// <param name="details"></param>
|
||||||
/// <summary>
|
public DocumentDto(byte[] byteData, DocumentDetailsDto details) : base(details)
|
||||||
/// Gets or sets the date and time when the document was added.
|
{
|
||||||
/// </summary>
|
ByteData = byteData;
|
||||||
public DateTime AddedWhen { get; set; }
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the binary data of the document, if available.
|
/// Gets or sets the binary data of the document, if available.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public byte[]? ByteData { get; set; }
|
public byte[]? ByteData { get; set; }
|
||||||
|
}
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the collection of elements associated with the document for receiver interactions, if any.
|
|
||||||
/// </summary>
|
|
||||||
public IEnumerable<DocReceiverElementDto>? Elements { get; set; }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ public record EnvelopeDto : IEnvelope
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
///
|
///
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int? ContractType { get; set; }
|
public ContractType? ContractType { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Default value is 'de-DE'
|
/// Default value is 'de-DE'
|
||||||
@@ -84,7 +84,42 @@ public record EnvelopeDto : IEnvelope
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
///
|
///
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int? CertificationType { get; set; }
|
public CertificationType? CertificationType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
public int? FinalEmailToCreator { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
public int? FinalEmailToReceivers { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
public bool? SendReminderEmails { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
public int? FirstReminderDays { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
public int? ReminderIntervalDays { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
public int? ExpiresWhenDays { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
public int? ExpiresWarningWhenDays { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
///
|
///
|
||||||
@@ -135,4 +170,4 @@ public record EnvelopeDto : IEnvelope
|
|||||||
/// Envelope history entries tracking actions like DocumentSigned, EnvelopeOpened, etc.
|
/// Envelope history entries tracking actions like DocumentSigned, EnvelopeOpened, etc.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IEnumerable<HistoryDto>? Histories { get; set; }
|
public IEnumerable<HistoryDto>? Histories { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
namespace EnvelopeGenerator.Application.Common.Dto;
|
using EnvelopeGenerator.Domain.Constants;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Application.Common.Dto;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Data Transfer Object representing a type of envelope with its configuration settings.
|
/// Data Transfer Object representing a type of envelope with its configuration settings.
|
||||||
@@ -28,7 +30,7 @@ public class EnvelopeTypeDto
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the certification type identifier.
|
/// Gets or sets the certification type identifier.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int? CertificationType { get; set; }
|
public CertificationType? CertificationType { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets a value indicating whether an access code is required.
|
/// Gets or sets a value indicating whether an access code is required.
|
||||||
@@ -78,5 +80,5 @@ public class EnvelopeTypeDto
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the contract type associated with the envelope type.
|
/// Gets or sets the contract type associated with the envelope type.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int? ContractType { get; set; }
|
public ContractType? ContractType { get; set; }
|
||||||
}
|
}
|
||||||
16
EnvelopeGenerator.Application/Common/Dto/PdfPageImageDto.cs
Normal file
16
EnvelopeGenerator.Application/Common/Dto/PdfPageImageDto.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
namespace EnvelopeGenerator.Application.Common.Dto;
|
||||||
|
|
||||||
|
public sealed class PdfPageImageDto
|
||||||
|
{
|
||||||
|
public int PageNumber { get; init; }
|
||||||
|
|
||||||
|
public double WidthPt { get; init; }
|
||||||
|
|
||||||
|
public double HeightPt { get; init; }
|
||||||
|
|
||||||
|
public int WidthPx { get; init; }
|
||||||
|
|
||||||
|
public int HeightPx { get; init; }
|
||||||
|
|
||||||
|
public string ImageDataUrl { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace EnvelopeGenerator.Application.Common.Dto;
|
||||||
|
|
||||||
|
public sealed class PdfRenderRequestDto
|
||||||
|
{
|
||||||
|
public byte[]? PdfBytes { get; init; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -49,7 +49,7 @@ public sealed record Signature
|
|||||||
public string? Position
|
public string? Position
|
||||||
{
|
{
|
||||||
get => _position;
|
get => _position;
|
||||||
init => _position = string.IsNullOrWhiteSpace(value) ? value : null;
|
init => _position = string.IsNullOrWhiteSpace(value) ? null : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -62,4 +62,4 @@ public sealed record Signature
|
|||||||
/// <b>Example:</b> "Berlin" ? rendered as "Berlin, 26.01.2025"
|
/// <b>Example:</b> "Berlin" ? rendered as "Berlin, 26.01.2025"
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public required string Place { get; init; }
|
public required string Place { 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
|
||||||
|
}
|
||||||
@@ -0,0 +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;
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ public class MappingProfile : Profile
|
|||||||
.ForMember(dest => dest.EnvelopeTypeTitle,
|
.ForMember(dest => dest.EnvelopeTypeTitle,
|
||||||
opt => opt.MapFrom(src => src.Type != null ? src.Type.Title : null));
|
opt => opt.MapFrom(src => src.Type != null ? src.Type.Title : null));
|
||||||
CreateMap<Document, DocumentDto>();
|
CreateMap<Document, DocumentDto>();
|
||||||
|
CreateMap<Document, DocumentDetailsDto>();
|
||||||
CreateMap<History, HistoryDto>().ForMember(dest => dest.ActionDate, opt => opt.MapFrom(src => src.ChangedWhen));
|
CreateMap<History, HistoryDto>().ForMember(dest => dest.ActionDate, opt => opt.MapFrom(src => src.ChangedWhen));
|
||||||
CreateMap<History, HistoryCreateDto>().ForMember(dest => dest.ActionDate, opt => opt.MapFrom(src => src.ChangedWhen));
|
CreateMap<History, HistoryCreateDto>().ForMember(dest => dest.ActionDate, opt => opt.MapFrom(src => src.ChangedWhen));
|
||||||
CreateMap<EnvelopeReceiver, EnvelopeReceiverDto>();
|
CreateMap<EnvelopeReceiver, EnvelopeReceiverDto>();
|
||||||
@@ -68,4 +69,4 @@ public class MappingProfile : Profile
|
|||||||
? new SmsResponse() { Ok = true }
|
? new SmsResponse() { Ok = true }
|
||||||
: new SmsResponse() { Ok = false, Errors = gtxRes });
|
: new SmsResponse() { Ok = false, Errors = gtxRes });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
using DigitalData.EmailProfilerDispatcher.Abstraction.Entities;
|
using DigitalData.EmailProfilerDispatcher.Abstraction.Entities;
|
||||||
using EnvelopeGenerator.Application.Common.Configurations;
|
using EnvelopeGenerator.Application.Common.Configurations;
|
||||||
using EnvelopeGenerator.Application.Common.Extensions;
|
using EnvelopeGenerator.Application.Common.Extensions;
|
||||||
|
using EnvelopeGenerator.Application.Common.Notifications.EnvelopeDeleted;
|
||||||
using EnvelopeGenerator.Domain.Constants;
|
using EnvelopeGenerator.Domain.Constants;
|
||||||
using EnvelopeGenerator.Domain.Entities;
|
using EnvelopeGenerator.Domain.Entities;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
@@ -96,6 +97,10 @@ public abstract class SendMailHandler<TNotification> : INotificationHandler<TNot
|
|||||||
/// <exception cref="NotImplementedException"></exception>
|
/// <exception cref="NotImplementedException"></exception>
|
||||||
public virtual async Task Handle(TNotification notification, CancellationToken cancel)
|
public virtual async Task Handle(TNotification notification, CancellationToken cancel)
|
||||||
{
|
{
|
||||||
|
// do not send email if the envelope is deleted and the status is less than or equal to EnvelopeSaved
|
||||||
|
if (notification is EnvelopeDeletedNotification delNot && delNot.Envelope.Status <= EnvelopeStatus.EnvelopeSaved)
|
||||||
|
return;
|
||||||
|
|
||||||
var placeHolders = CreatePlaceHolders(notification);
|
var placeHolders = CreatePlaceHolders(notification);
|
||||||
|
|
||||||
var temp = await TempRepo
|
var temp = await TempRepo
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ using System.Reflection;
|
|||||||
using MediatR;
|
using MediatR;
|
||||||
using EnvelopeGenerator.Application.DocReceiverElements.Commands;
|
using EnvelopeGenerator.Application.DocReceiverElements.Commands;
|
||||||
using EnvelopeGenerator.Application.DocReceiverElements.Behaviors;
|
using EnvelopeGenerator.Application.DocReceiverElements.Behaviors;
|
||||||
|
using EnvelopeGenerator.Application.Common.Behaviors;
|
||||||
|
using FluentValidation;
|
||||||
|
|
||||||
namespace EnvelopeGenerator.Application;
|
namespace EnvelopeGenerator.Application;
|
||||||
|
|
||||||
@@ -47,8 +49,10 @@ public static class DependencyInjection
|
|||||||
|
|
||||||
services.Configure<DispatcherParams>(config.GetSection(nameof(DispatcherParams)));
|
services.Configure<DispatcherParams>(config.GetSection(nameof(DispatcherParams)));
|
||||||
services.Configure<MailParams>(config.GetSection(nameof(MailParams)));
|
services.Configure<MailParams>(config.GetSection(nameof(MailParams)));
|
||||||
|
services.Configure<SignatureFieldParams>(config.GetSection(nameof(SignatureFieldParams)));
|
||||||
services.Configure<AuthenticatorParams>(config.GetSection(nameof(AuthenticatorParams)));
|
services.Configure<AuthenticatorParams>(config.GetSection(nameof(AuthenticatorParams)));
|
||||||
services.Configure<TotpSmsParams>(config.GetSection(nameof(TotpSmsParams)));
|
services.Configure<TotpSmsParams>(config.GetSection(nameof(TotpSmsParams)));
|
||||||
|
services.Configure<ESignParams>(config.GetSection(nameof(ESignParams)));
|
||||||
|
|
||||||
services.AddHttpClientService<GtxMessagingParams>(config.GetSection(nameof(GtxMessagingParams)));
|
services.AddHttpClientService<GtxMessagingParams>(config.GetSection(nameof(GtxMessagingParams)));
|
||||||
services.TryAddScoped<ISmsSender, GTXSmsSender>(); // Changed: Singleton → Scoped
|
services.TryAddScoped<ISmsSender, GTXSmsSender>(); // Changed: Singleton → Scoped
|
||||||
@@ -59,24 +63,32 @@ public static class DependencyInjection
|
|||||||
services.AddMediatR(cfg =>
|
services.AddMediatR(cfg =>
|
||||||
{
|
{
|
||||||
cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly());
|
cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly());
|
||||||
|
|
||||||
|
// Generic request validation
|
||||||
|
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
|
||||||
|
|
||||||
// Register SignCommand pipeline behaviors in execution order
|
// Register SignCommand pipeline behaviors in execution order
|
||||||
// 0. EnvelopeReceiverResolutionBehavior - Resolves EnvelopeReceiver from query parameters (executes FIRST)
|
// 0. EnvelopeReceiverResolutionBehavior - Resolves EnvelopeReceiver from query parameters (executes FIRST)
|
||||||
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, EnvelopeReceiverResolutionBehavior>();
|
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, EnvelopeReceiverResolutionBehavior>();
|
||||||
|
|
||||||
// 1. AnnotationBehavior - Saves annotations (executes second)
|
// 1. AnnotationBehavior - Saves annotations (executes second)
|
||||||
|
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, SaveSignatureBehavior>();
|
||||||
|
|
||||||
|
// 2. AnnotationBehavior - Saves annotations for legacy web flow
|
||||||
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, AnnotationBehavior>();
|
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, AnnotationBehavior>();
|
||||||
|
|
||||||
// 2. DocStatusBehavior - Creates document status (executes third)
|
// 3. DocStatusBehavior - Creates document status
|
||||||
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, DocStatusBehavior>();
|
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, DocStatusBehavior>();
|
||||||
|
|
||||||
// 3. HistoryBehavior - Records history (executes fourth)
|
// 4. HistoryBehavior - Records history
|
||||||
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, HistoryBehavior>();
|
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, HistoryBehavior>();
|
||||||
|
|
||||||
// 4. SendSignedMailBehavior - Sends notification email (executes LAST, only if all previous succeed)
|
// 5. SendSignedMailBehavior - Sends notification email (executes LAST)
|
||||||
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, SendSignedMailBehavior>();
|
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, SendSignedMailBehavior>();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,11 @@ public class EnvelopeReceiverResolutionBehavior : IPipelineBehavior<SigningComma
|
|||||||
// If EnvelopeReceiver is not provided, query it from database
|
// If EnvelopeReceiver is not provided, query it from database
|
||||||
if (request.EnvelopeReceiver is null)
|
if (request.EnvelopeReceiver is null)
|
||||||
{
|
{
|
||||||
var er = await _erRepo.Query.Where(request, notnull: true).SingleOrDefaultAsync(cancellationToken)
|
var er = await _erRepo.Query
|
||||||
|
.Where(request, notnull: true)
|
||||||
|
.Include(x => x.Envelope)
|
||||||
|
.Include(x => x.Receiver)
|
||||||
|
.SingleOrDefaultAsync(cancellationToken)
|
||||||
?? throw new NotFoundException("EnvelopeReceiver not found");
|
?? throw new NotFoundException("EnvelopeReceiver not found");
|
||||||
|
|
||||||
request.SetEnvelopeReceiver(_mapper.Map<EnvelopeReceiverDto>(er));
|
request.SetEnvelopeReceiver(_mapper.Map<EnvelopeReceiverDto>(er));
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ public class SaveSignatureBehavior : IPipelineBehavior<SigningCommand, Unit>
|
|||||||
throw new BadRequestException($"Signatures are required for saving signature behavior.");
|
throw new BadRequestException($"Signatures are required for saving signature behavior.");
|
||||||
|
|
||||||
var elements = await _elementRepo
|
var elements = await _elementRepo
|
||||||
.Where(e => e.Document.EnvelopeId == request.Envelope.Id)
|
.Where(e => e.Document.EnvelopeId == request.EnvelopeReceiver.EnvelopeId)
|
||||||
.Where(e => e.ReceiverId == request.Receiver.Id)
|
.Where(e => e.ReceiverId == request.EnvelopeReceiver.ReceiverId)
|
||||||
.ToListAsync(cancel);
|
.ToListAsync(cancel);
|
||||||
|
|
||||||
foreach (var element in elements)
|
foreach (var element in elements)
|
||||||
|
|||||||
@@ -14,6 +14,15 @@ namespace EnvelopeGenerator.Application.DocReceiverElements.Queries;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public record ReadDocReceiverElementQuery : EnvelopeReceiverQueryBase, IRequest<IEnumerable<DocReceiverElementDto>>
|
public record ReadDocReceiverElementQuery : EnvelopeReceiverQueryBase, IRequest<IEnumerable<DocReceiverElementDto>>
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
public int? Id { get; set; } = null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
public bool IncludeDocument { get; set; } = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -47,7 +56,10 @@ public class ReadDocReceiverElementQueryHandler : IRequestHandler<ReadDocReceive
|
|||||||
{
|
{
|
||||||
var q = _repository.Query;
|
var q = _repository.Query;
|
||||||
|
|
||||||
if(request.Envelope.Id is int envelopeId)
|
if (request.Id is not null)
|
||||||
|
q = q.Where(e => e.Id == request.Id);
|
||||||
|
|
||||||
|
if (request.Envelope.Id is int envelopeId)
|
||||||
q = q.Where(e => e.Document.EnvelopeId == envelopeId);
|
q = q.Where(e => e.Document.EnvelopeId == envelopeId);
|
||||||
|
|
||||||
if (request.Envelope.Uuid is string envelopeUuid)
|
if (request.Envelope.Uuid is string envelopeUuid)
|
||||||
@@ -59,6 +71,9 @@ public class ReadDocReceiverElementQueryHandler : IRequestHandler<ReadDocReceive
|
|||||||
if (request.Receiver.Signature is string signature)
|
if (request.Receiver.Signature is string signature)
|
||||||
q = q.Where(e => e.Receiver.Signature == signature);
|
q = q.Where(e => e.Receiver.Signature == signature);
|
||||||
|
|
||||||
|
if( request.IncludeDocument)
|
||||||
|
q = q.Include(e => e.Document);
|
||||||
|
|
||||||
var elements = await q.ToListAsync(cancellationToken);
|
var elements = await q.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
return _mapper.Map<IEnumerable<DocReceiverElementDto>>(elements);
|
return _mapper.Map<IEnumerable<DocReceiverElementDto>>(elements);
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
using DigitalData.Core.Abstraction.Application.Repository;
|
||||||
|
using EnvelopeGenerator.Application.Common.Dto;
|
||||||
|
using EnvelopeGenerator.Domain.Entities;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Application.Documents.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a query to read document metadata/details without binary payload.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="EnvelopeId">The identifier of the envelope associated with the document.</param>
|
||||||
|
/// <param name="UserId">The unique identifier of the sending user. Optional.</param>
|
||||||
|
public record ReadDocumentDetailsQuery(int EnvelopeId, int? UserId = null) : IRequest<DocumentDetailsDto?>;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles queries for reading document details without loading BYTE_DATA.
|
||||||
|
/// </summary>
|
||||||
|
public class ReadDocumentDetailsQueryHandler : IRequestHandler<ReadDocumentDetailsQuery, DocumentDetailsDto?>
|
||||||
|
{
|
||||||
|
private readonly IRepository<Document> _repo;
|
||||||
|
|
||||||
|
public ReadDocumentDetailsQueryHandler(IRepository<Document> repo)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DocumentDetailsDto?> Handle(ReadDocumentDetailsQuery query, CancellationToken cancel)
|
||||||
|
{
|
||||||
|
var docQuery = _repo.Query.AsNoTracking();
|
||||||
|
|
||||||
|
if (query.UserId is int userId)
|
||||||
|
docQuery = docQuery.Where(doc => doc.Envelope.UserId == userId);
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
.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,
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Application.Documents.Queries;
|
||||||
|
|
||||||
|
public class ReadDocumentDetailsQueryValidator : AbstractValidator<ReadDocumentDetailsQuery>
|
||||||
|
{
|
||||||
|
public ReadDocumentDetailsQueryValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.EnvelopeId)
|
||||||
|
.GreaterThan(0)
|
||||||
|
.WithMessage("EnvelopeId is required.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Application.Documents.Queries;
|
||||||
|
|
||||||
|
public class ReadDocumentQueryValidator : AbstractValidator<ReadDocumentQuery>
|
||||||
|
{
|
||||||
|
public ReadDocumentQueryValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x)
|
||||||
|
.Must(x => (x.Id ?? 0) > 0 || (x.EnvelopeId ?? 0) > 0)
|
||||||
|
.WithMessage("Id or EnvelopeId is required.");
|
||||||
|
|
||||||
|
RuleFor(x => x.Id)
|
||||||
|
.GreaterThan(0)
|
||||||
|
.When(x => x.Id.HasValue)
|
||||||
|
.WithMessage("Id must be greater than 0.");
|
||||||
|
|
||||||
|
RuleFor(x => x.EnvelopeId)
|
||||||
|
.GreaterThan(0)
|
||||||
|
.When(x => x.EnvelopeId.HasValue)
|
||||||
|
.WithMessage("EnvelopeId must be greater than 0.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
<TargetFrameworks>net7.0;net8.0;net9.0</TargetFrameworks>
|
<TargetFrameworks>net7.0;net8.0;net9.0</TargetFrameworks>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
|
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
@@ -19,6 +20,8 @@
|
|||||||
<PackageReference Include="DigitalData.Core.Client" Version="2.1.0" />
|
<PackageReference Include="DigitalData.Core.Client" Version="2.1.0" />
|
||||||
<PackageReference Include="DigitalData.Core.Exceptions" Version="1.1.0" />
|
<PackageReference Include="DigitalData.Core.Exceptions" Version="1.1.0" />
|
||||||
<PackageReference Include="DigitalData.EmailProfilerDispatcher" Version="3.1.1" />
|
<PackageReference Include="DigitalData.EmailProfilerDispatcher" Version="3.1.1" />
|
||||||
|
<PackageReference Include="FluentValidation" Version="11.11.0" />
|
||||||
|
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" />
|
||||||
<PackageReference Include="HtmlSanitizer" Version="9.0.892" />
|
<PackageReference Include="HtmlSanitizer" Version="9.0.892" />
|
||||||
<PackageReference Include="MediatR" Version="12.5.0" />
|
<PackageReference Include="MediatR" Version="12.5.0" />
|
||||||
<PackageReference Include="Microsoft.Identity.Client" Version="4.82.1" />
|
<PackageReference Include="Microsoft.Identity.Client" Version="4.82.1" />
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
using MediatR;
|
using MediatR;
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
namespace EnvelopeGenerator.Application.EnvelopeReceivers.Commands;
|
namespace EnvelopeGenerator.Application.EnvelopeReceivers.Commands.CreateEnvelopeReceiver;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Befehl zur Erstellung eines Umschlags.
|
/// Befehl zur Erstellung eines Umschlags.
|
||||||
@@ -4,7 +4,7 @@ using MediatR;
|
|||||||
using EnvelopeGenerator.Application.Common.Dto.Receiver;
|
using EnvelopeGenerator.Application.Common.Dto.Receiver;
|
||||||
using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor;
|
using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor;
|
||||||
|
|
||||||
namespace EnvelopeGenerator.Application.EnvelopeReceivers.Commands;
|
namespace EnvelopeGenerator.Application.EnvelopeReceivers.Commands.CreateEnvelopeReceiver;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handles the creation of an envelope along with its associated document and recipients.
|
/// Handles the creation of an envelope along with its associated document and recipients.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
using EnvelopeGenerator.Application.Common.Dto;
|
using EnvelopeGenerator.Application.Common.Dto;
|
||||||
using EnvelopeGenerator.Application.Common.Dto.Receiver;
|
using EnvelopeGenerator.Application.Common.Dto.Receiver;
|
||||||
|
|
||||||
namespace EnvelopeGenerator.Application.EnvelopeReceivers.Commands;
|
namespace EnvelopeGenerator.Application.EnvelopeReceivers.Commands.CreateEnvelopeReceiver;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
///
|
///
|
||||||
@@ -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()
|
public MappingProfile()
|
||||||
{
|
{
|
||||||
CreateMap<Receiver, ReceiverDto>();
|
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>
|
/// </remarks>
|
||||||
public record ReadEnvelopeReceiverSecretQuery
|
public record ReadEnvelopeReceiverSecretQuery
|
||||||
: EnvelopeReceiverQueryBase<ReadEnvelopeQuery, ReadReceiverQuery>,
|
: EnvelopeReceiverQueryBase<ReadEnvelopeQuery, ReadReceiverQuery>,
|
||||||
IRequest<EnvelopeReceiverSecretDto?>;
|
IRequest<EnvelopeReceiverSecretDto?>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
public ReadEnvelopeReceiverSecretQuery() { }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="envelopeId"></param>
|
||||||
|
/// <param name="receiverId"></param>
|
||||||
|
public ReadEnvelopeReceiverSecretQuery(int envelopeId, int receiverId)
|
||||||
|
{
|
||||||
|
Envelope.Id = envelopeId;
|
||||||
|
Receiver.Id = receiverId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Extension methods for dispatching <see cref="ReadEnvelopeReceiverSecretQuery"/> via <see cref="IMediator"/>.
|
/// Extension methods for dispatching <see cref="ReadEnvelopeReceiverSecretQuery"/> via <see cref="IMediator"/>.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
using EnvelopeGenerator.Application.Common.Dto;
|
using EnvelopeGenerator.Application.Common.Dto;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using AutoMapper;
|
|
||||||
using DigitalData.Core.Abstraction.Application.Repository;
|
using DigitalData.Core.Abstraction.Application.Repository;
|
||||||
using EnvelopeGenerator.Domain.Entities;
|
using EnvelopeGenerator.Domain.Entities;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -18,17 +17,14 @@ public record ReadEnvelopeTypesQuery : IRequest<IEnumerable<EnvelopeTypeDto>>;
|
|||||||
public class ReadEnvelopeTypesQueryHandler : IRequestHandler<ReadEnvelopeTypesQuery, IEnumerable<EnvelopeTypeDto>>
|
public class ReadEnvelopeTypesQueryHandler : IRequestHandler<ReadEnvelopeTypesQuery, IEnumerable<EnvelopeTypeDto>>
|
||||||
{
|
{
|
||||||
private readonly IRepository<EnvelopeType> _repository;
|
private readonly IRepository<EnvelopeType> _repository;
|
||||||
private readonly IMapper _mapper;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
///
|
///
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="repository"></param>
|
/// <param name="repository"></param>
|
||||||
/// <param name="mapper"></param>
|
public ReadEnvelopeTypesQueryHandler(IRepository<EnvelopeType> repository)
|
||||||
public ReadEnvelopeTypesQueryHandler(IRepository<EnvelopeType> repository, IMapper mapper)
|
|
||||||
{
|
{
|
||||||
_repository = repository;
|
_repository = repository;
|
||||||
_mapper = mapper;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -39,7 +35,28 @@ public class ReadEnvelopeTypesQueryHandler : IRequestHandler<ReadEnvelopeTypesQu
|
|||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public async Task<IEnumerable<EnvelopeTypeDto>> Handle(ReadEnvelopeTypesQuery request, CancellationToken cancellationToken)
|
public async Task<IEnumerable<EnvelopeTypeDto>> Handle(ReadEnvelopeTypesQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var types = await _repository.Query.ToListAsync(cancellationToken);
|
// TODO: Temporary manual projection to avoid null-materialization crashes from legacy nullable DB columns in EnvelopeType.
|
||||||
return _mapper.Map<IEnumerable<EnvelopeTypeDto>>(types);
|
// Revisit and switch to AutoMapper query projection (ProjectTo) after schema nullability and mapping contracts are stabilized.
|
||||||
|
return await _repository.Query
|
||||||
|
.AsNoTracking()
|
||||||
|
.Select(t => new EnvelopeTypeDto
|
||||||
|
{
|
||||||
|
Id = t.Id,
|
||||||
|
Title = t.Title ?? string.Empty,
|
||||||
|
Language = string.IsNullOrWhiteSpace(t.Language) ? "de-DE" : t.Language,
|
||||||
|
ExpiresDays = t.ExpiresDays,
|
||||||
|
CertificationType = t.CertificationType,
|
||||||
|
UseAccessCode = t.UseAccessCode,
|
||||||
|
FinalEmailToCreator = (int?)t.FinalEmailToCreator,
|
||||||
|
FinalEmailToReceivers = (int?)t.FinalEmailToReceivers,
|
||||||
|
AddedWhen = t.AddedWhen,
|
||||||
|
ChangedWhen = t.ChangedWhen,
|
||||||
|
ExpiresWarningDays = t.ExpiresWarningDays,
|
||||||
|
SendReminderEmails = t.SendReminderEmails,
|
||||||
|
FirstReminderDays = t.FirstReminderDays,
|
||||||
|
ReminderIntervalDays = t.ReminderIntervalDays,
|
||||||
|
ContractType = t.ContractType
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using EnvelopeGenerator.Application.Common.Dto;
|
using EnvelopeGenerator.Application.Common.Dto;
|
||||||
using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor;
|
using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor;
|
||||||
|
using EnvelopeGenerator.Domain.Constants;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
@@ -12,8 +13,66 @@ namespace EnvelopeGenerator.Application.Envelopes.Commands;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public record CreateEnvelopeCommand : IRequest<EnvelopeDto?>
|
public record CreateEnvelopeCommand : IRequest<EnvelopeDto?>
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Optionale Umschlag-ID fuer Aktualisierungsvorgaenge.
|
||||||
|
/// </summary>
|
||||||
public int? EnvelopeId { get; init; }
|
public int? EnvelopeId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Kennung des ausgewaehlten Umschlagtyps (Vorlage).
|
||||||
|
/// </summary>
|
||||||
|
public int? EnvelopeTypeId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sprachcode fuer Umschlaginhalt und Benachrichtigungen.
|
||||||
|
/// </summary>
|
||||||
|
public string? Language { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gibt an, ob der Zugriffscode fuer Empfaenger aktiviert ist.
|
||||||
|
/// </summary>
|
||||||
|
public bool? UseAccessCode { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Zertifizierungstyp fuer den Signaturprozess.
|
||||||
|
/// </summary>
|
||||||
|
public CertificationType? CertificationType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Finale E-Mail-Einstellung fuer den Ersteller.
|
||||||
|
/// </summary>
|
||||||
|
public int? FinalEmailToCreator { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Finale E-Mail-Einstellung fuer die Empfaenger.
|
||||||
|
/// </summary>
|
||||||
|
public int? FinalEmailToReceivers { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gibt an, ob Erinnerungs-E-Mails aktiviert sind.
|
||||||
|
/// </summary>
|
||||||
|
public bool? SendReminderEmails { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Anzahl der Tage bis zur ersten Erinnerung.
|
||||||
|
/// </summary>
|
||||||
|
public int? FirstReminderDays { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Intervall in Tagen zwischen Erinnerungs-E-Mails.
|
||||||
|
/// </summary>
|
||||||
|
public int? ReminderIntervalDays { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Anzahl der Tage bis zum Ablauf des Umschlags.
|
||||||
|
/// </summary>
|
||||||
|
public int? ExpiresWhenDays { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Anzahl der Tage vor Ablauf, ab denen gewarnt wird.
|
||||||
|
/// </summary>
|
||||||
|
public int? ExpiresWarningWhenDays { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Der Titel des Umschlags. Dies ist ein Pflichtfeld.
|
/// Der Titel des Umschlags. Dies ist ein Pflichtfeld.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -29,10 +88,19 @@ public record CreateEnvelopeCommand : IRequest<EnvelopeDto?>
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public bool TFAEnabled { get; set; } = false;
|
public bool TFAEnabled { get; set; } = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gibt an, ob der Umschlag nach dem Speichern sofort versendet werden soll.
|
||||||
|
/// </summary>
|
||||||
public bool Send { get; init; }
|
public bool Send { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optionaler PDF-Dokumentinhalt als Base64.
|
||||||
|
/// </summary>
|
||||||
public CreateEnvelopeDocumentDto? Document { get; init; }
|
public CreateEnvelopeDocumentDto? Document { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Empfaengerliste inklusive optionaler Signaturfeld-Koordinaten.
|
||||||
|
/// </summary>
|
||||||
public List<CreateEnvelopeReceiverDto> Receivers { get; init; } = new();
|
public List<CreateEnvelopeReceiverDto> Receivers { get; init; } = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -62,10 +130,27 @@ public record CreateEnvelopeCommand : IRequest<EnvelopeDto?>
|
|||||||
public bool UseSQLExecutor { get; set; } = true;
|
public bool UseSQLExecutor { get; set; } = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="DataAsBase64"></param>
|
||||||
public record CreateEnvelopeDocumentDto(string DataAsBase64);
|
public record CreateEnvelopeDocumentDto(string DataAsBase64);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="X"></param>
|
||||||
|
/// <param name="Y"></param>
|
||||||
|
/// <param name="Page"></param>
|
||||||
public record CreateEnvelopeFieldDto(double X, double Y, int Page);
|
public record CreateEnvelopeFieldDto(double X, double Y, int Page);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="EmailAddress"></param>
|
||||||
|
/// <param name="Name"></param>
|
||||||
|
/// <param name="PhoneNumber"></param>
|
||||||
|
/// <param name="Fields"></param>
|
||||||
public record CreateEnvelopeReceiverDto(
|
public record CreateEnvelopeReceiverDto(
|
||||||
string EmailAddress,
|
string EmailAddress,
|
||||||
string? Name,
|
string? Name,
|
||||||
|
|||||||
@@ -1,66 +1,66 @@
|
|||||||
using AutoMapper;
|
using AutoMapper;
|
||||||
using MediatR;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using DigitalData.Core.Abstraction.Application.Repository;
|
using DigitalData.Core.Abstraction.Application.Repository;
|
||||||
using DigitalData.EmailProfilerDispatcher.Abstraction.Entities;
|
|
||||||
using DigitalData.Core.Exceptions;
|
using DigitalData.Core.Exceptions;
|
||||||
using EnvelopeGenerator.Domain.Entities;
|
using DigitalData.EmailProfilerDispatcher.Abstraction.Entities;
|
||||||
using EnvelopeGenerator.Application.Common.Dto;
|
using DigitalData.UserManager.Domain.Entities;
|
||||||
using EnvelopeGenerator.Application.Common.Configurations;
|
using EnvelopeGenerator.Application.Common.Configurations;
|
||||||
|
using EnvelopeGenerator.Application.Common.Dto;
|
||||||
using EnvelopeGenerator.Application.Common.Extensions;
|
using EnvelopeGenerator.Application.Common.Extensions;
|
||||||
|
using EnvelopeGenerator.Application.Common.Interfaces;
|
||||||
using EnvelopeGenerator.Application.Common.Interfaces.Services;
|
using EnvelopeGenerator.Application.Common.Interfaces.Services;
|
||||||
|
using EnvelopeGenerator.Application.Common.Interfaces.Services.Notification;
|
||||||
using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor;
|
using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor;
|
||||||
using EnvelopeGenerator.Application.Histories.Commands;
|
using EnvelopeGenerator.Application.Histories.Commands;
|
||||||
using EnvelopeGenerator.Domain.Constants;
|
|
||||||
using EnvelopeGenerator.Domain;
|
using EnvelopeGenerator.Domain;
|
||||||
|
using EnvelopeGenerator.Domain.Constants;
|
||||||
|
using EnvelopeGenerator.Domain.Entities;
|
||||||
|
using EnvelopeGenerator.Domain.Interfaces;
|
||||||
|
using MediatR;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace EnvelopeGenerator.Application.Envelopes.Commands;
|
namespace EnvelopeGenerator.Application.Envelopes.Commands;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
///
|
///
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeCommand, EnvelopeDto?>
|
/// <param name="Provider"></param>
|
||||||
|
/// <param name="Mapper"></param>
|
||||||
|
/// <param name="EnvNtfService"></param>
|
||||||
|
/// <param name="ReqCntxt"></param>
|
||||||
|
/// <param name="Logger"></param>
|
||||||
|
public class CreateEnvelopeCommandHandler(IServiceProvider Provider, IMapper Mapper, IEnvelopeNotificationService EnvNtfService, IRequestContext ReqCntxt, ILogger<CreateEnvelopeCommandHandler> Logger) : IRequestHandler<CreateEnvelopeCommand, EnvelopeDto?>
|
||||||
{
|
{
|
||||||
private readonly IServiceProvider _provider;
|
private IEnvelopeExecutor Executor => Provider.GetRequiredService<IEnvelopeExecutor>();
|
||||||
|
|
||||||
private IEnvelopeExecutor Executor => _provider.GetRequiredService<IEnvelopeExecutor>();
|
private IRepository<Envelope> Repository => Provider.GetRequiredService<IRepository<Envelope>>();
|
||||||
|
|
||||||
private IRepository<Envelope> Repository => _provider.GetRequiredService<IRepository<Envelope>>();
|
private IRepository<Document> DocumentRepository => Provider.GetRequiredService<IRepository<Document>>();
|
||||||
|
|
||||||
private IRepository<Document> DocumentRepository => _provider.GetRequiredService<IRepository<Document>>();
|
private IRepository<EnvelopeReceiver> EnvelopeReceiverRepository => Provider.GetRequiredService<IRepository<EnvelopeReceiver>>();
|
||||||
|
|
||||||
private IRepository<EnvelopeReceiver> EnvelopeReceiverRepository => _provider.GetRequiredService<IRepository<EnvelopeReceiver>>();
|
private IRepository<EnvelopeType> EnvelopeTypeRepository => Provider.GetRequiredService<IRepository<EnvelopeType>>();
|
||||||
|
|
||||||
private IRepository<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 IConfigService ConfigService => _provider.GetRequiredService<IConfigService>();
|
private SignatureFieldParams SignatureFieldParams => Provider.GetRequiredService<IOptionsMonitor<SignatureFieldParams>>().CurrentValue;
|
||||||
|
|
||||||
private IMediator Mediator => _provider.GetRequiredService<IMediator>();
|
[Obsolete("Use IConfigService instead.")]
|
||||||
|
private IConfigService ConfigService => Provider.GetRequiredService<IConfigService>();
|
||||||
|
|
||||||
private readonly IMapper _mapper;
|
private IMediator Mediator => Provider.GetRequiredService<IMediator>();
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
///
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="provider"></param>
|
|
||||||
/// <param name="mapper"></param>
|
|
||||||
public CreateEnvelopeCommandHandler(IServiceProvider provider, IMapper mapper)
|
|
||||||
{
|
|
||||||
_provider = provider;
|
|
||||||
_mapper = mapper;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
///
|
///
|
||||||
@@ -73,6 +73,7 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
|
|||||||
var now = DateTime.Now;
|
var now = DateTime.Now;
|
||||||
var title = request.Title?.Trim() ?? string.Empty;
|
var title = request.Title?.Trim() ?? string.Empty;
|
||||||
var message = request.Message?.Trim() ?? string.Empty;
|
var message = request.Message?.Trim() ?? string.Empty;
|
||||||
|
var settings = await ResolveEnvelopeSettingsAsync(request, existing: null, cancel);
|
||||||
|
|
||||||
Envelope envelope;
|
Envelope envelope;
|
||||||
if (request.EnvelopeId is int envelopeId)
|
if (request.EnvelopeId is int envelopeId)
|
||||||
@@ -85,11 +86,27 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
|
|||||||
if (envelope.UserId != request.UserId)
|
if (envelope.UserId != request.UserId)
|
||||||
throw new ForbiddenException();
|
throw new ForbiddenException();
|
||||||
|
|
||||||
|
settings = await ResolveEnvelopeSettingsAsync(request, envelope, cancel);
|
||||||
|
|
||||||
await Repository.UpdateAsync(e =>
|
await Repository.UpdateAsync(e =>
|
||||||
{
|
{
|
||||||
e.Title = title;
|
e.Title = title;
|
||||||
e.Message = message;
|
e.Message = message;
|
||||||
e.TfaEnabled = request.TFAEnabled;
|
e.TfaEnabled = request.TFAEnabled;
|
||||||
|
e.EnvelopeTypeId = settings.EnvelopeTypeId;
|
||||||
|
e.Language = settings.Language;
|
||||||
|
e.UseAccessCode = settings.UseAccessCode;
|
||||||
|
e.CertificationType = settings.CertificationType;
|
||||||
|
e.FinalEmailToCreator = settings.FinalEmailToCreator;
|
||||||
|
e.FinalEmailToReceivers = settings.FinalEmailToReceivers;
|
||||||
|
e.SendReminderEmails = settings.SendReminderEmails;
|
||||||
|
e.FirstReminderDays = settings.FirstReminderDays;
|
||||||
|
e.ReminderIntervalDays = settings.ReminderIntervalDays;
|
||||||
|
e.ExpiresWhenDays = settings.ExpiresWhenDays;
|
||||||
|
e.ExpiresWarningWhenDays = settings.ExpiresWarningWhenDays;
|
||||||
|
e.ContractType = settings.ContractType;
|
||||||
|
e.ExpiresWhen = settings.ExpiresWhen;
|
||||||
|
e.ExpiresWarningWhen = settings.ExpiresWarningWhen;
|
||||||
e.Status = EnvelopeStatus.EnvelopeSaved;
|
e.Status = EnvelopeStatus.EnvelopeSaved;
|
||||||
e.ChangedWhen = now;
|
e.ChangedWhen = now;
|
||||||
}, e => e.Id == envelopeId, cancel);
|
}, e => e.Id == envelopeId, cancel);
|
||||||
@@ -97,6 +114,20 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
|
|||||||
envelope.Title = title;
|
envelope.Title = title;
|
||||||
envelope.Message = message;
|
envelope.Message = message;
|
||||||
envelope.TfaEnabled = request.TFAEnabled;
|
envelope.TfaEnabled = request.TFAEnabled;
|
||||||
|
envelope.EnvelopeTypeId = settings.EnvelopeTypeId;
|
||||||
|
envelope.Language = settings.Language;
|
||||||
|
envelope.UseAccessCode = settings.UseAccessCode;
|
||||||
|
envelope.CertificationType = settings.CertificationType;
|
||||||
|
envelope.FinalEmailToCreator = settings.FinalEmailToCreator;
|
||||||
|
envelope.FinalEmailToReceivers = settings.FinalEmailToReceivers;
|
||||||
|
envelope.SendReminderEmails = settings.SendReminderEmails;
|
||||||
|
envelope.FirstReminderDays = settings.FirstReminderDays;
|
||||||
|
envelope.ReminderIntervalDays = settings.ReminderIntervalDays;
|
||||||
|
envelope.ExpiresWhenDays = settings.ExpiresWhenDays;
|
||||||
|
envelope.ExpiresWarningWhenDays = settings.ExpiresWarningWhenDays;
|
||||||
|
envelope.ContractType = settings.ContractType;
|
||||||
|
envelope.ExpiresWhen = settings.ExpiresWhen;
|
||||||
|
envelope.ExpiresWarningWhen = settings.ExpiresWarningWhen;
|
||||||
envelope.Status = EnvelopeStatus.EnvelopeSaved;
|
envelope.Status = EnvelopeStatus.EnvelopeSaved;
|
||||||
envelope.ChangedWhen = now;
|
envelope.ChangedWhen = now;
|
||||||
}
|
}
|
||||||
@@ -114,12 +145,69 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
|
|||||||
AddedWhen = now,
|
AddedWhen = now,
|
||||||
ChangedWhen = now,
|
ChangedWhen = now,
|
||||||
Comment = string.Empty,
|
Comment = string.Empty,
|
||||||
Language = "de-DE",
|
EnvelopeTypeId = settings.EnvelopeTypeId,
|
||||||
|
Language = settings.Language,
|
||||||
TfaEnabled = request.TFAEnabled,
|
TfaEnabled = request.TFAEnabled,
|
||||||
UseAccessCode = false,
|
UseAccessCode = settings.UseAccessCode,
|
||||||
SendReminderEmails = false
|
CertificationType = settings.CertificationType,
|
||||||
|
FinalEmailToCreator = settings.FinalEmailToCreator,
|
||||||
|
FinalEmailToReceivers = settings.FinalEmailToReceivers,
|
||||||
|
SendReminderEmails = settings.SendReminderEmails,
|
||||||
|
FirstReminderDays = settings.FirstReminderDays,
|
||||||
|
ReminderIntervalDays = settings.ReminderIntervalDays,
|
||||||
|
ExpiresWhenDays = settings.ExpiresWhenDays,
|
||||||
|
ExpiresWarningWhenDays = settings.ExpiresWarningWhenDays,
|
||||||
|
ExpiresWhen = settings.ExpiresWhen,
|
||||||
|
ExpiresWarningWhen = settings.ExpiresWarningWhen,
|
||||||
|
ContractType = settings.ContractType
|
||||||
}, cancel);
|
}, cancel);
|
||||||
|
|
||||||
|
if (request.UseSQLExecutor)
|
||||||
|
{
|
||||||
|
await Repository.UpdateAsync(e =>
|
||||||
|
{
|
||||||
|
e.Title = title;
|
||||||
|
e.Message = message;
|
||||||
|
e.TfaEnabled = request.TFAEnabled;
|
||||||
|
e.EnvelopeTypeId = settings.EnvelopeTypeId;
|
||||||
|
e.Language = settings.Language;
|
||||||
|
e.UseAccessCode = settings.UseAccessCode;
|
||||||
|
e.CertificationType = settings.CertificationType;
|
||||||
|
e.FinalEmailToCreator = settings.FinalEmailToCreator;
|
||||||
|
e.FinalEmailToReceivers = settings.FinalEmailToReceivers;
|
||||||
|
e.SendReminderEmails = settings.SendReminderEmails;
|
||||||
|
e.FirstReminderDays = settings.FirstReminderDays;
|
||||||
|
e.ReminderIntervalDays = settings.ReminderIntervalDays;
|
||||||
|
e.ExpiresWhenDays = settings.ExpiresWhenDays;
|
||||||
|
e.ExpiresWarningWhenDays = settings.ExpiresWarningWhenDays;
|
||||||
|
e.ExpiresWhen = settings.ExpiresWhen;
|
||||||
|
e.ExpiresWarningWhen = settings.ExpiresWarningWhen;
|
||||||
|
e.ContractType = settings.ContractType;
|
||||||
|
e.Status = EnvelopeStatus.EnvelopeSaved;
|
||||||
|
e.ChangedWhen = now;
|
||||||
|
}, e => e.Id == envelope.Id, cancel);
|
||||||
|
|
||||||
|
envelope.Title = title;
|
||||||
|
envelope.Message = message;
|
||||||
|
envelope.TfaEnabled = request.TFAEnabled;
|
||||||
|
envelope.EnvelopeTypeId = settings.EnvelopeTypeId;
|
||||||
|
envelope.Language = settings.Language;
|
||||||
|
envelope.UseAccessCode = settings.UseAccessCode;
|
||||||
|
envelope.CertificationType = settings.CertificationType;
|
||||||
|
envelope.FinalEmailToCreator = settings.FinalEmailToCreator;
|
||||||
|
envelope.FinalEmailToReceivers = settings.FinalEmailToReceivers;
|
||||||
|
envelope.SendReminderEmails = settings.SendReminderEmails;
|
||||||
|
envelope.FirstReminderDays = settings.FirstReminderDays;
|
||||||
|
envelope.ReminderIntervalDays = settings.ReminderIntervalDays;
|
||||||
|
envelope.ExpiresWhenDays = settings.ExpiresWhenDays;
|
||||||
|
envelope.ExpiresWarningWhenDays = settings.ExpiresWarningWhenDays;
|
||||||
|
envelope.ExpiresWhen = settings.ExpiresWhen;
|
||||||
|
envelope.ExpiresWarningWhen = settings.ExpiresWarningWhen;
|
||||||
|
envelope.ContractType = settings.ContractType;
|
||||||
|
envelope.Status = EnvelopeStatus.EnvelopeSaved;
|
||||||
|
envelope.ChangedWhen = now;
|
||||||
|
}
|
||||||
|
|
||||||
await Mediator.Send(new CreateHistoryCommand
|
await Mediator.Send(new CreateHistoryCommand
|
||||||
{
|
{
|
||||||
EnvelopeId = envelope.Id,
|
EnvelopeId = envelope.Id,
|
||||||
@@ -140,9 +228,108 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
|
|||||||
.ThenInclude(er => er.Receiver)
|
.ThenInclude(er => er.Receiver)
|
||||||
.FirstAsync(e => e.Id == envelope.Id, cancel);
|
.FirstAsync(e => e.Id == envelope.Id, cancel);
|
||||||
|
|
||||||
return _mapper.Map<EnvelopeDto>(envelope);
|
return Mapper.Map<EnvelopeDto>(envelope);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<EnvelopeSettings> ResolveEnvelopeSettingsAsync(CreateEnvelopeCommand request, Envelope? existing, CancellationToken cancel)
|
||||||
|
{
|
||||||
|
EnvelopeType? selectedType = null;
|
||||||
|
if (request.EnvelopeTypeId is int envelopeTypeId && envelopeTypeId > 0)
|
||||||
|
{
|
||||||
|
selectedType = await EnvelopeTypeRepository.Query
|
||||||
|
.FirstOrDefaultAsync(t => t.Id == envelopeTypeId, cancel)
|
||||||
|
?? throw new BadRequestException($"Envelope type {envelopeTypeId} not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var expiresWhenDays = Math.Max(0,
|
||||||
|
request.ExpiresWhenDays
|
||||||
|
?? selectedType?.ExpiresWhenDays
|
||||||
|
?? existing?.ExpiresWhenDays
|
||||||
|
?? 0);
|
||||||
|
|
||||||
|
var expiresWarningWhenDays = Math.Max(0,
|
||||||
|
request.ExpiresWarningWhenDays
|
||||||
|
?? selectedType?.ExpiresWarningWhenDays
|
||||||
|
?? existing?.ExpiresWarningWhenDays
|
||||||
|
?? 0);
|
||||||
|
|
||||||
|
var useAccessCode = request.UseAccessCode
|
||||||
|
?? selectedType?.UseAccessCode
|
||||||
|
?? existing?.UseAccessCode
|
||||||
|
?? false;
|
||||||
|
|
||||||
|
if (request.TFAEnabled)
|
||||||
|
useAccessCode = true;
|
||||||
|
|
||||||
|
return new EnvelopeSettings(
|
||||||
|
EnvelopeTypeId: request.EnvelopeTypeId ?? existing?.EnvelopeTypeId,
|
||||||
|
Language: ResolveLanguage(request.Language, selectedType?.Language, existing?.Language),
|
||||||
|
UseAccessCode: useAccessCode,
|
||||||
|
CertificationType: request.CertificationType
|
||||||
|
?? selectedType?.CertificationType
|
||||||
|
?? existing?.CertificationType
|
||||||
|
?? CertificationType.AdvancedElectronicSignature,
|
||||||
|
FinalEmailToCreator: request.FinalEmailToCreator
|
||||||
|
?? (int?)selectedType?.FinalEmailToCreator
|
||||||
|
?? existing?.FinalEmailToCreator
|
||||||
|
?? (int)FinalEmailType.No,
|
||||||
|
FinalEmailToReceivers: request.FinalEmailToReceivers
|
||||||
|
?? (int?)selectedType?.FinalEmailToReceivers
|
||||||
|
?? existing?.FinalEmailToReceivers
|
||||||
|
?? (int)FinalEmailType.No,
|
||||||
|
SendReminderEmails: request.SendReminderEmails
|
||||||
|
?? selectedType?.SendReminderEmails
|
||||||
|
?? existing?.SendReminderEmails
|
||||||
|
?? false,
|
||||||
|
FirstReminderDays: Math.Max(0,
|
||||||
|
request.FirstReminderDays
|
||||||
|
?? selectedType?.FirstReminderDays
|
||||||
|
?? existing?.FirstReminderDays
|
||||||
|
?? 0),
|
||||||
|
ReminderIntervalDays: Math.Max(0,
|
||||||
|
request.ReminderIntervalDays
|
||||||
|
?? selectedType?.ReminderIntervalDays
|
||||||
|
?? existing?.ReminderIntervalDays
|
||||||
|
?? 0),
|
||||||
|
ExpiresWhenDays: expiresWhenDays,
|
||||||
|
ExpiresWarningWhenDays: expiresWarningWhenDays,
|
||||||
|
ExpiresWhen: expiresWhenDays > 0 ? DateTime.Now.AddDays(expiresWhenDays) : null,
|
||||||
|
ExpiresWarningWhen: expiresWarningWhenDays > 0 ? DateTime.Now.AddDays(expiresWarningWhenDays) : null,
|
||||||
|
ContractType: existing?.ContractType
|
||||||
|
?? selectedType?.ContractType
|
||||||
|
?? ContractType.Contract);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveLanguage(string? preferred, string? fallback1, string? fallback2)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(preferred))
|
||||||
|
return preferred.Trim();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(fallback1))
|
||||||
|
return fallback1.Trim();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(fallback2))
|
||||||
|
return fallback2.Trim();
|
||||||
|
|
||||||
|
return "de-DE";
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record EnvelopeSettings(
|
||||||
|
int? EnvelopeTypeId,
|
||||||
|
string Language,
|
||||||
|
bool UseAccessCode,
|
||||||
|
CertificationType CertificationType,
|
||||||
|
int FinalEmailToCreator,
|
||||||
|
int FinalEmailToReceivers,
|
||||||
|
bool SendReminderEmails,
|
||||||
|
int FirstReminderDays,
|
||||||
|
int ReminderIntervalDays,
|
||||||
|
int ExpiresWhenDays,
|
||||||
|
int ExpiresWarningWhenDays,
|
||||||
|
DateTime? ExpiresWhen,
|
||||||
|
DateTime? ExpiresWarningWhen,
|
||||||
|
ContractType ContractType);
|
||||||
|
|
||||||
private async Task<string> ResolveSenderReferenceAsync(Envelope envelope, CancellationToken cancel)
|
private async Task<string> ResolveSenderReferenceAsync(Envelope envelope, CancellationToken cancel)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(envelope.User?.Email))
|
if (!string.IsNullOrWhiteSpace(envelope.User?.Email))
|
||||||
@@ -255,8 +442,8 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
|
|||||||
ElementType = 1,
|
ElementType = 1,
|
||||||
X = field.X,
|
X = field.X,
|
||||||
Y = field.Y,
|
Y = field.Y,
|
||||||
Width = 1.77,
|
Width = SignatureFieldParams.Width,
|
||||||
Height = 1.96,
|
Height = SignatureFieldParams.Height,
|
||||||
Page = field.Page,
|
Page = field.Page,
|
||||||
Required = true,
|
Required = true,
|
||||||
ReadOnly = false,
|
ReadOnly = false,
|
||||||
@@ -340,15 +527,15 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
|
|||||||
if (!hasDocument)
|
if (!hasDocument)
|
||||||
throw new BadRequestException("Cannot send envelope without a document.");
|
throw new BadRequestException("Cannot send envelope without a document.");
|
||||||
|
|
||||||
var links = await EnvelopeReceiverRepository.Query
|
var envRcvs = await EnvelopeReceiverRepository.Query
|
||||||
.Include(er => er.Receiver)
|
.Include(er => er.Receiver)
|
||||||
.Where(er => er.EnvelopeId == envelope.Id)
|
.Where(er => er.EnvelopeId == envelope.Id)
|
||||||
.ToListAsync(cancel);
|
.ToListAsync(cancel);
|
||||||
|
|
||||||
if (links.Count == 0)
|
if (envRcvs.Count == 0)
|
||||||
throw new BadRequestException("Cannot send envelope without receivers.");
|
throw new BadRequestException("Cannot send envelope without receivers.");
|
||||||
|
|
||||||
var receiverIds = links.Select(l => l.ReceiverId).Distinct().ToList();
|
var receiverIds = envRcvs.Select(l => l.ReceiverId).Distinct().ToList();
|
||||||
var documentIds = await DocumentRepository.Query
|
var documentIds = await DocumentRepository.Query
|
||||||
.Where(d => d.EnvelopeId == envelope.Id)
|
.Where(d => d.EnvelopeId == envelope.Id)
|
||||||
.Select(d => d.Id)
|
.Select(d => d.Id)
|
||||||
@@ -361,7 +548,7 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
|
|||||||
.Select(g => new { ReceiverId = g.Key, Count = g.Count() })
|
.Select(g => new { ReceiverId = g.Key, Count = g.Count() })
|
||||||
.ToDictionaryAsync(x => x.ReceiverId, x => x.Count, cancel);
|
.ToDictionaryAsync(x => x.ReceiverId, x => x.Count, cancel);
|
||||||
|
|
||||||
var receiversWithoutFields = links
|
var receiversWithoutFields = envRcvs
|
||||||
.Where(link => !receiverFieldCounts.TryGetValue(link.ReceiverId, out var count) || count <= 0)
|
.Where(link => !receiverFieldCounts.TryGetValue(link.ReceiverId, out var count) || count <= 0)
|
||||||
.Select(link => link.Name)
|
.Select(link => link.Name)
|
||||||
.ToList();
|
.ToList();
|
||||||
@@ -385,70 +572,39 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
|
|||||||
Status = EnvelopeStatus.EnvelopeQueued
|
Status = EnvelopeStatus.EnvelopeQueued
|
||||||
}, cancel);
|
}, cancel);
|
||||||
|
|
||||||
await QueueInvitationEmailsAsync(envelope, links, userReference, cancel);
|
await SendInvitationEmailsAsync(envelope, envRcvs, cancel);
|
||||||
|
|
||||||
envelope.Status = EnvelopeStatus.EnvelopeQueued;
|
envelope.Status = EnvelopeStatus.EnvelopeQueued;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task QueueInvitationEmailsAsync(
|
private async Task SendInvitationEmailsAsync(
|
||||||
Envelope envelope,
|
Envelope envelope,
|
||||||
IReadOnlyCollection<EnvelopeReceiver> links,
|
IReadOnlyCollection<EnvelopeReceiver> envRcvs,
|
||||||
string senderReference,
|
|
||||||
CancellationToken cancel)
|
CancellationToken cancel)
|
||||||
{
|
{
|
||||||
var template = await EmailTemplateRepository.Query
|
foreach (var er in envRcvs)
|
||||||
.SingleOrDefaultAsync(x => x.Name == EmailTemplateType.DocumentReceived.ToString(), cancel)
|
|
||||||
?? throw new InvalidOperationException($"Email template not found: {EmailTemplateType.DocumentReceived}");
|
|
||||||
|
|
||||||
var signatureHost = await ConfigService.ReadDefaultSignatureHost();
|
|
||||||
var senderName = senderReference;
|
|
||||||
|
|
||||||
foreach (var link in links)
|
|
||||||
{
|
{
|
||||||
var receiver = link.Receiver;
|
var advancedSignature = envelope.CertificationType == CertificationType.AdvancedElectronicSignature;
|
||||||
if (receiver is null || string.IsNullOrWhiteSpace(receiver.EmailAddress) || string.IsNullOrWhiteSpace(receiver.Signature))
|
var origin = new Uri(ReqCntxt.Origin ?? throw new InvalidOperationException($"The request origin is missing in {nameof(CreateEnvelopeCommandHandler)}."));
|
||||||
continue;
|
try
|
||||||
|
|
||||||
var envelopeKey = (envelope.Uuid, receiver.Signature).ToEnvelopeKey();
|
|
||||||
var documentLink = $"{signatureHost.TrimEnd('/')}/envelope/login/{Uri.EscapeDataString(envelopeKey)}";
|
|
||||||
|
|
||||||
var placeholders = new Dictionary<string, string>(MailParams.Placeholders)
|
|
||||||
{
|
{
|
||||||
["[NAME_RECEIVER]"] = link.Name ?? string.Empty,
|
await EnvNtfService.Notify(er.Receiver.EmailAddress, new DocumentReceivedNotification
|
||||||
["[NAME_SENDER]"] = senderName ?? string.Empty,
|
{
|
||||||
["[DOCUMENT_TITLE]"] = envelope.Title ?? string.Empty,
|
AdvancedSignature = advancedSignature,
|
||||||
["[MESSAGE]"] = envelope.Message ?? string.Empty,
|
DocumentTitle = envelope.Title ?? string.Empty,
|
||||||
["[LINK_TO_DOCUMENT]"] = documentLink,
|
NameReceiver = er.Name ?? string.Empty,
|
||||||
["[LINK_TO_DOCUMENT_TEXT]"] = documentLink[..Math.Min(40, documentLink.Length)] + ".."
|
EnvelopeUuid = envelope.Uuid,
|
||||||
};
|
IsReadAndConfirm = envelope.IsReadAndConfirm(),
|
||||||
|
Message = envelope.Message ?? string.Empty,
|
||||||
var subject = ReplacePlaceHolders(template.Subject, placeholders);
|
NameSender = $"{envelope.User.Prename} {envelope.User.Name}",
|
||||||
var body = ReplacePlaceHolders(template.Body, placeholders);
|
Origin = origin,
|
||||||
|
ReceiverSignature = er.Receiver.Signature,
|
||||||
var emailOut = new EmailOut
|
}, cancel);
|
||||||
|
}
|
||||||
|
catch(Exception ex)
|
||||||
{
|
{
|
||||||
EmailAddress = receiver.EmailAddress,
|
Logger.LogError(ex, "Failed to send invitation email for receiver {emailAddress} in envelope {envelopeId}.", er.Receiver.EmailAddress, envelope.Id);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 AutoMapper;
|
||||||
using EnvelopeGenerator.Application.EnvelopeReceivers.Commands;
|
using EnvelopeGenerator.Application.EnvelopeReceivers.Commands.CreateEnvelopeReceiver;
|
||||||
using EnvelopeGenerator.Application.Envelopes.Commands;
|
using EnvelopeGenerator.Application.Envelopes.Commands;
|
||||||
using EnvelopeGenerator.Domain.Entities;
|
using EnvelopeGenerator.Domain.Entities;
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
using EnvelopeGenerator.Domain.Constants;
|
using EnvelopeGenerator.Domain.Constants;
|
||||||
using EnvelopeGenerator.Application.Common.Query;
|
using EnvelopeGenerator.Application.Common.Query;
|
||||||
using EnvelopeGenerator.Application.Common.Dto;
|
using EnvelopeGenerator.Application.Common.Dto;
|
||||||
|
using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver;
|
||||||
|
using EnvelopeGenerator.Application.Common.Dto.History;
|
||||||
|
using EnvelopeGenerator.Application.Common.Dto.Receiver;
|
||||||
using AutoMapper;
|
using AutoMapper;
|
||||||
using DigitalData.Core.Abstraction.Application.Repository;
|
using DigitalData.Core.Abstraction.Application.Repository;
|
||||||
using EnvelopeGenerator.Domain.Entities;
|
using EnvelopeGenerator.Domain.Entities;
|
||||||
@@ -14,6 +17,11 @@ namespace EnvelopeGenerator.Application.Envelopes.Queries;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public record ReadEnvelopeQuery : EnvelopeQueryBase, IRequest<IEnumerable<EnvelopeDto>>
|
public record ReadEnvelopeQuery : EnvelopeQueryBase, IRequest<IEnumerable<EnvelopeDto>>
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
public bool IncludeDocAndDocRes { get; init; } = true;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
///
|
///
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -148,6 +156,86 @@ public class ReadEnvelopeQueryHandler : IRequestHandler<ReadEnvelopeQuery, IEnum
|
|||||||
if (request is { OnlyCompleted: true })
|
if (request is { OnlyCompleted: true })
|
||||||
query = query.Where(e => Status.Completed.Contains(e.Status));
|
query = query.Where(e => Status.Completed.Contains(e.Status));
|
||||||
|
|
||||||
|
if (!request.IncludeDocAndDocRes)
|
||||||
|
{
|
||||||
|
return await query
|
||||||
|
.Select(e => new EnvelopeDto
|
||||||
|
{
|
||||||
|
Id = e.Id,
|
||||||
|
UserId = e.UserId,
|
||||||
|
Status = e.Status,
|
||||||
|
StatusName = string.Empty,
|
||||||
|
Uuid = e.Uuid,
|
||||||
|
Message = e.Message,
|
||||||
|
AddedWhen = e.AddedWhen,
|
||||||
|
ChangedWhen = e.ChangedWhen,
|
||||||
|
Title = e.Title ?? string.Empty,
|
||||||
|
ContractType = e.ContractType,
|
||||||
|
Language = e.Language,
|
||||||
|
EnvelopeTypeId = e.EnvelopeTypeId,
|
||||||
|
CertificationType = e.CertificationType,
|
||||||
|
FinalEmailToCreator = e.FinalEmailToCreator,
|
||||||
|
FinalEmailToReceivers = e.FinalEmailToReceivers,
|
||||||
|
SendReminderEmails = e.SendReminderEmails,
|
||||||
|
FirstReminderDays = e.FirstReminderDays,
|
||||||
|
ReminderIntervalDays = e.ReminderIntervalDays,
|
||||||
|
ExpiresWhenDays = e.ExpiresWhenDays,
|
||||||
|
ExpiresWarningWhenDays = e.ExpiresWarningWhenDays,
|
||||||
|
UseAccessCode = e.UseAccessCode ?? true,
|
||||||
|
TFAEnabled = e.TfaEnabled ?? false,
|
||||||
|
EnvelopeTypeTitle = e.Type != null ? e.Type.Title : null,
|
||||||
|
IsAlreadySent = false,
|
||||||
|
DocResult = null,
|
||||||
|
Documents = null,
|
||||||
|
EnvelopeReceivers = e.EnvelopeReceivers.Select(er => new EnvelopeReceiverDto
|
||||||
|
{
|
||||||
|
EnvelopeId = er.EnvelopeId,
|
||||||
|
ReceiverId = er.ReceiverId,
|
||||||
|
Sequence = er.Sequence,
|
||||||
|
Name = er.Name,
|
||||||
|
JobTitle = er.JobTitle,
|
||||||
|
CompanyName = er.CompanyName,
|
||||||
|
PrivateMessage = er.PrivateMessage,
|
||||||
|
AccessCode = er.AccessCode,
|
||||||
|
AddedWhen = er.AddedWhen,
|
||||||
|
ChangedWhen = er.ChangedWhen,
|
||||||
|
HasPhoneNumber = er.HasPhoneNumber,
|
||||||
|
Receiver = er.Receiver == null
|
||||||
|
? null
|
||||||
|
: new ReceiverDto
|
||||||
|
{
|
||||||
|
Id = er.Receiver.Id,
|
||||||
|
EmailAddress = er.Receiver.EmailAddress,
|
||||||
|
Signature = er.Receiver.Signature,
|
||||||
|
AddedWhen = er.Receiver.AddedWhen,
|
||||||
|
TfaRegDeadline = er.Receiver.TfaRegDeadline
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
Histories = e.Histories.Select(h => new HistoryDto
|
||||||
|
{
|
||||||
|
Id = h.Id,
|
||||||
|
EnvelopeId = h.EnvelopeId,
|
||||||
|
UserReference = h.UserReference,
|
||||||
|
Status = h.Status,
|
||||||
|
StatusName = null,
|
||||||
|
AddedWhen = h.AddedWhen,
|
||||||
|
ActionDate = h.ActionDate,
|
||||||
|
Comment = h.Comment,
|
||||||
|
Receiver = h.Receiver == null
|
||||||
|
? null
|
||||||
|
: new ReceiverDto
|
||||||
|
{
|
||||||
|
Id = h.Receiver.Id,
|
||||||
|
EmailAddress = h.Receiver.EmailAddress,
|
||||||
|
Signature = h.Receiver.Signature,
|
||||||
|
AddedWhen = h.Receiver.AddedWhen,
|
||||||
|
TfaRegDeadline = h.Receiver.TfaRegDeadline
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.ToListAsync(cancel);
|
||||||
|
}
|
||||||
|
|
||||||
var envelopes = await query
|
var envelopes = await query
|
||||||
.Include(e => e.Type)
|
.Include(e => e.Type)
|
||||||
.Include(e => e.EnvelopeReceivers).ThenInclude(er => er.Receiver)
|
.Include(e => e.EnvelopeReceivers).ThenInclude(er => er.Receiver)
|
||||||
|
|||||||
@@ -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">
|
<data name="Back" xml:space="preserve">
|
||||||
<value>Zurück</value>
|
<value>Zurück</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="Next" xml:space="preserve">
|
||||||
|
<value>Weiter</value>
|
||||||
|
</data>
|
||||||
<data name="AuthenticatorSetup_Prefix" xml:space="preserve">
|
<data name="AuthenticatorSetup_Prefix" xml:space="preserve">
|
||||||
<value>Klicken Sie auf den</value>
|
<value>Klicken Sie auf den</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -564,6 +567,12 @@
|
|||||||
<data name="AddReceiver" xml:space="preserve">
|
<data name="AddReceiver" xml:space="preserve">
|
||||||
<value>Empfänger hinzufügen</value>
|
<value>Empfänger hinzufügen</value>
|
||||||
</data>
|
</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">
|
<data name="DeleteReceiver" xml:space="preserve">
|
||||||
<value>Empfänger löschen</value>
|
<value>Empfänger löschen</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -735,7 +744,9 @@
|
|||||||
<data name="SenderEditor_LoadFileError" xml:space="preserve"><value>Fehler beim Laden der Datei: {0}</value></data>
|
<data name="SenderEditor_LoadFileError" xml:space="preserve"><value>Fehler beim Laden der Datei: {0}</value></data>
|
||||||
<data name="SenderEditor_Message_Placeholder" xml:space="preserve"><value>Bitte unterzeichnen Sie das beigefügte Dokument.</value></data>
|
<data name="SenderEditor_Message_Placeholder" xml:space="preserve"><value>Bitte unterzeichnen Sie das beigefügte Dokument.</value></data>
|
||||||
<data name="SenderEditor_NoDocumentLoaded" xml:space="preserve"><value>Kein Dokument geladen</value></data>
|
<data name="SenderEditor_NoDocumentLoaded" xml:space="preserve"><value>Kein Dokument geladen</value></data>
|
||||||
|
<data name="SenderEditor_NoPagesToDisplay" xml:space="preserve"><value>Keine Seiten zur Anzeige verfügbar.</value></data>
|
||||||
<data name="SenderEditor_NoReceiversAdded" xml:space="preserve"><value>Noch keine Empfänger hinzugefügt.</value></data>
|
<data name="SenderEditor_NoReceiversAdded" xml:space="preserve"><value>Noch keine Empfänger hinzugefügt.</value></data>
|
||||||
|
<data name="SenderEditor_PageLabel" xml:space="preserve"><value>Seite</value></data>
|
||||||
<data name="SenderEditor_PlaceSignatureField" xml:space="preserve"><value>Signaturfeld platzieren</value></data>
|
<data name="SenderEditor_PlaceSignatureField" xml:space="preserve"><value>Signaturfeld platzieren</value></data>
|
||||||
<data name="SenderEditor_RemoveAllSignatureFields" xml:space="preserve"><value>Alle Signaturfelder entfernen</value></data>
|
<data name="SenderEditor_RemoveAllSignatureFields" xml:space="preserve"><value>Alle Signaturfelder entfernen</value></data>
|
||||||
<data name="SenderEditor_SelectPdf" xml:space="preserve"><value>Bitte wählen Sie eine PDF-Datei aus.</value></data>
|
<data name="SenderEditor_SelectPdf" xml:space="preserve"><value>Bitte wählen Sie eine PDF-Datei aus.</value></data>
|
||||||
|
|||||||
@@ -123,6 +123,9 @@
|
|||||||
<data name="Back" xml:space="preserve">
|
<data name="Back" xml:space="preserve">
|
||||||
<value>Back</value>
|
<value>Back</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="Next" xml:space="preserve">
|
||||||
|
<value>Next</value>
|
||||||
|
</data>
|
||||||
<data name="AuthenticatorSetup_Prefix" xml:space="preserve">
|
<data name="AuthenticatorSetup_Prefix" xml:space="preserve">
|
||||||
<value>Click the</value>
|
<value>Click the</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -564,6 +567,12 @@
|
|||||||
<data name="AddReceiver" xml:space="preserve">
|
<data name="AddReceiver" xml:space="preserve">
|
||||||
<value>Add Receiver</value>
|
<value>Add Receiver</value>
|
||||||
</data>
|
</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">
|
<data name="DeleteReceiver" xml:space="preserve">
|
||||||
<value>Delete Receiver</value>
|
<value>Delete Receiver</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -735,7 +744,9 @@
|
|||||||
<data name="SenderEditor_LoadFileError" xml:space="preserve"><value>Error loading file: {0}</value></data>
|
<data name="SenderEditor_LoadFileError" xml:space="preserve"><value>Error loading file: {0}</value></data>
|
||||||
<data name="SenderEditor_Message_Placeholder" xml:space="preserve"><value>Please sign the attached document.</value></data>
|
<data name="SenderEditor_Message_Placeholder" xml:space="preserve"><value>Please sign the attached document.</value></data>
|
||||||
<data name="SenderEditor_NoDocumentLoaded" xml:space="preserve"><value>No document loaded</value></data>
|
<data name="SenderEditor_NoDocumentLoaded" xml:space="preserve"><value>No document loaded</value></data>
|
||||||
|
<data name="SenderEditor_NoPagesToDisplay" xml:space="preserve"><value>No pages to display.</value></data>
|
||||||
<data name="SenderEditor_NoReceiversAdded" xml:space="preserve"><value>No receivers added yet.</value></data>
|
<data name="SenderEditor_NoReceiversAdded" xml:space="preserve"><value>No receivers added yet.</value></data>
|
||||||
|
<data name="SenderEditor_PageLabel" xml:space="preserve"><value>Page</value></data>
|
||||||
<data name="SenderEditor_PlaceSignatureField" xml:space="preserve"><value>Place signature field</value></data>
|
<data name="SenderEditor_PlaceSignatureField" xml:space="preserve"><value>Place signature field</value></data>
|
||||||
<data name="SenderEditor_RemoveAllSignatureFields" xml:space="preserve"><value>Remove all signature fields</value></data>
|
<data name="SenderEditor_RemoveAllSignatureFields" xml:space="preserve"><value>Remove all signature fields</value></data>
|
||||||
<data name="SenderEditor_SelectPdf" xml:space="preserve"><value>Please select a PDF file.</value></data>
|
<data name="SenderEditor_SelectPdf" xml:space="preserve"><value>Please select a PDF file.</value></data>
|
||||||
|
|||||||
@@ -123,6 +123,9 @@
|
|||||||
<data name="Back" xml:space="preserve">
|
<data name="Back" xml:space="preserve">
|
||||||
<value>Retour</value>
|
<value>Retour</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="Next" xml:space="preserve">
|
||||||
|
<value>Suivant</value>
|
||||||
|
</data>
|
||||||
<data name="AuthenticatorSetup_Prefix" xml:space="preserve">
|
<data name="AuthenticatorSetup_Prefix" xml:space="preserve">
|
||||||
<value>Cliquez sur</value>
|
<value>Cliquez sur</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -564,6 +567,12 @@
|
|||||||
<data name="AddReceiver" xml:space="preserve">
|
<data name="AddReceiver" xml:space="preserve">
|
||||||
<value>Ajouter un destinataire</value>
|
<value>Ajouter un destinataire</value>
|
||||||
</data>
|
</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">
|
<data name="DeleteReceiver" xml:space="preserve">
|
||||||
<value>Supprimer le destinataire</value>
|
<value>Supprimer le destinataire</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -735,7 +744,9 @@
|
|||||||
<data name="SenderEditor_LoadFileError" xml:space="preserve"><value>Erreur lors du chargement du fichier : {0}</value></data>
|
<data name="SenderEditor_LoadFileError" xml:space="preserve"><value>Erreur lors du chargement du fichier : {0}</value></data>
|
||||||
<data name="SenderEditor_Message_Placeholder" xml:space="preserve"><value>Veuillez signer le document ci-joint.</value></data>
|
<data name="SenderEditor_Message_Placeholder" xml:space="preserve"><value>Veuillez signer le document ci-joint.</value></data>
|
||||||
<data name="SenderEditor_NoDocumentLoaded" xml:space="preserve"><value>Aucun document chargé</value></data>
|
<data name="SenderEditor_NoDocumentLoaded" xml:space="preserve"><value>Aucun document chargé</value></data>
|
||||||
|
<data name="SenderEditor_NoPagesToDisplay" xml:space="preserve"><value>Aucune page à afficher.</value></data>
|
||||||
<data name="SenderEditor_NoReceiversAdded" xml:space="preserve"><value>Aucun destinataire ajouté pour le moment.</value></data>
|
<data name="SenderEditor_NoReceiversAdded" xml:space="preserve"><value>Aucun destinataire ajouté pour le moment.</value></data>
|
||||||
|
<data name="SenderEditor_PageLabel" xml:space="preserve"><value>Page</value></data>
|
||||||
<data name="SenderEditor_PlaceSignatureField" xml:space="preserve"><value>Placer un champ de signature</value></data>
|
<data name="SenderEditor_PlaceSignatureField" xml:space="preserve"><value>Placer un champ de signature</value></data>
|
||||||
<data name="SenderEditor_RemoveAllSignatureFields" xml:space="preserve"><value>Supprimer tous les champs de signature</value></data>
|
<data name="SenderEditor_RemoveAllSignatureFields" xml:space="preserve"><value>Supprimer tous les champs de signature</value></data>
|
||||||
<data name="SenderEditor_SelectPdf" xml:space="preserve"><value>Veuillez sélectionner un fichier PDF.</value></data>
|
<data name="SenderEditor_SelectPdf" xml:space="preserve"><value>Veuillez sélectionner un fichier PDF.</value></data>
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
{
|
{
|
||||||
public enum CertificationType
|
public enum CertificationType
|
||||||
{
|
{
|
||||||
AdvancedElectronicSignature = 1
|
AdvancedElectronicSignature = 1,
|
||||||
// ElectronicSignature = 1
|
ElectronicSignature = 2
|
||||||
// QualifiedSignature = 2
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ namespace EnvelopeGenerator.Domain.Constants
|
|||||||
{
|
{
|
||||||
public static bool IsActive(this EnvelopeStatus status)
|
public static bool IsActive(this EnvelopeStatus status)
|
||||||
{
|
{
|
||||||
return status >= EnvelopeStatus.EnvelopeCreated && status < EnvelopeStatus.EnvelopePartlySigned;
|
return status >= EnvelopeStatus.EnvelopeCreated && status <= EnvelopeStatus.EnvelopePartlySigned;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static bool IsCompleted(this EnvelopeStatus status)
|
public static bool IsCompleted(this EnvelopeStatus status)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ using System.Collections.Generic;
|
|||||||
namespace EnvelopeGenerator.Domain.Entities
|
namespace EnvelopeGenerator.Domain.Entities
|
||||||
{
|
{
|
||||||
[Table("TBSIG_DOCUMENT_RECEIVER_ELEMENT", Schema = "dbo")]
|
[Table("TBSIG_DOCUMENT_RECEIVER_ELEMENT", Schema = "dbo")]
|
||||||
public class DocReceiverElement : IDocReceiverElement, IHasReceiver, IHasAddedWhen, IUpdateAuditable
|
public class DocReceiverElement : IDocReceiverElement, ISignatureInkPayload, IHasReceiver, IHasAddedWhen, IUpdateAuditable
|
||||||
{
|
{
|
||||||
public DocReceiverElement()
|
public DocReceiverElement()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ namespace EnvelopeGenerator.Domain.Entities
|
|||||||
SendReminderEmails = false;
|
SendReminderEmails = false;
|
||||||
FirstReminderDays = 0;
|
FirstReminderDays = 0;
|
||||||
ReminderIntervalDays = 0;
|
ReminderIntervalDays = 0;
|
||||||
CertificationType = (int)Constants.CertificationType.AdvancedElectronicSignature;
|
CertificationType = Constants.CertificationType.AdvancedElectronicSignature;
|
||||||
UseAccessCode = false;
|
UseAccessCode = false;
|
||||||
Documents = Enumerable.Empty<Document>().ToList();
|
Documents = Enumerable.Empty<Document>().ToList();
|
||||||
Histories = Enumerable.Empty<History>().ToList();
|
Histories = Enumerable.Empty<History>().ToList();
|
||||||
@@ -83,7 +83,7 @@ namespace EnvelopeGenerator.Domain.Entities
|
|||||||
public string Comment { get; set; }
|
public string Comment { get; set; }
|
||||||
|
|
||||||
[Column("CONTRACT_TYPE")]
|
[Column("CONTRACT_TYPE")]
|
||||||
public int? ContractType { get; set; }
|
public ContractType? ContractType { get; set; }
|
||||||
|
|
||||||
#if NETFRAMEWORK
|
#if NETFRAMEWORK
|
||||||
[NotMapped]
|
[NotMapped]
|
||||||
@@ -111,7 +111,7 @@ namespace EnvelopeGenerator.Domain.Entities
|
|||||||
public bool ReadOnly => this.IsReadAndConfirm();
|
public bool ReadOnly => this.IsReadAndConfirm();
|
||||||
|
|
||||||
[Column("CERTIFICATION_TYPE")]
|
[Column("CERTIFICATION_TYPE")]
|
||||||
public int? CertificationType { get; set; }
|
public CertificationType? CertificationType { get; set; }
|
||||||
|
|
||||||
[Column("USE_ACCESS_CODE")]
|
[Column("USE_ACCESS_CODE")]
|
||||||
public bool? UseAccessCode { get; set; }
|
public bool? UseAccessCode { get; set; }
|
||||||
@@ -129,7 +129,11 @@ namespace EnvelopeGenerator.Domain.Entities
|
|||||||
public int? ExpiresWarningWhenDays { get; set; }
|
public int? ExpiresWarningWhenDays { get; set; }
|
||||||
|
|
||||||
[ForeignKey("UserId")]
|
[ForeignKey("UserId")]
|
||||||
public User User { get; set; }
|
public User
|
||||||
|
#if nullable
|
||||||
|
?
|
||||||
|
#endif
|
||||||
|
User { get; set; }
|
||||||
|
|
||||||
[Column("TFA_ENABLED")]
|
[Column("TFA_ENABLED")]
|
||||||
public bool? TfaEnabled { get; set; }
|
public bool? TfaEnabled { get; set; }
|
||||||
@@ -137,7 +141,6 @@ namespace EnvelopeGenerator.Domain.Entities
|
|||||||
= false;
|
= false;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
[NotMapped]
|
|
||||||
[Column("DOC_RESULT")]
|
[Column("DOC_RESULT")]
|
||||||
public byte[]
|
public byte[]
|
||||||
#if nullable
|
#if nullable
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using EnvelopeGenerator.Domain.Constants;
|
||||||
|
|
||||||
#if NETFRAMEWORK
|
#if NETFRAMEWORK
|
||||||
using System;
|
using System;
|
||||||
#endif
|
#endif
|
||||||
@@ -22,41 +24,41 @@ namespace EnvelopeGenerator.Domain.Entities
|
|||||||
public string Language { get; set; }
|
public string Language { get; set; }
|
||||||
|
|
||||||
[Column("EXPIRES_DAYS")]
|
[Column("EXPIRES_DAYS")]
|
||||||
public int ExpiresDays { get; set; }
|
public int? ExpiresDays { get; set; }
|
||||||
|
|
||||||
[Column("CERTIFICATION_TYPE")]
|
[Column("CERTIFICATION_TYPE")]
|
||||||
public Constants.CertificationType CertificationType { get; set; } = Constants.CertificationType.AdvancedElectronicSignature;
|
public Constants.CertificationType? CertificationType { get; set; }
|
||||||
|
|
||||||
[Column("USE_ACCESS_CODE")]
|
[Column("USE_ACCESS_CODE")]
|
||||||
public bool UseAccessCode { get; set; }
|
public bool? UseAccessCode { get; set; }
|
||||||
|
|
||||||
[Column("FINAL_EMAIL_TO_CREATOR")]
|
[Column("FINAL_EMAIL_TO_CREATOR")]
|
||||||
public Constants.FinalEmailType FinalEmailToCreator { get; set; }
|
public Constants.FinalEmailType? FinalEmailToCreator { get; set; }
|
||||||
|
|
||||||
[Column("FINAL_EMAIL_TO_RECEIVERS")]
|
[Column("FINAL_EMAIL_TO_RECEIVERS")]
|
||||||
public Constants.FinalEmailType FinalEmailToReceivers { get; set; }
|
public Constants.FinalEmailType? FinalEmailToReceivers { get; set; }
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
[Column("ADDED_WHEN", TypeName = "datetime")]
|
[Column("ADDED_WHEN", TypeName = "datetime")]
|
||||||
public DateTime AddedWhen { get; set; }
|
public DateTime AddedWhen { get; set; }
|
||||||
|
|
||||||
[Column("CHANGED_WHEN", TypeName = "datetime")]
|
[Column("CHANGED_WHEN", TypeName = "datetime")]
|
||||||
public DateTime ChangedWhen { get; set; }
|
public DateTime? ChangedWhen { get; set; }
|
||||||
|
|
||||||
[Column("EXPIRES_WARNING_DAYS")]
|
[Column("EXPIRES_WARNING_DAYS")]
|
||||||
public int ExpiresWarningDays { get; set; }
|
public int? ExpiresWarningDays { get; set; }
|
||||||
|
|
||||||
[Column("SEND_REMINDER_EMAILS")]
|
[Column("SEND_REMINDER_EMAILS")]
|
||||||
public bool SendReminderEmails { get; set; } = false;
|
public bool? SendReminderEmails { get; set; }
|
||||||
|
|
||||||
[Column("FIRST_REMINDER_DAYS")]
|
[Column("FIRST_REMINDER_DAYS")]
|
||||||
public int FirstReminderDays { get; set; } = 0;
|
public int? FirstReminderDays { get; set; }
|
||||||
|
|
||||||
[Column("REMINDER_INTERVAL_DAYS")]
|
[Column("REMINDER_INTERVAL_DAYS")]
|
||||||
public int ReminderIntervalDays { get; set; } = 0;
|
public int? ReminderIntervalDays { get; set; }
|
||||||
|
|
||||||
[Column("CONTRACT_TYPE")]
|
[Column("CONTRACT_TYPE")]
|
||||||
public int ContractType { get; set; }
|
public ContractType? ContractType { get; set; }
|
||||||
|
|
||||||
[NotMapped]
|
[NotMapped]
|
||||||
public bool TFA_Enabled { get; set; } = false;
|
public bool TFA_Enabled { get; set; } = false;
|
||||||
@@ -70,14 +72,14 @@ namespace EnvelopeGenerator.Domain.Entities
|
|||||||
[NotMapped]
|
[NotMapped]
|
||||||
public int ExpiresWhenDays
|
public int ExpiresWhenDays
|
||||||
{
|
{
|
||||||
get => ExpiresDays;
|
get => ExpiresDays ?? 0;
|
||||||
set => ExpiresDays = value;
|
set => ExpiresDays = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
[NotMapped]
|
[NotMapped]
|
||||||
public int ExpiresWarningWhenDays
|
public int ExpiresWarningWhenDays
|
||||||
{
|
{
|
||||||
get => ExpiresWarningDays;
|
get => ExpiresWarningDays ?? 0;
|
||||||
set => ExpiresWarningDays = value;
|
set => ExpiresWarningDays = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,4 +88,4 @@ namespace EnvelopeGenerator.Domain.Entities
|
|||||||
return Title;
|
return Title;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFrameworks>net462;net7.0;net8.0;net9.0</TargetFrameworks>
|
<TargetFrameworks>net462;net7.0;net8.0;net9.0</TargetFrameworks>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
<AssemblyVersion>1.4.0</AssemblyVersion>
|
<AssemblyVersion>1.4.0</AssemblyVersion>
|
||||||
<FileVersion>1.4.0</FileVersion>
|
<FileVersion>1.4.0</FileVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -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
|
public interface IEnvelope
|
||||||
{
|
{
|
||||||
int? EnvelopeTypeId { get; set; }
|
int? EnvelopeTypeId { get; set; }
|
||||||
|
|
||||||
|
byte[]
|
||||||
|
#if nullable
|
||||||
|
?
|
||||||
|
#endif
|
||||||
|
DocResult { get; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class EnvelopeExtensions
|
public static class EnvelopeExtensions
|
||||||
@@ -16,5 +22,10 @@
|
|||||||
{
|
{
|
||||||
return envelope.EnvelopeTypeId != 2;
|
return envelope.EnvelopeTypeId != 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static bool IsBurned(this IEnvelope envelope)
|
||||||
|
{
|
||||||
|
return envelope.DocResult != null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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">
|
<data name="YesWithAttachment" xml:space="preserve">
|
||||||
<value>Yes, with Attachment</value>
|
<value>Yes, with Attachment</value>
|
||||||
</data>
|
</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">
|
<data name="MessageInvitationSent" xml:space="preserve">
|
||||||
<value>Invitation envoyee</value>
|
<value>Invitation envoyee</value>
|
||||||
</data>
|
</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>
|
</root>
|
||||||
|
|||||||
@@ -279,4 +279,61 @@
|
|||||||
<data name="YesWithAttachment" xml:space="preserve">
|
<data name="YesWithAttachment" xml:space="preserve">
|
||||||
<value>Ja, mit Anhang</value>
|
<value>Ja, mit Anhang</value>
|
||||||
</data>
|
</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 If
|
||||||
End Sub
|
End Sub
|
||||||
Private Function TranslateCertificationType(pType As CertificationType) As String
|
Private Function TranslateCertificationType(pType As CertificationType) As String
|
||||||
Return Resources.Model.ResourceManager.GetString(pType.ToString())
|
Return CommonServices.My.Resources.Model.ResourceManager.GetString(pType.ToString())
|
||||||
End Function
|
End Function
|
||||||
Private Function TranslateFinalEmailType(pType As FinalEmailType) As String
|
Private Function TranslateFinalEmailType(pType As FinalEmailType) As String
|
||||||
Return Resources.Model.ResourceManager.GetString(pType.ToString())
|
Return CommonServices.My.Resources.Model.ResourceManager.GetString(pType.ToString())
|
||||||
End Function
|
End Function
|
||||||
|
|
||||||
Private Sub btOK_Click(sender As Object, e As EventArgs) Handles btOK.Click
|
Private Sub btOK_Click(sender As Object, e As EventArgs) Handles btOK.Click
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
18
EnvelopeGenerator.Infrastructure.Doc/DependencyInjection.cs
Normal file
18
EnvelopeGenerator.Infrastructure.Doc/DependencyInjection.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
using EnvelopeGenerator.Application.Common.Interfaces.Doc;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Infrastructure.Doc;
|
||||||
|
|
||||||
|
public static class DependencyInjection
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddDocInfrastructure(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddScoped<IPdfPageImageRenderer, PdfPageImageRenderer>();
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\EnvelopeGenerator.Application\EnvelopeGenerator.Application.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="DevExpress.Document.Processor" Version="25.2.3" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
180
EnvelopeGenerator.Infrastructure.Doc/PdfPageImageRenderer.cs
Normal file
180
EnvelopeGenerator.Infrastructure.Doc/PdfPageImageRenderer.cs
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
public class PdfPageImageRenderer : IPdfPageImageRenderer
|
||||||
|
{
|
||||||
|
private const float RenderDpi = 110f;
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<PdfPageImageDto>> RenderPagesAsImagesAsync(byte[] pdfBytes, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(pdfBytes);
|
||||||
|
|
||||||
|
if (pdfBytes.Length == 0)
|
||||||
|
return Task.FromResult<IReadOnlyList<PdfPageImageDto>>(Array.Empty<PdfPageImageDto>());
|
||||||
|
|
||||||
|
using var stream = new MemoryStream(pdfBytes, writable: false);
|
||||||
|
using var processor = new PdfDocumentProcessor();
|
||||||
|
processor.LoadDocument(stream);
|
||||||
|
|
||||||
|
var pages = processor.Document.Pages;
|
||||||
|
if (pages.Count == 0)
|
||||||
|
return Task.FromResult<IReadOnlyList<PdfPageImageDto>>(Array.Empty<PdfPageImageDto>());
|
||||||
|
|
||||||
|
var renderOptions = PdfPageRenderingParameters.CreateWithResolution(RenderDpi);
|
||||||
|
var results = new List<PdfPageImageDto>(pages.Count);
|
||||||
|
|
||||||
|
for (var i = 0; i < pages.Count; i++)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var pageNumber = i + 1;
|
||||||
|
var page = pages[i];
|
||||||
|
var cropBox = page.CropBox;
|
||||||
|
|
||||||
|
using DXBitmap bitmap = processor.CreateDXBitmap(pageNumber, renderOptions);
|
||||||
|
using var imageStream = new MemoryStream();
|
||||||
|
bitmap.Save(imageStream, DXImageFormat.Png);
|
||||||
|
|
||||||
|
var dataUrl = $"data:image/png;base64,{Convert.ToBase64String(imageStream.ToArray())}";
|
||||||
|
|
||||||
|
results.Add(new PdfPageImageDto
|
||||||
|
{
|
||||||
|
PageNumber = pageNumber,
|
||||||
|
WidthPt = cropBox.Width,
|
||||||
|
HeightPt = cropBox.Height,
|
||||||
|
WidthPx = bitmap.Width,
|
||||||
|
HeightPx = bitmap.Height,
|
||||||
|
ImageDataUrl = dataUrl
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -204,16 +204,24 @@ public abstract class EGDbContextBase : DbContext
|
|||||||
|
|
||||||
#region Trigger
|
#region Trigger
|
||||||
// Configure entities to handle database triggers
|
// Configure entities to handle database triggers
|
||||||
void AddTrigger<T>() where T : class => _triggers
|
void AddTrigger<T>() where T : class
|
||||||
.Where(t => t.Key == typeof(T).Name)
|
{
|
||||||
.SelectMany(t => t.Value)
|
var entityName = typeof(T).Name;
|
||||||
.ForEach(tName =>
|
|
||||||
|
var configuredTriggerNames = _triggers
|
||||||
|
.Where(t => t.Key == entityName)
|
||||||
|
.SelectMany(t => t.Value)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var triggerName in configuredTriggerNames)
|
||||||
{
|
{
|
||||||
#if NET
|
#if NET
|
||||||
modelBuilder.Entity<T>().ToTable(tb => tb.HasTrigger(tName));
|
modelBuilder.Entity<T>().ToTable(tb => tb.HasTrigger(triggerName));
|
||||||
#endif
|
#endif
|
||||||
_logger?.LogInformation("Trigger '{triggerName}' has been added to the '{entityName}' entity.", tName, typeof(T).Name);
|
_logger?.LogInformation("Trigger '{triggerName}' has been added to the '{entityName}' entity.", triggerName, entityName);
|
||||||
});
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: call add trigger methods with attributes and reflection
|
// TODO: call add trigger methods with attributes and reflection
|
||||||
AddTrigger<Config>();
|
AddTrigger<Config>();
|
||||||
@@ -243,4 +251,4 @@ public abstract class EGDbContextBase : DbContext
|
|||||||
|
|
||||||
#if NETFRAMEWORK
|
#if NETFRAMEWORK
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace EnvelopeGenerator.Server.Client.Components.PdfEditor;
|
||||||
|
|
||||||
|
public sealed record SenderPdfFieldMoveRequest(
|
||||||
|
int Index,
|
||||||
|
int Page,
|
||||||
|
double XPt,
|
||||||
|
double YPt);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace EnvelopeGenerator.Server.Client.Components.PdfEditor;
|
||||||
|
|
||||||
|
public sealed record SenderPdfFieldOverlay(
|
||||||
|
int Index,
|
||||||
|
int Page,
|
||||||
|
double XPt,
|
||||||
|
double YPt,
|
||||||
|
string ReceiverName,
|
||||||
|
string Color);
|
||||||
@@ -0,0 +1,458 @@
|
|||||||
|
@using EnvelopeGenerator.Application.Resources
|
||||||
|
@using EnvelopeGenerator.Application.Common.Dto
|
||||||
|
@using System.Globalization
|
||||||
|
@using Microsoft.AspNetCore.Components.Web
|
||||||
|
@using Microsoft.Extensions.Localization
|
||||||
|
@inject IStringLocalizer<Resource> UiLocalizer
|
||||||
|
@inject IJSRuntime JSRuntime
|
||||||
|
|
||||||
|
<div class="sender-image-editor-shell">
|
||||||
|
@if (PageCount > 0)
|
||||||
|
{
|
||||||
|
<div class="sender-image-editor__toolbar-host">
|
||||||
|
<SenderPdfImageEditorToolbar CurrentPage="@_currentPage"
|
||||||
|
TotalPages="@PageCount"
|
||||||
|
ZoomPercent="@_zoomPercent"
|
||||||
|
MinZoomPercent="@MinZoomPercent"
|
||||||
|
MaxZoomPercent="@MaxZoomPercent"
|
||||||
|
ZoomStepPercent="@ZoomStepPercent"
|
||||||
|
GoFirstPage="OnGoFirstPageAsync"
|
||||||
|
GoPreviousPage="OnGoPreviousPageAsync"
|
||||||
|
GoNextPage="OnGoNextPageAsync"
|
||||||
|
GoLastPage="OnGoLastPageAsync"
|
||||||
|
GoToPage="OnGoToPageAsync"
|
||||||
|
ZoomOut="OnZoomOutAsync"
|
||||||
|
ZoomIn="OnZoomInAsync"
|
||||||
|
SetZoomPercent="OnSetZoomPercentAsync"
|
||||||
|
CanDownloadPdf="@CanDownloadPdf"
|
||||||
|
CanPrintPdf="@CanPrintPdf"
|
||||||
|
IsDownloading="@_isDownloading"
|
||||||
|
IsPrinting="@_isPrinting"
|
||||||
|
DownloadPdf="OnDownloadPdfAsync"
|
||||||
|
PrintPdf="OnPrintPdfAsync" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="pdf-editor-wrapper sender-image-editor sender-editor-pdf-viewer"
|
||||||
|
style="cursor: @(IsPlacementMode ? "crosshair" : (_isDragging ? "grabbing" : "default"));"
|
||||||
|
tabindex="0"
|
||||||
|
@onclick="HandleClickAsync"
|
||||||
|
@onwheel="OnEditorWheelAsync"
|
||||||
|
@onwheel:preventDefault="true"
|
||||||
|
@onscroll="OnEditorScrollAsync"
|
||||||
|
@onmousemove="OnEditorMouseMoveAsync"
|
||||||
|
@onmouseup="OnEditorMouseUp"
|
||||||
|
@onmouseleave="OnEditorMouseUp">
|
||||||
|
@if (PageCount <= 0)
|
||||||
|
{
|
||||||
|
<div class="sender-image-editor__empty">@L("SenderEditor_NoPagesToDisplay", "Keine Seiten zur Anzeige verfügbar.")</div>
|
||||||
|
}
|
||||||
|
else if (Pages.Count == 0 && !string.IsNullOrWhiteSpace(PdfDataUrl))
|
||||||
|
{
|
||||||
|
<div class="sender-image-editor__native-preview" style="@GetZoomStyle()">
|
||||||
|
<iframe class="sender-image-editor__native-frame"
|
||||||
|
src="@PdfDataUrl"
|
||||||
|
title="PDF Preview"></iframe>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="sender-image-editor__pages" style="@GetZoomStyle()">
|
||||||
|
@for (var pageIndex = 0; pageIndex < PageCount; pageIndex++)
|
||||||
|
{
|
||||||
|
var pagePreview = pageIndex < Pages.Count ? Pages[pageIndex] : null;
|
||||||
|
var pageNumber = pagePreview?.PageNumber ?? (pageIndex + 1);
|
||||||
|
var widthPt = pagePreview?.WidthPt ?? 595.0;
|
||||||
|
var heightPt = pagePreview?.HeightPt ?? 842.0;
|
||||||
|
|
||||||
|
<div class="sender-image-editor__page-block"
|
||||||
|
data-editor-page="@pageNumber"
|
||||||
|
data-page-width-pt="@widthPt"
|
||||||
|
data-page-height-pt="@heightPt">
|
||||||
|
<div class="sender-image-editor__page-canvas"
|
||||||
|
data-editor-page-canvas="@pageNumber"
|
||||||
|
style="@GetPageCanvasStyle(widthPt, heightPt)">
|
||||||
|
@if (!string.IsNullOrWhiteSpace(pagePreview?.ImageDataUrl))
|
||||||
|
{
|
||||||
|
<img class="sender-image-editor__page-image"
|
||||||
|
src="@(pagePreview!.ImageDataUrl)"
|
||||||
|
alt="PDF Page @pageNumber"
|
||||||
|
draggable="false" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="sender-image-editor__page-placeholder">@L("SenderEditor_PageLabel", "Seite") @pageNumber</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@foreach (var field in Fields.Where(f => f.Page == pageNumber))
|
||||||
|
{
|
||||||
|
<div class="sender-image-editor__field"
|
||||||
|
style="@GetFieldStyle(field, widthPt, heightPt)"
|
||||||
|
title="@field.ReceiverName"
|
||||||
|
@onmousedown="args => OnFieldMouseDownAsync(args, field)"
|
||||||
|
@onmousedown:preventDefault="true"
|
||||||
|
@onclick:stopPropagation="true">
|
||||||
|
<span class="sender-image-editor__field-label">@field.ReceiverName</span>
|
||||||
|
<button type="button"
|
||||||
|
class="sender-image-editor__field-remove"
|
||||||
|
title='@L("Delete", "Löschen")'
|
||||||
|
draggable="false"
|
||||||
|
@onclick:stopPropagation="true"
|
||||||
|
@onclick="() => RemoveField.InvokeAsync(field.Index)">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
|
<path d="M4 4L12 12M12 4L4 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter]
|
||||||
|
public bool IsPlacementMode { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public int PageCount { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public IReadOnlyList<PdfPageImageDto> Pages { get; set; } = Array.Empty<PdfPageImageDto>();
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public string? PdfDataUrl { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public string? PdfFileName { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public IReadOnlyList<SenderPdfFieldOverlay> Fields { get; set; } = Array.Empty<SenderPdfFieldOverlay>();
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<MouseEventArgs> EditorClick { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<int> RemoveField { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<SenderPdfFieldMoveRequest> MoveField { get; set; }
|
||||||
|
|
||||||
|
private int? _draggedFieldIndex;
|
||||||
|
private bool _isDragging;
|
||||||
|
private double _grabOffsetXPt;
|
||||||
|
private double _grabOffsetYPt;
|
||||||
|
private int _currentPage = 1;
|
||||||
|
private int _zoomPercent = 112;
|
||||||
|
private bool _zoomOverrideInitialized;
|
||||||
|
private bool _isDownloading;
|
||||||
|
private bool _isPrinting;
|
||||||
|
|
||||||
|
private const int MinZoomPercent = 50;
|
||||||
|
private const int MaxZoomPercent = 300;
|
||||||
|
private const int ZoomStepPercent = 5;
|
||||||
|
private bool CanDownloadPdf => !string.IsNullOrWhiteSpace(PdfDataUrl);
|
||||||
|
private bool CanPrintPdf => !string.IsNullOrWhiteSpace(PdfDataUrl);
|
||||||
|
|
||||||
|
private static string GetFieldStyle(SenderPdfFieldOverlay field, double pageWidthPt, double pageHeightPt)
|
||||||
|
{
|
||||||
|
var leftPercent = (field.XPt / pageWidthPt) * 100.0;
|
||||||
|
var topPercent = (field.YPt / pageHeightPt) * 100.0;
|
||||||
|
var widthPercent = (127.44 / pageWidthPt) * 100.0;
|
||||||
|
var heightPercent = (141.12 / pageHeightPt) * 100.0;
|
||||||
|
|
||||||
|
var left = leftPercent.ToString("F4", CultureInfo.InvariantCulture);
|
||||||
|
var top = topPercent.ToString("F4", CultureInfo.InvariantCulture);
|
||||||
|
var width = widthPercent.ToString("F4", CultureInfo.InvariantCulture);
|
||||||
|
var height = heightPercent.ToString("F4", CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
return string.Join(' ',
|
||||||
|
"position:absolute;",
|
||||||
|
$"left:{left}%;",
|
||||||
|
$"top:{top}%;",
|
||||||
|
$"width:{width}%;",
|
||||||
|
$"height:{height}%;",
|
||||||
|
"border:2px solid;",
|
||||||
|
"border-radius:8px;",
|
||||||
|
"display:flex;",
|
||||||
|
"align-items:flex-start;",
|
||||||
|
"justify-content:space-between;",
|
||||||
|
"gap:0.35rem;",
|
||||||
|
"padding:0.2rem 0.3rem;",
|
||||||
|
"overflow:hidden;",
|
||||||
|
"z-index:30;",
|
||||||
|
$"border-color:{field.Color};",
|
||||||
|
$"background-color:{field.Color}22;");
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetPageCanvasStyle(double pageWidthPt, double pageHeightPt)
|
||||||
|
{
|
||||||
|
var ratio = pageHeightPt <= 0 ? 595.0 / 842.0 : pageWidthPt / pageHeightPt;
|
||||||
|
var ratioText = ratio.ToString("F6", CultureInfo.InvariantCulture);
|
||||||
|
var cursor = _isDragging
|
||||||
|
? "grabbing"
|
||||||
|
: (IsPlacementMode ? "crosshair" : "default");
|
||||||
|
return $"aspect-ratio:{ratioText};cursor:{cursor};";
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetZoomStyle()
|
||||||
|
{
|
||||||
|
var factor = (_zoomPercent / 100.0).ToString("F4", CultureInfo.InvariantCulture);
|
||||||
|
return $"zoom:{factor};";
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
if (PageCount <= 0)
|
||||||
|
{
|
||||||
|
_currentPage = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_currentPage = Math.Max(1, Math.Min(_currentPage, PageCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (_zoomOverrideInitialized)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await JSRuntime.InvokeVoidAsync(
|
||||||
|
"envelopeEditor.ensureZoomOverride",
|
||||||
|
"sender-editor-pdf-viewer");
|
||||||
|
|
||||||
|
_zoomOverrideInitialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleClickAsync(MouseEventArgs args)
|
||||||
|
{
|
||||||
|
if (EditorClick.HasDelegate)
|
||||||
|
await EditorClick.InvokeAsync(args);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnFieldMouseDownAsync(MouseEventArgs args, SenderPdfFieldOverlay field)
|
||||||
|
{
|
||||||
|
var coords = await JSRuntime.InvokeAsync<EditorCoords?>(
|
||||||
|
"envelopeEditor.getClickCoordsOnPdfPage",
|
||||||
|
"sender-editor-pdf-viewer",
|
||||||
|
args.ClientX,
|
||||||
|
args.ClientY);
|
||||||
|
|
||||||
|
if (coords is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var pageNumber = coords.PageNumber;
|
||||||
|
var (pageWidthPt, pageHeightPt) = GetPageSize(pageNumber);
|
||||||
|
var pointerXPt = coords.NormX * pageWidthPt;
|
||||||
|
var pointerYPt = coords.NormY * pageHeightPt;
|
||||||
|
|
||||||
|
_draggedFieldIndex = field.Index;
|
||||||
|
_isDragging = true;
|
||||||
|
_grabOffsetXPt = pointerXPt - field.XPt;
|
||||||
|
_grabOffsetYPt = pointerYPt - field.YPt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnEditorMouseMoveAsync(MouseEventArgs args)
|
||||||
|
{
|
||||||
|
if (_draggedFieldIndex is null || !MoveField.HasDelegate)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var coords = await JSRuntime.InvokeAsync<EditorCoords?>(
|
||||||
|
"envelopeEditor.getClickCoordsOnPdfPage",
|
||||||
|
"sender-editor-pdf-viewer",
|
||||||
|
args.ClientX,
|
||||||
|
args.ClientY);
|
||||||
|
|
||||||
|
if (coords is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var pageNumber = coords.PageNumber;
|
||||||
|
var (pageWidthPt, pageHeightPt) = GetPageSize(pageNumber);
|
||||||
|
|
||||||
|
const double sigWidthPt = 1.77 * 72;
|
||||||
|
const double sigHeightPt = 1.96 * 72;
|
||||||
|
|
||||||
|
var xPt = (coords.NormX * pageWidthPt) - _grabOffsetXPt;
|
||||||
|
var yPt = (coords.NormY * pageHeightPt) - _grabOffsetYPt;
|
||||||
|
xPt = Math.Max(0, Math.Min(xPt, pageWidthPt - sigWidthPt));
|
||||||
|
yPt = Math.Max(0, Math.Min(yPt, pageHeightPt - sigHeightPt));
|
||||||
|
|
||||||
|
await MoveField.InvokeAsync(new SenderPdfFieldMoveRequest(_draggedFieldIndex.Value, pageNumber, xPt, yPt));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnGoFirstPageAsync()
|
||||||
|
{
|
||||||
|
await NavigateToPageAsync(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnGoPreviousPageAsync()
|
||||||
|
{
|
||||||
|
await NavigateToPageAsync(_currentPage - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnGoNextPageAsync()
|
||||||
|
{
|
||||||
|
await NavigateToPageAsync(_currentPage + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnGoLastPageAsync()
|
||||||
|
{
|
||||||
|
await NavigateToPageAsync(PageCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnGoToPageAsync(int page)
|
||||||
|
{
|
||||||
|
await NavigateToPageAsync(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task NavigateToPageAsync(int targetPage)
|
||||||
|
{
|
||||||
|
if (PageCount <= 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var page = Math.Max(1, Math.Min(targetPage, PageCount));
|
||||||
|
_currentPage = page;
|
||||||
|
|
||||||
|
await JSRuntime.InvokeVoidAsync(
|
||||||
|
"envelopeEditor.scrollToPdfPage",
|
||||||
|
"sender-editor-pdf-viewer",
|
||||||
|
page);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnEditorScrollAsync(EventArgs _)
|
||||||
|
{
|
||||||
|
if (PageCount <= 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var visiblePage = await JSRuntime.InvokeAsync<int?>(
|
||||||
|
"envelopeEditor.getVisiblePdfPageNumber",
|
||||||
|
"sender-editor-pdf-viewer");
|
||||||
|
|
||||||
|
if (!visiblePage.HasValue)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var clamped = Math.Max(1, Math.Min(visiblePage.Value, PageCount));
|
||||||
|
if (clamped == _currentPage)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_currentPage = clamped;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnZoomOutAsync()
|
||||||
|
{
|
||||||
|
SetZoomPercent(_zoomPercent - ZoomStepPercent);
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnZoomInAsync()
|
||||||
|
{
|
||||||
|
SetZoomPercent(_zoomPercent + ZoomStepPercent);
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnSetZoomPercentAsync(int zoomPercent)
|
||||||
|
{
|
||||||
|
SetZoomPercent(zoomPercent);
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnEditorWheelAsync(WheelEventArgs args)
|
||||||
|
{
|
||||||
|
if (args.CtrlKey)
|
||||||
|
{
|
||||||
|
if (args.DeltaY < 0)
|
||||||
|
await OnZoomInAsync();
|
||||||
|
else if (args.DeltaY > 0)
|
||||||
|
await OnZoomOutAsync();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await JSRuntime.InvokeVoidAsync(
|
||||||
|
"envelopeEditor.scrollPdfViewerByDelta",
|
||||||
|
"sender-editor-pdf-viewer",
|
||||||
|
args.DeltaY);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetZoomPercent(int value)
|
||||||
|
{
|
||||||
|
_zoomPercent = Math.Max(MinZoomPercent, Math.Min(value, MaxZoomPercent));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnDownloadPdfAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(PdfDataUrl) || _isDownloading || _isPrinting)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_isDownloading = true;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fallbackName = string.IsNullOrWhiteSpace(PdfFileName)
|
||||||
|
? "document.pdf"
|
||||||
|
: PdfFileName;
|
||||||
|
|
||||||
|
var fileName = fallbackName.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? fallbackName
|
||||||
|
: $"{fallbackName}.pdf";
|
||||||
|
|
||||||
|
await JSRuntime.InvokeVoidAsync(
|
||||||
|
"envelopeEditor.downloadDataUrlAsFile",
|
||||||
|
PdfDataUrl,
|
||||||
|
fileName,
|
||||||
|
"application/pdf");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isDownloading = false;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnPrintPdfAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(PdfDataUrl) || _isPrinting || _isDownloading)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_isPrinting = true;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await JSRuntime.InvokeVoidAsync(
|
||||||
|
"envelopeEditor.printDataUrlPdf",
|
||||||
|
PdfDataUrl,
|
||||||
|
"application/pdf");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isPrinting = false;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnEditorMouseUp(MouseEventArgs args)
|
||||||
|
{
|
||||||
|
_draggedFieldIndex = null;
|
||||||
|
_isDragging = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private (double WidthPt, double HeightPt) GetPageSize(int pageNumber)
|
||||||
|
{
|
||||||
|
var page = Pages.FirstOrDefault(p => p.PageNumber == pageNumber);
|
||||||
|
return page is null ? (595.0, 842.0) : (page.WidthPt, page.HeightPt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record EditorCoords(double NormX, double NormY, int PageIndex, int PageNumber);
|
||||||
|
|
||||||
|
private string L(string key, string fallback)
|
||||||
|
{
|
||||||
|
var value = UiLocalizer[key];
|
||||||
|
return value.ResourceNotFound ? fallback : value.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
<div class="sender-image-toolbar" @onclick:stopPropagation="true">
|
||||||
|
<div class="sender-image-toolbar__group sender-image-toolbar__group--nav" role="group" aria-label="Page navigation">
|
||||||
|
<button type="button" class="sender-image-toolbar__icon-btn" title="First page" disabled="@(!CanGoPrevious)" @onclick="GoToFirstAsync">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M8 6V18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||||
|
<path d="M16 7L11 12L16 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" class="sender-image-toolbar__icon-btn" title="Previous page" disabled="@(!CanGoPrevious)" @onclick="GoToPreviousAsync">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M15 7L10 12L15 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="sender-image-toolbar__value-box">
|
||||||
|
<input type="number"
|
||||||
|
min="1"
|
||||||
|
max="@TotalPagesDisplay"
|
||||||
|
value="@CurrentPageDisplay"
|
||||||
|
class="sender-image-toolbar__page-input"
|
||||||
|
@onchange="OnPageInputChangedAsync"
|
||||||
|
disabled="@(TotalPagesDisplay <= 0)" />
|
||||||
|
<span class="sender-image-toolbar__page-total">of @TotalPagesDisplay</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sender-image-toolbar__select-wrap" title="Page list">
|
||||||
|
<button type="button" class="sender-image-toolbar__icon-btn sender-image-toolbar__icon-btn--caret" disabled="@(TotalPagesDisplay <= 0)">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M6 9L12 15L18 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<select class="sender-image-toolbar__page-select"
|
||||||
|
aria-label="Select page"
|
||||||
|
disabled="@(TotalPagesDisplay <= 0)"
|
||||||
|
value="@CurrentPageDisplay"
|
||||||
|
@onchange="OnPageSelectChangedAsync">
|
||||||
|
@for (var pageNumber = 1; pageNumber <= TotalPagesDisplay; pageNumber++)
|
||||||
|
{
|
||||||
|
<option value="@pageNumber">@pageNumber</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" class="sender-image-toolbar__icon-btn" title="Next page" disabled="@(!CanGoNext)" @onclick="GoToNextAsync">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M9 7L14 12L9 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" class="sender-image-toolbar__icon-btn" title="Last page" disabled="@(!CanGoNext)" @onclick="GoToLastAsync">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M16 6V18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||||
|
<path d="M8 7L13 12L8 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sender-image-toolbar__group sender-image-toolbar__group--zoom" role="group" aria-label="Zoom controls">
|
||||||
|
<button type="button" class="sender-image-toolbar__icon-btn" title="Zoom out" disabled="@(!CanZoomOut)" @onclick="ZoomOutAsync">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M6 12H18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="sender-image-toolbar__value-box" aria-hidden="true">
|
||||||
|
<span>@ZoomPercentDisplay%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="range"
|
||||||
|
class="sender-image-toolbar__zoom-slider"
|
||||||
|
min="@MinZoomPercent"
|
||||||
|
max="@MaxZoomPercent"
|
||||||
|
step="@ZoomStepPercent"
|
||||||
|
value="@ZoomPercentDisplay"
|
||||||
|
@oninput="OnZoomSliderInputAsync"
|
||||||
|
aria-label="Zoom slider" />
|
||||||
|
|
||||||
|
<button type="button" class="sender-image-toolbar__icon-btn" title="Zoom in" disabled="@(!CanZoomIn)" @onclick="ZoomInAsync">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M12 6V18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||||
|
<path d="M6 12H18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sender-image-toolbar__group sender-image-toolbar__group--actions" role="group" aria-label="Document actions">
|
||||||
|
<button type="button"
|
||||||
|
class="sender-image-toolbar__icon-btn @(IsPrinting ? "sender-image-toolbar__icon-btn--busy" : string.Empty)"
|
||||||
|
title="@(IsPrinting ? "Preparing print..." : "Print")"
|
||||||
|
disabled="@(!CanPrintPdf || IsPrinting || IsDownloading)"
|
||||||
|
@onclick="PrintPdfAsync">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M7 9V4H17V9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
<rect x="5" y="9" width="14" height="8" rx="2" stroke="currentColor" stroke-width="2" />
|
||||||
|
<path d="M7 14H17V20H7V14Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button"
|
||||||
|
class="sender-image-toolbar__icon-btn @(IsDownloading ? "sender-image-toolbar__icon-btn--busy" : string.Empty)"
|
||||||
|
title="@(IsDownloading ? "Preparing download..." : "Download PDF")"
|
||||||
|
disabled="@(!CanDownloadPdf || IsDownloading || IsPrinting)"
|
||||||
|
@onclick="DownloadPdfAsync">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M12 4V14" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||||
|
<path d="M8 10L12 14L16 10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
<path d="M5 18H19" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter]
|
||||||
|
public int CurrentPage { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public int TotalPages { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public int ZoomPercent { get; set; } = 112;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public int MinZoomPercent { get; set; } = 50;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public int MaxZoomPercent { get; set; } = 300;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public int ZoomStepPercent { get; set; } = 5;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback GoFirstPage { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback GoPreviousPage { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback GoNextPage { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback GoLastPage { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<int> GoToPage { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback ZoomOut { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback ZoomIn { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<int> SetZoomPercent { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public bool CanDownloadPdf { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public bool CanPrintPdf { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public bool IsDownloading { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public bool IsPrinting { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback DownloadPdf { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback PrintPdf { get; set; }
|
||||||
|
|
||||||
|
private int CurrentPageDisplay => CurrentPage > 0 ? CurrentPage : 0;
|
||||||
|
private int TotalPagesDisplay => TotalPages > 0 ? TotalPages : 0;
|
||||||
|
private int ZoomPercentDisplay => ZoomPercent > 0 ? ZoomPercent : 100;
|
||||||
|
private bool CanGoPrevious => CurrentPageDisplay > 1;
|
||||||
|
private bool CanGoNext => TotalPagesDisplay > 0 && CurrentPageDisplay < TotalPagesDisplay;
|
||||||
|
private bool CanZoomOut => ZoomPercentDisplay > MinZoomPercent;
|
||||||
|
private bool CanZoomIn => ZoomPercentDisplay < MaxZoomPercent;
|
||||||
|
|
||||||
|
private async Task GoToFirstAsync()
|
||||||
|
{
|
||||||
|
if (GoFirstPage.HasDelegate)
|
||||||
|
await GoFirstPage.InvokeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task GoToPreviousAsync()
|
||||||
|
{
|
||||||
|
if (GoPreviousPage.HasDelegate)
|
||||||
|
await GoPreviousPage.InvokeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task GoToNextAsync()
|
||||||
|
{
|
||||||
|
if (GoNextPage.HasDelegate)
|
||||||
|
await GoNextPage.InvokeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task GoToLastAsync()
|
||||||
|
{
|
||||||
|
if (GoLastPage.HasDelegate)
|
||||||
|
await GoLastPage.InvokeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnPageInputChangedAsync(ChangeEventArgs args)
|
||||||
|
{
|
||||||
|
if (!GoToPage.HasDelegate)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!int.TryParse(args.Value?.ToString(), out var page))
|
||||||
|
return;
|
||||||
|
|
||||||
|
page = Math.Max(1, Math.Min(page, Math.Max(1, TotalPagesDisplay)));
|
||||||
|
await GoToPage.InvokeAsync(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnPageSelectChangedAsync(ChangeEventArgs args)
|
||||||
|
{
|
||||||
|
if (!GoToPage.HasDelegate)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!int.TryParse(args.Value?.ToString(), out var page))
|
||||||
|
return;
|
||||||
|
|
||||||
|
page = Math.Max(1, Math.Min(page, Math.Max(1, TotalPagesDisplay)));
|
||||||
|
await GoToPage.InvokeAsync(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ZoomOutAsync()
|
||||||
|
{
|
||||||
|
if (ZoomOut.HasDelegate)
|
||||||
|
await ZoomOut.InvokeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ZoomInAsync()
|
||||||
|
{
|
||||||
|
if (ZoomIn.HasDelegate)
|
||||||
|
await ZoomIn.InvokeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnZoomSliderInputAsync(ChangeEventArgs args)
|
||||||
|
{
|
||||||
|
if (!SetZoomPercent.HasDelegate)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!int.TryParse(args.Value?.ToString(), out var zoomPercent))
|
||||||
|
return;
|
||||||
|
|
||||||
|
zoomPercent = Math.Max(MinZoomPercent, Math.Min(zoomPercent, MaxZoomPercent));
|
||||||
|
await SetZoomPercent.InvokeAsync(zoomPercent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DownloadPdfAsync()
|
||||||
|
{
|
||||||
|
if (DownloadPdf.HasDelegate)
|
||||||
|
await DownloadPdf.InvokeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PrintPdfAsync()
|
||||||
|
{
|
||||||
|
if (PrintPdf.HasDelegate)
|
||||||
|
await PrintPdf.InvokeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user