Compare commits

..

1 Commits

Author SHA1 Message Date
b76b59f37b Migrate from iText to GdPicture for PDF processing
Replaced iText with GdPicture for PDF manipulation, including:
- Added `Width` and `Height` properties to `IDocReceiverElement`.
- Updated `Pdf.cs` to use GdPicture's API for drawing and exporting.
- Introduced GdPicture licensing management with `EnsureInitialized`.
- Refactored `Background` method to align with GdPicture's coordinate system.
- Simplified resource management in `Dispose` and `DisposeAsync`.

Updated project files:
- Modified target frameworks in `EnvelopeGenerator.PdfEditor.csproj` to remove `net7.0` and `net9.0`, and added `net8.0-windows`.
- Replaced iText and BouncyCastle dependencies with GdPicture packages.
- Added `Microsoft.Bcl.AsyncInterfaces` to `EnvelopeGenerator.Web.csproj`.

Performed general cleanup and refactoring to remove unused iText code.
2026-08-24 11:09:19 +02:00
402 changed files with 30019 additions and 18896 deletions

1
.gitignore vendored
View File

@@ -370,4 +370,3 @@ 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

241
AGENTS.md
View File

@@ -4,130 +4,35 @@
- **`COPILOT_CONTEXT.md`** - Architecture, coordinate systems, migration status - **`COPILOT_CONTEXT.md`** - Architecture, coordinate systems, migration status
- **`FORM_APPLICATION_CONTEXT.md`** - Legacy VB.NET features to migrate - **`FORM_APPLICATION_CONTEXT.md`** - Legacy VB.NET features to migrate
---
## Documentation Catalog
This section explains every markdown file in the repo: what it contains, why it exists, and when you need it. Read this before deciding which file to open.
### Core Architecture Docs (Active — Always Relevant)
#### `AGENTS.md` ← you are here
**What:** Authoritative agent/copilot onboarding guide. Covers project structure, route table, render mode rules, coordinate system, API gaps, and common mistakes.
**Why it exists:** Single source of truth for agents so they don't re-derive architecture from code on every session.
**When you need it:** Always. Read first on every session.
**Maintenance:** Update whenever project structure, routes, render modes, or naming conventions change.
#### `COPILOT_CONTEXT.md`
**What:** Deep-dive architecture reference. Covers the full Blazor Auto hybrid setup, authentication flows (JWT + cookies, per-envelope receiver tokens), service registration patterns, coordinate system proof, and an explicit "prefer Server/Server.Client over old WebUI/ReceiverUI" rule.
**Why it exists:** Prevents agents from re-deriving or misinterpreting the hybrid SSR+WASM architecture. Also documents coordinate system evidence to prevent regressions (inches vs. points vs. pixels).
**When you need it:**
- Implementing any new page or service
- Debugging auth/cookie issues
- Any coordinate conversion question
- Unsure which project a new file should go in
**Maintenance:** Update when render mode assignments change, new auth schemes are added, or the coordinate system is touched.
#### `FORM_APPLICATION_CONTEXT.md`
**What:** Comprehensive reverse-engineering of the legacy VB.NET WinForms app (`EnvelopeGenerator.Form`). Documents every form (frmMain, frmEnvelopeEditor, frmFieldEditor, etc.), their UI layout, toolbar actions, data models, coordinate system, and a feature-by-feature migration mapping table to `EnvelopeGenerator.Server`.
**Why it exists:** The WinForms app is the functional specification for the sender-side web UI. It documents the full intended workflow that is being migrated to Blazor — especially the envelope creation, signature field placement, and dashboard features that are not yet implemented.
**When you need it:**
- Implementing any sender-side feature (`/sender`, `/sender/editor`, `/sender/envelope/{id}`)
- Understanding what a specific toolbar button or form should do
- Designing API endpoints for sender workflow
- Checking coordinate system behavior (INCHES proof is here)
**Maintenance:** Update the mapping table as features are implemented (change `❌ Not implemented` → `✅ Exists`). Do not rename `ReceiverUI` back — it was the old project name; current target is `EnvelopeGenerator.Server`.
#### `RECEIVER_PDF_VIEWER_CONTEXT.md`
**What:** Detailed technical reference for the receiver-side PDF viewer (`EnvelopeReceiverPage.razor`). Documents the PDF.js integration, signature button rendering, JS interop API (`window.pdfViewer`), signature capture flow, HiDPI/zoom/thumbnail config, and the full component lifecycle.
**Why it exists:** The receiver PDF viewer is the most complex component in the codebase. This doc prevents agents from misunderstanding the JS↔Blazor boundary, re-implementing already-solved problems (HiDPI, zoom, signature caching), or breaking the signing flow.
**When you need it:**
- Working on `EnvelopeReceiverPage.razor` or `pdf-viewer.js`
- Adding new signature field rendering logic
- Debugging signature rendering, zoom, or thumbnail issues
- Implementing sender-side PDF.js overlay (signature field placement) — the receiver side is the reference implementation
**Maintenance:** Update when `pdf-viewer.js` API changes, new JS interop functions are added, or the signing flow is modified.
#### `DATABASE_STRUCTURE_CONTEXT.md`
**What:** Deep DB documentation for the envelope domain. Includes table/column snapshot, entity-to-table mapping, EF relationship/constraint map, stored procedure behavior + call sites, and trigger behavior + wiring path.
**Why it exists:** DB is centralized and can evolve independently; this file preserves migration-critical data semantics when historical DB context is hard to recover.
**When you need it:**
- Planning or reviewing DB/schema changes
- Debugging status transitions/history side effects
- Tracing where `PRSIG_*` procedures or DB triggers affect app behavior
- Reconciling Domain entities with physical SQL schema
**Maintenance:** Refresh on each DB migration, trigger/procedure change, or significant Domain/EGDbContext model change.
---
### Deployment Docs
#### `EnvelopeGenerator.Server/EnvelopeGenerator.Server/README.md`
**What:** IIS publish and deployment guide for `EnvelopeGenerator.Server`. Covers why self-contained publish is required (WASM assembly version strictness), the exact `dotnet publish` command, IIS Application Pool settings (`No Managed Code`), directory structure after publish, and common deployment errors.
**Why it exists:** Blazor Auto (Server + WASM hybrid) has non-obvious deployment requirements that differ from a standard ASP.NET Core app. Forgetting self-contained publish or using the wrong app pool will break WASM loading in production.
**When you need it:**
- Deploying to IIS for the first time
- Troubleshooting 500 errors on production after deploy
- Setting up a new deployment environment
**Maintenance:** Update if the publish target, IIS config, or runtime version changes.
---
## 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)
- **EnvelopeGenerator.Server** (Server): `@rendermode InteractiveServer` - PDF viewers requiring DevExpress backend - **WebUI** (Server): `@rendermode InteractiveServer` - PDF viewers requiring DevExpress backend
- **EnvelopeGenerator.Server.Client** (WASM): `@rendermode InteractiveWebAssembly` - Login, dashboards, business logic - **WebUI.Client** (WASM): `@rendermode InteractiveWebAssembly` - Login, dashboards, business logic
**Backend + API Host:** `EnvelopeGenerator.Server` (ASP.NET Core 8.0, merged Blazor + Web API host) **Backend:** EnvelopeGenerator.API (ASP.NET Core 8.0)
**Proxy:** YARP in `EnvelopeGenerator.Server` is used for external integrations (for example AuthHub forwarding), not for routing app APIs to `EnvelopeGenerator.API`. **Proxy:** YARP in WebUI routes `/api/*` → `localhost:8088` (API)
### Deprecated Projects - DO NOT USE ### Deprecated Projects - DO NOT USE
- `EnvelopeGenerator.ReceiverUI` - Pure WASM (migrated to Server) - `EnvelopeGenerator.ReceiverUI` - Pure WASM (migrated to WebUI)
- `EnvelopeGenerator.Web` - Razor Pages (replaced by Server) - `EnvelopeGenerator.Web` - Razor Pages (replaced by WebUI)
- **VB.NET projects** (`Form`, `Service`, `BBTests`) - Legacy, read-only for reference - **VB.NET projects** (`Form`, `Service`, `BBTests`) - Legacy, read-only for reference
## Development Commands ## Development Commands
### Run Active Host ### Run Both Projects (Required)
```powershell ```powershell
# Terminal - Blazor + Web API host # Terminal 1 - API Backend
cd EnvelopeGenerator.Server\EnvelopeGenerator.Server cd EnvelopeGenerator.API
dotnet run
# Terminal 2 - Blazor Frontend
cd EnvelopeGenerator.WebUI\EnvelopeGenerator.WebUI
dotnet run dotnet run
``` ```
**Critical:** `EnvelopeGenerator.Server` is the active runtime host for both UI and API controllers. **Critical:** Both must run simultaneously. WebUI proxy forwards `/api/*` to API.
### Build ### Build
```powershell ```powershell
@@ -140,26 +45,24 @@ dotnet build EnvelopeGenerator.sln
EnvelopeGenerator.Domain/ # Entities (Envelope, Receiver, Document, etc.) EnvelopeGenerator.Domain/ # Entities (Envelope, Receiver, Document, etc.)
EnvelopeGenerator.Application/ # MediatR CQRS (Commands, Queries, Handlers) EnvelopeGenerator.Application/ # MediatR CQRS (Commands, Queries, Handlers)
EnvelopeGenerator.Infrastructure/ # EF Core, SQL executors, repositories EnvelopeGenerator.Infrastructure/ # EF Core, SQL executors, repositories
EnvelopeGenerator.Server/ EnvelopeGenerator.API/ # Controllers, endpoints
EnvelopeGenerator.Server/ # Server-side Blazor + API controllers EnvelopeGenerator.WebUI/ # Server-side Blazor components
├─ Components/Pages/ # @rendermode InteractiveServer ├─ Components/Pages/ # @rendermode InteractiveServer
├─ Controllers/ # Active Web API endpoints EnvelopeGenerator.WebUI.Client/ # Client-side WASM components
EnvelopeGenerator.Server.Client/ # Client-side WASM components ├─ Pages/ # @rendermode InteractiveWebAssembly
├─ Pages/ # @rendermode InteractiveWebAssembly ├─ Services/ # HTTP API clients
├─ Services/ # HTTP API clients ├─ Models/ # DTOs
├─ Models/ # DTOs
``` ```
## Route Structure (Critical) ## Route Structure (Critical)
| Route | File Location | Render Mode | Purpose | | Route | File Location | Render Mode | Purpose |
|-------|--------------|-------------|---------| |-------|--------------|-------------|---------|
| `/` | `Server.Client/Pages/Index.razor` | WASM | Landing page | | `/` | `WebUI.Client/Pages/Index.razor` | WASM | Landing page |
| `/sender/login` | `Server.Client/Pages/LoginSenderPage.razor` | WASM | Sender auth | | `/sender/login` | `WebUI.Client/Pages/LoginSenderPage.razor` | WASM | Sender auth |
| `/sender` | `Server/Components/Pages/EnvelopeSenderPage.razor` | **Server** | Sender dashboard | | `/sender` | `WebUI.Client/Pages/EnvelopeSenderPage.razor` | WASM | Sender dashboard |
| `/sender/editor` | `Server/Components/Pages/EnvelopeSenderEditorPage.razor` | **Server** | Envelope editor + PDF viewer | | `/envelope/login/{key}` | `WebUI.Client/Pages/LoginReceiverPage.razor` | WASM | Receiver auth |
| `/envelope/login/{key}` | `Server.Client/Pages/LoginReceiverPage.razor` | WASM | Receiver auth | | `/envelope/{key}` | `WebUI/Components/Pages/EnvelopeReceiverPage.razor` | **Server** | PDF viewer + signing |
| `/envelope/{key}` | `Server/Components/Pages/EnvelopeReceiverPage.razor` | **Server** | PDF viewer + signing |
**Rule:** PDF viewers MUST use `@rendermode InteractiveServer` (DevExpress backend requirement). Everything else uses WASM. **Rule:** PDF viewers MUST use `@rendermode InteractiveServer` (DevExpress backend requirement). Everything else uses WASM.
@@ -192,20 +95,22 @@ float inches = (pixelX / canvasWidth) * pageWidthInches;
- **Use case:** External API consumers - **Use case:** External API consumers
- **Not suitable for:** Step-by-step UI workflow (no draft support, no partial updates) - **Not suitable for:** Step-by-step UI workflow (no draft support, no partial updates)
### Sender Workflow Endpoint ### Missing Granular Endpoints (Need to Create)
``` ```
POST /api/Envelope # Create/update/save/send envelope workflow payload POST /api/Envelope/draft # Create draft envelope
PUT /api/Envelope/{id} # Update metadata
DELETE /api/Envelope/{id} # Delete with reason DELETE /api/Envelope/{id} # Delete with reason
POST /api/Envelope/{id}/document # Upload PDF POST /api/Envelope/{id}/document # Upload PDF
POST /api/Envelope/{id}/receivers # Add receiver POST /api/Envelope/{id}/receivers # Add receiver
POST /api/Envelope/{id}/signature-fields # Place signature field POST /api/Envelope/{id}/signature-fields # Place signature field
POST /api/Envelope/{id}/send # Send to receivers
``` ```
See `FORM_APPLICATION_CONTEXT.md` for detailed workflow requirements. See `FORM_APPLICATION_CONTEXT.md` for detailed workflow requirements.
## Status Color Coding ## Status Color Coding
Form app uses DevExpress `CustomDrawCell`. EnvelopeGenerator.Server needs CSS: Form app uses DevExpress `CustomDrawCell`. WebUI needs CSS:
```css ```css
.envelope-row.status-partly-signed { background-color: #81C784; } /* GREEN_300 */ .envelope-row.status-partly-signed { background-color: #81C784; } /* GREEN_300 */
@@ -218,10 +123,10 @@ Form app uses DevExpress `CustomDrawCell`. EnvelopeGenerator.Server needs CSS:
## Configuration ## Configuration
### YARP Proxy (`EnvelopeGenerator.Server/yarp.json`) ### YARP Proxy (`WebUI/yarp.json`)
Routes `/api/*`, `/swagger/*`, `/openapi/*`, `/scalar/*` → `https://localhost:8088` Routes `/api/*`, `/swagger/*`, `/openapi/*`, `/scalar/*` → `https://localhost:8088`
### PDF.js Settings (`EnvelopeGenerator.Server/wwwroot/appsettings.json`) ### PDF.js Settings (`WebUI/wwwroot/appsettings.json`)
```json ```json
{ {
"PdfViewerOptions": { "PdfViewerOptions": {
@@ -233,7 +138,7 @@ Routes `/api/*`, `/swagger/*`, `/openapi/*`, `/scalar/*` → `https://localhost:
} }
``` ```
### Server Host Config (`EnvelopeGenerator.Server/EnvelopeGenerator.Server/appsettings.json`) ### API Config (`API/appsettings.json`)
- `ConnectionStrings:Default` - SQL Server DB - `ConnectionStrings:Default` - SQL Server DB
- `AllowedOrigins` - CORS (includes `http://localhost:5131`, `http://localhost:7192`) - `AllowedOrigins` - CORS (includes `http://localhost:5131`, `http://localhost:7192`)
- `Cache:SignatureCacheExpiration` - Signature persistence timeout - `Cache:SignatureCacheExpiration` - Signature persistence timeout
@@ -271,84 +176,27 @@ Routes `/api/*`, `/swagger/*`, `/openapi/*`, `/scalar/*` → `https://localhost:
**No automated tests exist yet.** **No automated tests exist yet.**
Manual testing workflow: Manual testing workflow:
1. Start EnvelopeGenerator.Server (`dotnet run` in `EnvelopeGenerator.Server\EnvelopeGenerator.Server`) 1. Start API (`dotnet run` in `EnvelopeGenerator.API`)
2. Navigate to `https://localhost:5131` (or check console output for port) 2. Start WebUI (`dotnet run` in `EnvelopeGenerator.WebUI\EnvelopeGenerator.WebUI`)
3. Test sender login at `/sender/login` 3. Navigate to `https://localhost:5131` (or check console output for port)
4. Test receiver flow at `/envelope/login/{envelopeKey}` 4. Test sender login at `/sender/login`
5. 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)
- Connection string in `EnvelopeGenerator.Server/EnvelopeGenerator.Server/appsettings.json` - Connection string in `API/appsettings.json`
- EF Core migrations NOT used (manual SQL scripts) - EF Core migrations NOT used (manual SQL scripts)
- Stored procedures: `PRSIG_*` prefix - Stored procedures: `PRSIG_*` prefix
**Key Tables:** **Key Tables:**
- `TBSIG_ENVELOPE` - Envelope metadata - `TBSIG_ENVELOPE` - Envelope metadata
- `TBSIG_ENVELOPE_RECEIVER` - Receiver assignments - `TBSIG_ENVELOPE_RECEIVER` - Receiver assignments
- `TBSIG_DOCUMENT_RECEIVER_ELEMENT` - Signature fields (X, Y in INCHES) - `TBSIG_DOC_RECEIVER_ELEMENT` - Signature fields (X, Y in INCHES)
- `TBSIG_RECEIVER` - Receiver registry - `TBSIG_RECEIVER` - Receiver registry
- `TBSIG_ENVELOPE_DOCUMENT` - PDF binary data - `TBSIG_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)
@@ -397,7 +245,7 @@ Each envelope maintains independent authentication state.
**CDN:** **CDN:**
- PDF.js 3.11.174: `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js` - PDF.js 3.11.174: `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js`
**NuGet (Server.Client):** **NuGet (WebUI.Client):**
- `DevExpress.Blazor.*` 25.2.3 - `DevExpress.Blazor.*` 25.2.3
- `SkiaSharp.*` 3.119.1 (WASM rendering) - `SkiaSharp.*` 3.119.1 (WASM rendering)
@@ -411,4 +259,5 @@ Each envelope maintains independent authentication state.
None required. All config in `appsettings.json`. None required. All config in `appsettings.json`.
**Local dev ports:** **Local dev ports:**
- App host (UI + API): `https://localhost:5131` or `http://localhost:7192` - API: `https://localhost:8088`
- WebUI: `https://localhost:5131` or `http://localhost:7192`

View File

@@ -39,7 +39,7 @@ This project contains:
- `EnvelopeGenerator.Domain` — domain models, constants, shared abstractions - `EnvelopeGenerator.Domain` — domain models, constants, shared abstractions
- `EnvelopeGenerator.Infrastructure` — EF Core and infrastructure services - `EnvelopeGenerator.Infrastructure` — EF Core and infrastructure services
- `EnvelopeGenerator.PdfEditor` — PDF-related backend utilities - `EnvelopeGenerator.PdfEditor` — PDF-related backend utilities
- `EnvelopeGenerator.API` — deprecated project (no longer part of the active solution/runtime path) - `EnvelopeGenerator.API` — still exists in the solution, but the current merged app host is `EnvelopeGenerator.Server`
### Legacy / Do Not Touch ### Legacy / Do Not Touch
- `EnvelopeGenerator.Service` - `EnvelopeGenerator.Service`
@@ -121,7 +121,7 @@ Current controller set includes:
- `SignatureController` - `SignatureController`
- `TfaRegistrationController` - `TfaRegistrationController`
Do not assume API behavior lives in `EnvelopeGenerator.API`; the active merged host contains controller endpoints directly. Do not assume API behavior lives only in `EnvelopeGenerator.API`; the active merged host contains controller endpoints directly.
--- ---

View File

@@ -1,384 +0,0 @@
# Database Structure Context
## Why this file exists
This is the deep-dive DB reference for migration work when schema can change and old DB knowledge may be lost.
- Scope: `EnvelopeGenerator.Domain` entities + `EnvelopeGenerator.Infrastructure/EGDbContext.cs` + live DB metadata snapshot from `DD_ECM`.
- Goal: make schema transitions predictable (what exists, how it is linked, where logic executes).
- Safety: all DB checks used read-only `SELECT` metadata queries; no data mutation.
## Snapshot metadata
- Source DB: `DD_ECM` on `SDD-VHP04-SQL19\DD_TESTING01`
- Snapshot date: `2026-10-20`
- App context: `EnvelopeGenerator.Infrastructure/EGDbContext.cs`
## Fast navigation
- High-level aggregate map: `AGENTS.md` -> `Database` section
- EF model config source: `EnvelopeGenerator.Infrastructure/EGDbContext.cs`
- Stored procedure wrappers:
- `EnvelopeGenerator.Application/Common/SQL/EnvelopeCreateReadSQL.cs`
- `EnvelopeGenerator.Application/Common/SQL/EnvelopeReceiverAddReadSQL.cs`
- `EnvelopeGenerator.Application/Common/SQL/DocumentCreateReadSQL.cs`
- Direct SQL execution in controller:
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/EnvelopeReceiverController.cs`
## Core data model (ER-level)
- `Envelope` (`TBSIG_ENVELOPE`) is root aggregate.
- `Envelope (1) -> (N) Document` via `TBSIG_ENVELOPE_DOCUMENT.ENVELOPE_ID`.
- `Envelope (1) -> (N) History` via `TBSIG_ENVELOPE_HISTORY.ENVELOPE_ID`.
- `Envelope (N) <-> (N) Receiver` through `EnvelopeReceiver` (`TBSIG_ENVELOPE_RECEIVER`, composite PK `{ENVELOPE_ID, RECEIVER_ID}`).
- `Document (1) -> (N) DocReceiverElement` via `TBSIG_DOCUMENT_RECEIVER_ELEMENT.DOCUMENT_ID`.
- `DocReceiverElement (1) -> (N) ElementAnnotation` via `TBSIG_DOCUMENT_RECEIVER_ELEMENT_ANNOTATION.ELEMENT_ID`.
- `DocumentStatus` links `Envelope + Receiver` state in `TBSIG_DOCUMENT_STATUS`.
## Entity <-> table mapping (code-level)
| Entity | Table | PK in code | PK in DB | Important relation notes |
|---|---|---|---|---|
| `Envelope` | `TBSIG_ENVELOPE` | `Id` | `GUID` | Has `Documents`, `Histories`, `EnvelopeReceivers`; unique index on `Uuid` configured in EF |
| `EnvelopeReceiver` | `TBSIG_ENVELOPE_RECEIVER` | `{EnvelopeId, ReceiverId}` | `{ENVELOPE_ID, RECEIVER_ID}` | DB also has `GUID` identity (not modeled as entity PK) |
| `Document` | `TBSIG_ENVELOPE_DOCUMENT` | `Id` | `GUID` | FK `ENVELOPE_ID` |
| `DocReceiverElement` | `TBSIG_DOCUMENT_RECEIVER_ELEMENT` | `Id` | `GUID` | Coordinates persisted here (`POSITION_X/Y`, `WIDTH`, `HEIGHT`, `PAGE`) |
| `ElementAnnotation` | `TBSIG_DOCUMENT_RECEIVER_ELEMENT_ANNOTATION` | `Id` | `GUID` | FK `ELEMENT_ID` |
| `DocumentStatus` | `TBSIG_DOCUMENT_STATUS` | `Id` | `GUID` | FK `ENVELOPE_ID`, `RECEIVER_ID` |
| `History` | `TBSIG_ENVELOPE_HISTORY` | `Id` | `GUID` | `USER_REFERENCE` maps to `Receiver.EmailAddress` and `User.Email` when `IsMigration == false` |
| `Receiver` | `TBSIG_RECEIVER` | `Id` | `GUID` | Unique indexes in code: `Signature`, `EmailAddress` |
| `EnvelopeReceiverReadOnly` | `TBSIG_ENVELOPE_RECEIVER_READ_ONLY` | `Id` | `GUID` | `AddedWho` is FK-like mapping to `Receiver.EmailAddress` |
| `EnvelopeType` | `TBSIG_ENVELOPE_TYPE` | `Id` | `GUID` | Used as optional envelope defaults/template |
| `EmailTemplate` | `TBSIG_EMAIL_TEMPLATE` | `Id` | `GUID` | Trigger-managed `CHANGED_WHEN` |
| `EmailOut` (legacy/bridge) | `TBEMLP_EMAIL_OUT` | `Id` | `GUID` | Internal/obsolete domain class, still registered for DB access |
## Physical schema snapshot (table/column level)
Notes:
- Format per column: `COLUMN_NAME TYPE NULLABLE`.
- This section reflects live DB metadata at snapshot time.
### `TBSIG_ENVELOPE`
- `GUID int NOT NULL`
- `USER_ID int NOT NULL`
- `STATUS int NOT NULL`
- `ENVELOPE_UUID nvarchar(36) NOT NULL`
- `MESSAGE nvarchar(max) NOT NULL`
- `EXPIRES_WHEN datetime NULL`
- `EXPIRES_WARNING_WHEN datetime NULL`
- `VERIFICATION_TYPE tinyint NOT NULL`
- `TITLE nvarchar(128) NULL`
- `CONTRACT_TYPE int NULL`
- `LANGUAGE nvarchar(5) NULL`
- `SEND_REMINDER_EMAILS bit NULL`
- `FIRST_REMINDER_DAYS int NULL`
- `REMINDER_INTERVAL_DAYS int NULL`
- `ENVELOPE_TYPE int NULL`
- `CERTIFICATION_TYPE int NULL`
- `USE_ACCESS_CODE bit NULL`
- `FINAL_EMAIL_TO_CREATOR int NULL`
- `FINAL_EMAIL_TO_RECEIVERS int NULL`
- `EXPIRES_WHEN_DAYS int NULL`
- `EXPIRES_WARNING_WHEN_DAYS int NULL`
- `TFA_ENABLED bit NOT NULL`
- `DOC_RESULT varbinary(max) NULL`
- `LANG_CODE varchar(5) NOT NULL`
- `SOURCE varchar(10) NOT NULL`
- `COMMENT varchar(128) NULL`
- `ADDED_WHEN datetime NOT NULL`
- `CHANGED_WHEN datetime NULL`
### `TBSIG_ENVELOPE_RECEIVER`
- `ENVELOPE_ID int NOT NULL`
- `RECEIVER_ID int NOT NULL`
- `SEQUENCE int NOT NULL`
- `NAME nvarchar(128) NULL`
- `JOB_TITLE nvarchar(128) NULL`
- `COMPANY_NAME nvarchar(128) NULL`
- `PRIVATE_MESSAGE nvarchar(max) NULL`
- `ACCESS_CODE nvarchar(64) NULL`
- `ADDED_WHEN datetime NOT NULL`
- `CHANGED_WHEN datetime NULL`
- `GUID bigint NOT NULL`
- `PHONE_NUMBER varchar(20) NULL`
- `PARENT_ENVELOPE_RECEIVER_ID int NOT NULL`
### `TBSIG_ENVELOPE_DOCUMENT`
- `GUID int NOT NULL`
- `ENVELOPE_ID int NOT NULL`
- `FILENAME nvarchar(256) NOT NULL`
- `FILEPATH nvarchar(256) NOT NULL`
- `ADDED_WHEN datetime NOT NULL`
- `FILENAME_ORIGINAL nvarchar(256) NULL`
- `BYTE_DATA varbinary(max) NULL`
### `TBSIG_DOCUMENT_RECEIVER_ELEMENT`
- `GUID int NOT NULL`
- `DOCUMENT_ID int NOT NULL`
- `RECEIVER_ID int NOT NULL`
- `ELEMENT_TYPE int NOT NULL`
- `POSITION_X float NOT NULL`
- `POSITION_Y float NOT NULL`
- `WIDTH float NOT NULL`
- `HEIGHT float NOT NULL`
- `PAGE int NOT NULL`
- `REQUIRED bit NOT NULL`
- `TOOLTIP nvarchar(512) NULL`
- `READ_ONLY bit NOT NULL`
- `ANNOTATION_INDEX int NOT NULL`
- `ADDED_WHEN datetime NOT NULL`
- `CHANGED_WHEN datetime NULL`
- `CHANGED_WHO nvarchar(100) NULL`
- `FULL_NAME nvarchar(100) NULL`
- `POSITION nvarchar(100) NULL`
- `PLACE nvarchar(100) NULL`
- `INK varbinary(max) NULL`
### `TBSIG_DOCUMENT_RECEIVER_ELEMENT_ANNOTATION`
- `GUID bigint NOT NULL`
- `ELEMENT_ID int NOT NULL`
- `NAME nvarchar(100) NOT NULL`
- `VALUE nvarchar(max) NOT NULL`
- `ADDED_WHEN datetime NOT NULL`
- `CHANGED_WHEN datetime NULL`
- `CHANGED_WHO nvarchar(100) NULL`
- `TYPE nvarchar(50) NOT NULL`
- `POSITION_X float NULL`
- `POSITION_Y float NULL`
- `WIDTH float NULL`
- `HEIGHT float NULL`
### `TBSIG_DOCUMENT_STATUS`
- `GUID int NOT NULL`
- `ENVELOPE_ID int NOT NULL`
- `RECEIVER_ID int NOT NULL`
- `STATUS int NOT NULL`
- `STATUS_CHANGED_WHEN datetime NULL`
- `VALUE nvarchar(max) NULL`
- `ADDED_WHEN datetime NOT NULL`
- `CHANGED_WHEN datetime NULL`
### `TBSIG_ENVELOPE_HISTORY`
- `GUID bigint NOT NULL`
- `ENVELOPE_ID int NOT NULL`
- `USER_REFERENCE nvarchar(128) NOT NULL`
- `STATUS int NOT NULL`
- `ADDED_WHEN datetime NOT NULL`
- `ACTION_DATE datetime NULL`
- `COMMENT nvarchar(max) NULL`
### `TBSIG_RECEIVER`
- `GUID int NOT NULL`
- `EMAIL_ADDRESS nvarchar(128) NOT NULL`
- `SIGNATURE nvarchar(64) NOT NULL`
- `ADDED_WHEN datetime NOT NULL`
- `TOTP_SECRET_KEY nvarchar(max) NULL`
- `TFA_REG_DEADLINE datetime NULL`
- `CULTURE nvarchar(10) NULL`
### `TBSIG_ENVELOPE_RECEIVER_READ_ONLY`
- `GUID bigint NOT NULL`
- `ENVELOPE_ID bigint NOT NULL`
- `RECEIVER_MAIL nvarchar(250) NOT NULL`
- `DATE_VALID date NOT NULL`
- `ADDED_WHO varchar(100) NOT NULL`
- `ADDED_WHEN datetime NULL`
- `CHANGED_WHO varchar(100) NULL`
- `CHANGED_WHEN datetime NULL`
### `TBSIG_ENVELOPE_TYPE`
- `GUID int NOT NULL`
- `TITLE nvarchar(128) NOT NULL`
- `LANGUAGE nvarchar(5) NOT NULL`
- `EXPIRES_DAYS int NULL`
- `CERTIFICATION_TYPE int NULL`
- `USE_ACCESS_CODE bit NULL`
- `FINAL_EMAIL_TO_CREATOR int NULL`
- `FINAL_EMAIL_TO_RECEIVERS int NULL`
- `ADDED_WHEN datetime NOT NULL`
- `CHANGED_WHEN datetime NULL`
- `EXPIRES_WARNING_DAYS int NULL`
- `SEND_REMINDER_EMAILS bit NULL`
- `FIRST_REMINDER_DAYS int NULL`
- `REMINDER_INTERVAL_DAYS int NULL`
- `CONTRACT_TYPE int NULL`
### `TBSIG_EMAIL_TEMPLATE`
- `GUID int NOT NULL`
- `NAME nvarchar(64) NULL`
- `BODY nvarchar(max) NULL`
- `SUBJECT nvarchar(512) NULL`
- `ADDED_WHEN datetime NOT NULL`
- `CHANGED_WHEN datetime NULL`
- `LANG_CODE varchar(5) NOT NULL`
### `TBEMLP_EMAIL_OUT`
- `GUID int NOT NULL`
- `REMINDER_TYPE_ID int NOT NULL`
- `SENDING_PROFILE int NOT NULL`
- `REFERENCE_ID int NOT NULL`
- `REFERENCE_STRING varchar(200) NULL`
- `ENTITY_ID int NULL`
- `WF_ID int NOT NULL`
- `WF_REFERENCE varchar(200) NULL`
- `EMAIL_ADRESS varchar(1000) NOT NULL`
- `EMAIL_SUBJ varchar(500) NOT NULL`
- `EMAIL_BODY varchar(max) NOT NULL`
- `EMAIL_ATTMT1 varchar(512) NULL`
- `EMAIL_SENT datetime NULL`
- `COMMENT varchar(500) NULL`
- `ADDED_WHO varchar(50) NOT NULL`
- `ADDED_WHEN datetime NULL`
- `CHANGED_WHO varchar(50) NULL`
- `CHANGED_WHEN datetime NULL`
- `ERROR_TIMESTAMP datetime NULL`
- `ERROR_MSG varchar(900) NULL`
- `ATTACHMENT1_BYTES varbinary(max) NULL`
- `ATT1_RELATED_ID bigint NULL`
- `ATT1_REL_TYPE varchar(20) NULL`
## Constraints and indexes (relevant)
From EF + DB metadata snapshot:
- `TBSIG_ENVELOPE`: PK `PK_TBSIG_ENVELOPE (GUID)`.
- `TBSIG_ENVELOPE_RECEIVER`: composite PK `PK_TBSIG_ENVELOPE_RECEIVER (ENVELOPE_ID, RECEIVER_ID)`.
- `TBSIG_DOCUMENT_RECEIVER_ELEMENT`: PK `GUID`, plus unique key `UQTBSIG_DOCUMENT_RECEIVER_ELEMENT_DocID_PosX_PosY (DOCUMENT_ID, POSITION_X, POSITION_Y)`.
- `TBSIG_RECEIVER`: PK `GUID`, unique key `UQTBSIG_RECEIVER (EMAIL_ADDRESS)`.
- `TBEMLP_EMAIL_OUT`: PK `GUID`, FK `FK_TBEMLP_EMAIL_OUT_SENDING_PROFILE`.
## Stored procedures: behavior and code call sites
### Active in .NET code path
1) `PRSIG_API_CREATE_ENVELOPE`
- Purpose: creates envelope row (`STATUS=1002`, `SOURCE='API'`, defaults) and returns `ENVELOPE_UUID` via output.
- DB writes: `TBSIG_ENVELOPE`.
- Called by:
- SQL template `EnvelopeGenerator.Application/Common/SQL/EnvelopeCreateReadSQL.cs`
- executor `EnvelopeGenerator.Infrastructure/Executor/EnvelopeExecutor.cs`
- used in `EnvelopeReceiverController.CreateAsync` and `CreateEnvelopeReceiverCommandHandler`.
2) `PRSIG_API_CREATE_RECEIVER`
- Purpose: upserts `TBSIG_RECEIVER` by email, adds envelope assignment row with generated access code.
- DB writes: `TBSIG_RECEIVER`, `TBSIG_ENVELOPE_RECEIVER`.
- Called by:
- SQL template `EnvelopeGenerator.Application/Common/SQL/EnvelopeReceiverAddReadSQL.cs`
- executor `EnvelopeGenerator.Infrastructure/Executor/EnvelopeReceiverExecutor.cs`
- used in `EnvelopeReceiverController.CreateAsync` and `CreateEnvelopeReceiverCommandHandler`.
3) `PRSIG_API_ADD_DOC`
- Purpose: stores one document for envelope if absent; returns doc id.
- DB writes: `TBSIG_ENVELOPE_DOCUMENT`.
- Called by:
- SQL template `EnvelopeGenerator.Application/Common/SQL/DocumentCreateReadSQL.cs`
- executor `EnvelopeGenerator.Infrastructure/Executor/DocumentExecutor.cs`
- used in `EnvelopeReceiverController.CreateAsync`.
4) `PRSIG_API_ADD_DOC_RECEIVER_ELEM`
- Purpose: inserts signature element record if `(DOC_ID, RECEIVER_ID)` pair does not exist.
- DB writes: `TBSIG_DOCUMENT_RECEIVER_ELEMENT`.
- Called directly by:
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/EnvelopeReceiverController.cs`
5) `PRSIG_API_ADD_HISTORY_STATE`
- Purpose: appends history row with status and user reference.
- DB writes: `TBSIG_ENVELOPE_HISTORY`.
- Called directly by:
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/EnvelopeReceiverController.cs`
### Legacy/other path references in repo
- `PRSIG_GET_ENVELOPES_FOR_USER` and `PRSIG_GET_CLOSED_ENVELOPES_FOR_USER` are called in legacy VB path:
- `EnvelopeGenerator.CommonServices/Models/EnvelopeModel.vb`
- `PRSIG_API_WITHDRAW_ENVELOPE` exists in DB snapshot but no active `.cs` call site found in current server flow.
## Trigger behavior and where they are wired
## How trigger names are wired in app
- Config source: `EnvelopeGenerator.Server/EnvelopeGenerator.Server/appsettings.json` -> `DbTriggerParams`.
- DI binding: `EnvelopeGenerator.Infrastructure/DependencyInjection.cs` (`AddDbTriggerParams`).
- Model binding: `EnvelopeGenerator.Infrastructure/EGDbContext.cs` -> `AddTrigger<T>()` -> `ToTable(tb => tb.HasTrigger(triggerName))`.
### Trigger definitions observed in DB snapshot
1) `TBSIG_DOCUMENT_RECEIVER_ELEMENT_AFT_INS`
- Fires on insert of `TBSIG_DOCUMENT_RECEIVER_ELEMENT`.
- Normalizes/rewrites `POSITION_X`, `POSITION_Y` and sets `CHANGED_WHO='TBSIG_DOC_REC_EL_AFT_INS'`.
2) `TBSIG_DOCUMENT_RECEIVER_ELEMENT_AFT_UPD`
- Fires on update of `TBSIG_DOCUMENT_RECEIVER_ELEMENT`.
- Rewrites coordinates and `CHANGED_WHO='TBSIG_DOC_REC_EL_AFT_UPD'` except when `CHANGED_WHO='OFFSET_Y_POS'`.
3) `TBSIG_ENVELOPE_HISTORY_AFT_INS`
- Fires on history insert.
- Central state machine side-effects:
- syncs `TBSIG_ENVELOPE.STATUS` for selected `100x` states,
- on `1003` applies Y-offset update to receiver elements,
- on `2005` calculates partial/fully signed and may insert follow-up `1006` history,
- on `2007` (type-dependent reject path) writes rejection emails into `TBEMLP_EMAIL_OUT` and updates envelope rejected state,
- on `3001` invitation-message counting can move envelope to `1004`.
4) `TBSIG_EMAIL_TEMPLATE_AFT_UPD`
- Fires on update of `TBSIG_EMAIL_TEMPLATE`.
- Sets `CHANGED_WHEN = GETDATE()`.
5) `TBSIG_ENVELOPE_RECEIVER_READ_ONLY_UPD`
- Fires on update of `TBSIG_ENVELOPE_RECEIVER_READ_ONLY`.
- Updates config timestamp row (`TBSIG_CONFIG.GUID = 1`).
6) `TBEMLP_EMAIL_OUT_AFT_UPD`
- Fires on update of `TBEMLP_EMAIL_OUT`.
- When `EMAIL_SENT` changes and `WF_ID in (3001..3005)`, writes corresponding history entries to `TBSIG_ENVELOPE_HISTORY`.
### Trigger mismatch alert (important)
- `DbTriggerParams` contains `TBEMLP_EMAIL_OUT_AFT_INS`, but this trigger was not found in the snapshot DB (`ExistsInDb = 0`).
- `TBEMLP_EMAIL_OUT_AFT_UPD` exists and is active.
This should be treated as environment drift and validated before migrations.
## Known model-vs-DB drift risks
These are not necessarily bugs, but they are migration risk hotspots:
- `Receiver.EmailAddress` is `[StringLength(250)]` in entity, DB column is `nvarchar(128)`.
- `EnvelopeReceiverReadOnly.AddedWho` entity expects `nvarchar(250)`, DB column is `varchar(100)`.
- `EnvelopeReceiver` entity does not model DB columns `GUID` and `PARENT_ENVELOPE_RECEIVER_ID`.
- `Envelope` entity does not explicitly model some DB columns (`VERIFICATION_TYPE`, `LANG_CODE`, `SOURCE`).
- `EmailOut` entity has `[NotMapped] AddedWhen`, while DB has `ADDED_WHEN` column.
Before schema changes, reconcile these deltas intentionally (either mapping update or documented accepted divergence).
## Migration-safe operating rules
- Do not rely on implicit trigger behavior; document expected side effects in feature code touching history/status flows.
- Any change to `TBSIG_ENVELOPE_HISTORY` semantics requires trigger impact review first.
- Keep `DbTriggerParams` synchronized with actual DB trigger inventory per environment.
- For stored procedure changes, update both:
- SQL wrapper templates (`Application/Common/SQL/*`)
- direct SQL callers in controllers/executors.
## Refresh checklist (read-only)
Run these metadata checks before/after DB migration:
1. Column inventory per table (`INFORMATION_SCHEMA.COLUMNS`).
2. Constraint inventory (`INFORMATION_SCHEMA.TABLE_CONSTRAINTS` + `KEY_COLUMN_USAGE`).
3. Trigger inventory (`sys.triggers`) and definitions (`OBJECT_DEFINITION`).
4. Stored procedure inventory (`sys.procedures`) and definitions (`OBJECT_DEFINITION`).
5. Repo call-site grep for `PRSIG_` names to detect dead or missing integration points.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

10
EnvelopeGenerator.API/Jenkinsfile vendored Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,17 +0,0 @@
namespace EnvelopeGenerator.Application.Common.Configurations;
/// <summary>
///
/// </summary>
public class ESignParams
{
/// <summary>
///
/// </summary>
public required string ApplicationName { get; set; }
/// <summary>
///
/// </summary>
public string DefaultReason { get; set; } = string.Empty;
}

View File

@@ -1,17 +0,0 @@
namespace EnvelopeGenerator.Application.Common.Configurations;
/// <summary>
///
/// </summary>
public class SignatureFieldParams
{
/// <summary>
///
/// </summary>
public double Width { get; init; } = 1.77;
/// <summary>
///
/// </summary>
public double Height { get; init; } = 1.96;
}

View File

@@ -1,14 +1,12 @@
using EnvelopeGenerator.Application.Common.Dto.Receiver; using EnvelopeGenerator.Domain.Constants;
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, ISignatureInkPayload public class DocReceiverElementDto : IDocReceiverElement
{ {
/// <summary> /// <summary>
/// Gets or sets the unique identifier of the element. /// Gets or sets the unique identifier of the element.
@@ -20,21 +18,11 @@ public class DocReceiverElementDto : IDocReceiverElement, ISignatureInkPayload
/// </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>
@@ -134,41 +122,4 @@ public class DocReceiverElementDto : IDocReceiverElement, ISignatureInkPayload
/// ///
/// </summary> /// </summary>
public byte[]? Ink { get; set; } public byte[]? Ink { get; set; }
/// <summary>
/// Returns the stored signature bytes as an image payload with detected MIME type.
/// </summary>
/// <exception cref="InvalidOperationException">Thrown when signature image bytes do not exist.</exception>
public (byte[] Bytes, string ContentType, string FileExtension) ToImage()
{
if (Ink is not { Length: > 0 })
throw new InvalidOperationException("Signature image (Ink) does not exist.");
if (HasPrefix(Ink, 0x89, 0x50, 0x4E, 0x47))
return (Ink, "image/png", ".png");
if (HasPrefix(Ink, 0xFF, 0xD8, 0xFF))
return (Ink, "image/jpeg", ".jpg");
if (HasPrefix(Ink, 0x52, 0x49, 0x46, 0x46) &&
Ink.Length >= 12 &&
Ink[8] == 0x57 && Ink[9] == 0x45 && Ink[10] == 0x42 && Ink[11] == 0x50)
return (Ink, "image/webp", ".webp");
return (Ink, "application/octet-stream", ".bin");
}
private static bool HasPrefix(byte[] data, params byte[] prefix)
{
if (data.Length < prefix.Length)
return false;
for (var i = 0; i < prefix.Length; i++)
{
if (data[i] != prefix[i])
return false;
}
return true;
}
} }

View File

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

View File

@@ -3,25 +3,30 @@
/// <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 record DocumentDto : DocumentDetailsDto public class DocumentDto
{ {
/// <summary> /// <summary>
/// /// Gets or sets the unique identifier of the document.
/// </summary> /// </summary>
public DocumentDto() { } public int Id { get; set; }
/// <summary> /// <summary>
/// /// Gets or sets the envelope ID to which the document belongs.
/// </summary> /// </summary>
/// <param name="byteData"></param> public int EnvelopeId { get; set; }
/// <param name="details"></param>
public DocumentDto(byte[] byteData, DocumentDetailsDto details) : base(details) /// <summary>
{ /// Gets or sets the date and time when the document was added.
ByteData = byteData; /// </summary>
} 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; }
} }

View File

@@ -62,7 +62,7 @@ public record EnvelopeDto : IEnvelope
/// <summary> /// <summary>
/// ///
/// </summary> /// </summary>
public ContractType? ContractType { get; set; } public int? ContractType { get; set; }
/// <summary> /// <summary>
/// Default value is 'de-DE' /// Default value is 'de-DE'
@@ -84,42 +84,7 @@ public record EnvelopeDto : IEnvelope
/// <summary> /// <summary>
/// ///
/// </summary> /// </summary>
public CertificationType? CertificationType { get; set; } public int? CertificationType { get; set; }
/// <summary>
///
/// </summary>
public int? FinalEmailToCreator { get; set; }
/// <summary>
///
/// </summary>
public int? FinalEmailToReceivers { get; set; }
/// <summary>
///
/// </summary>
public bool? SendReminderEmails { get; set; }
/// <summary>
///
/// </summary>
public int? FirstReminderDays { get; set; }
/// <summary>
///
/// </summary>
public int? ReminderIntervalDays { get; set; }
/// <summary>
///
/// </summary>
public int? ExpiresWhenDays { get; set; }
/// <summary>
///
/// </summary>
public int? ExpiresWarningWhenDays { get; set; }
/// <summary> /// <summary>
/// ///

View File

@@ -60,11 +60,6 @@ public record EnvelopeReceiverDto
/// </summary> /// </summary>
public string? PrivateMessage { get; init; } public string? PrivateMessage { get; init; }
/// <summary>
///
/// </summary>
public string? AccessCode { get; init; }
/// <summary> /// <summary>
/// ///
/// </summary> /// </summary>

View File

@@ -1,6 +1,4 @@
using EnvelopeGenerator.Domain.Constants; namespace EnvelopeGenerator.Application.Common.Dto;
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.
@@ -30,7 +28,7 @@ public class EnvelopeTypeDto
/// <summary> /// <summary>
/// Gets or sets the certification type identifier. /// Gets or sets the certification type identifier.
/// </summary> /// </summary>
public CertificationType? CertificationType { get; set; } public int? 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.
@@ -80,5 +78,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 ContractType? ContractType { get; set; } public int? ContractType { get; set; }
} }

View File

@@ -1,16 +0,0 @@
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;
}

View File

@@ -1,6 +0,0 @@
namespace EnvelopeGenerator.Application.Common.Dto;
public sealed class PdfRenderRequestDto
{
public byte[]? PdfBytes { get; init; }
}

View File

@@ -1,54 +0,0 @@
using EnvelopeGenerator.Application.Common.Enums;
namespace EnvelopeGenerator.Application.Common.Dto;
/// <summary>
///
/// </summary>
public sealed record PdfSignOptionsDto
{
/// <summary>
///
/// </summary>
public long DocumentElementId { get; set; }
/// <summary>
///
/// </summary>
public string Name { get; set; } = null!;
/// <summary>
///
/// </summary>
public string Reason { get; set; } = null!;
/// <summary>
///
/// </summary>
public string Location { get; set; } = null!;
/// <summary>
///
/// </summary>
public string? Email { get; set; }
/// <summary>
///
/// </summary>
public string? Phone { get; set; }
/// <summary>
///
/// </summary>
public string ApplicationName { get; set; } = null!;
/// <summary>
///
/// </summary>
public PdfSignatureCertificationLevel CertificationLevel { get; set; }
/// <summary>
///
/// </summary>
public PdfSignatureAppearanceDto? Appearance { get; set; }
}

View File

@@ -1,96 +0,0 @@
namespace EnvelopeGenerator.Application.Common.Dto;
/// <summary>
///
/// </summary>
public enum PdfSignatureCoordinateUnit
{
/// <summary>
///
/// </summary>
Point = 0,
/// <summary>
///
/// </summary>
Inch = 1
}
/// <summary>
///
/// </summary>
public enum PdfSignatureCoordinateOrigin
{
/// <summary>
///
/// </summary>
BottomLeft = 0,
/// <summary>
///
/// </summary>
TopLeft = 1
}
/// <summary>
///
/// </summary>
/// <param name="PageNumber"></param>
/// <param name="Left"></param>
/// <param name="Bottom"></param>
/// <param name="Width"></param>
/// <param name="Height"></param>
/// <param name="ShowLabels"></param>
/// <param name="ShowName"></param>
/// <param name="ShowDate"></param>
/// <param name="ShowReason"></param>
/// <param name="ShowLocation"></param>
/// <param name="ShowDistinguishedName"></param>
/// <param name="DateTimeFormat"></param>
/// <param name="CoordinateUnit"></param>
/// <param name="CoordinateOrigin"></param>
public sealed record PdfSignatureAppearanceDto(
int PageNumber,
double Left,
double Bottom,
double Width,
double Height,
bool ShowLabels = false,
bool ShowName = false,
bool ShowDate = false,
bool ShowReason = false,
bool ShowLocation = false,
bool ShowDistinguishedName = false,
string DateTimeFormat = "dd.MM.yyyy HH:mm:ss zzz",
PdfSignatureCoordinateUnit CoordinateUnit = PdfSignatureCoordinateUnit.Point,
PdfSignatureCoordinateOrigin CoordinateOrigin = PdfSignatureCoordinateOrigin.BottomLeft)
{
/// <summary>
///
/// </summary>
/// <param name="FamilyName"></param>
/// <param name="Size"></param>
/// <param name="IsBold"></param>
/// <param name="IsItalic"></param>
/// <param name="IsUnderline"></param>
public sealed record FontDto(string FamilyName = "Helvetica", float Size = 10, bool IsBold = false, bool IsItalic = false, bool IsUnderline = false)
{
/// <summary>
///
/// </summary>
public RgbaColorDto Color { get; init; } = new();
}
/// <summary>
///
/// </summary>
/// <param name="R"></param>
/// <param name="G"></param>
/// <param name="B"></param>
public sealed record RgbaColorDto(double R = 0, double G = 0, double B = 0);
/// <summary>
///
/// </summary>
public FontDto Font { get; init; } = new();
}

View File

@@ -1,57 +0,0 @@
namespace EnvelopeGenerator.Application.Common.Dto;
public sealed record PdfSignaturePrincipalDto(
string? CommonName,
string? Organization,
string? Country,
string? Full);
public sealed record PdfSignatureCertificateInfoDto(
PdfSignaturePrincipalDto IssuedTo,
PdfSignaturePrincipalDto IssuedBy,
string? SerialNumber,
DateTimeOffset? ValidFrom,
DateTimeOffset? ValidTo,
bool SelfSigned,
bool Expired,
bool NotYetValid,
bool IsSigner,
int? Version,
string? KeyAlgorithm,
string? SignatureAlgorithm,
string? Pem,
int? KeySizeBits);
public sealed record PdfSignatureCheckItemDto(
bool CoversWholeDoc,
string? Reason,
string? BusinessReason,
string? Location,
string? DeclaredName,
string? ContactInfo,
string? Email,
string? Phone,
string? DigestAlgorithm,
string? SignatureAlgorithm,
string? SignerName,
string? Issuer,
string? SerialNumber,
DateTimeOffset? ValidFrom,
DateTimeOffset? ValidTo,
string ExpiryCheckedAgainst,
IReadOnlyList<PdfSignatureCertificateInfoDto> Certificates,
bool Unchanged,
string? FieldName,
bool IsCryptographicallyValid,
string? SigningTime,
string? Filter,
string? SubFilter,
string? ApplicationName,
long? DocumentElementId,
string? OriginalDocumentSha256,
string? MessageDigestHex,
string? Error);
public sealed record PdfSignatureCheckResultDto(
bool Signed,
IReadOnlyList<PdfSignatureCheckItemDto> Signatures);

View File

@@ -1,10 +0,0 @@
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; }
}

View File

@@ -49,7 +49,7 @@ public sealed record Signature
public string? Position public string? Position
{ {
get => _position; get => _position;
init => _position = string.IsNullOrWhiteSpace(value) ? null : value; init => _position = string.IsNullOrWhiteSpace(value) ? value : null;
} }
/// <summary> /// <summary>

View File

@@ -1,39 +0,0 @@
namespace EnvelopeGenerator.Application.Common.Dto;
/// <summary>
/// Represents a user's UI preferences stored in the distributed cache (dbo.TBDD_CACHE).
/// </summary>
public sealed record UserPreferencesDto
{
/// <summary>
/// The active DevExpress theme name.
/// Allowed values: "blazing-berry", "fluent", "purple"
/// </summary>
public string ThemeName { get; set; } = "blazing-berry";
/// <summary>
/// Whether dark mode is active.
/// Default: false (light mode).
/// </summary>
public bool IsDarkMode { get; set; } = false;
/// <summary>
/// Persisted DxGrid layout states, keyed by a stable grid identifier.
/// Example keys: "sender.active-envelopes", "sender.completed-envelopes"
/// Values are opaque JSON strings produced by DxGrid.SaveLayoutAsync().
/// </summary>
public Dictionary<string, string> GridLayouts { get; set; } = new();
/// <summary>
/// The authenticated user's display name ("{GivenName} {Surname}").
/// Populated by the server on GET responses from JWT claims.
/// NOT persisted in cache — injected at read time by UserPreferencesController.
/// Ignored on PUT requests.
/// </summary>
public string? DisplayName { get; set; }
/// <summary>
///
/// </summary>
public static UserPreferencesDto Default { get; } = new UserPreferencesDto();
}

View File

@@ -1,27 +0,0 @@
namespace EnvelopeGenerator.Application.Common.Enums;
/// <summary>
///
/// </summary>
public enum PdfSignatureCertificationLevel
{
/// <summary>
/// All changes are allowed
/// </summary>
NoCertification = 0,
/// <summary>
/// No changes are allowed
/// </summary>
NoChangesAllowed,
/// <summary>
/// Allows users to fill in forms and sign the document.
/// </summary>
FillForms,
/// <summary>
/// Allows users to fill in forms, create, modify and delete annotations, and sign the document.
/// </summary>
FillFormsAndAnnotate
}

View File

@@ -1,66 +0,0 @@
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();
}
}

View File

@@ -1,12 +0,0 @@
namespace EnvelopeGenerator.Application.Common.Interfaces;
/// <summary>
///
/// </summary>
public interface IRequestContext
{
/// <summary>
///
/// </summary>
string? Origin { get; }
}

View File

@@ -1,29 +0,0 @@
using EnvelopeGenerator.Application.Common.Dto;
namespace EnvelopeGenerator.Application.Common.Interfaces.Services;
/// <summary>
/// Defines PDF digital-signing operations for the application boundary.
/// </summary>
public interface IPdfDigitalSignatureService
{
/// <summary>
/// Creates a digital signature for the provided PDF bytes and returns
/// a new signed PDF document.
/// </summary>
/// <param name="inputPdf">Source PDF content stream.</param>
/// <param name="outputPdf">Destination stream for signed PDF output.</param>
/// <param name="options">Optional signing options that map to PDF signature metadata fields.</param>
/// <param name="image">Optional image stream for signature appearance.</param>
/// <param name="cancellationToken">Cancellation token for cooperative cancellation.</param>
Task SignPdfAsync(Stream inputPdf, Stream outputPdf, PdfSignOptionsDto? options = null, Stream? image = null, CancellationToken cancellationToken = default);
/// <summary>
/// Checks whether the provided PDF contains signature field(s) and whether
/// those signatures are cryptographically valid.
/// </summary>
/// <param name="inputPdf">PDF content stream to inspect.</param>
/// <param name="cancellationToken">Cancellation token for cooperative cancellation.</param>
/// <returns>A structured result containing signature presence and per-signature details.</returns>
Task<PdfSignatureCheckResultDto> CheckPdfSignatureAsync(Stream inputPdf, CancellationToken cancellationToken = default);
}

View File

@@ -1,29 +0,0 @@
using EnvelopeGenerator.Application.Common.Dto;
namespace EnvelopeGenerator.Application.Common.Interfaces.Services;
/// <summary>
/// Manages per-user UI preferences (theme, dark mode, grid layouts).
/// Preferences are stored in the distributed cache (dbo.TBDD_CACHE) as JSON.
/// Cache key schema: "signflow.user-preferences:{username}"
/// </summary>
public interface IUserPreferencesService
{
/// <summary>
/// Retrieves preferences for the given user.
/// Always returns a valid object — never throws, never returns null.
/// Returns defaults if no entry exists in cache.
/// </summary>
/// <param name="id"></param>
/// <param name="ct">Cancellation token.</param>
Task<UserPreferencesDto> GetOrCreateAsync(string id, CancellationToken ct = default);
/// <summary>
/// Persists the given preferences for the user in the distributed cache.
/// Failures are non-fatal and silently ignored.
/// </summary>
/// <param name="id"></param>
/// <param name="preferences">The preferences to persist.</param>
/// <param name="ct">Cancellation token.</param>
Task SaveAsync(string id, UserPreferencesDto preferences, CancellationToken ct = default);
}

View File

@@ -1,98 +0,0 @@
using EnvelopeGenerator.Application.Common.Extensions;
using EnvelopeGenerator.Domain.Constants;
namespace EnvelopeGenerator.Application.Common.Interfaces.Services.Notification;
/// <summary>
///
/// </summary>
public record DocumentReceivedNotification : EnvelopeNotificationBase
{
/// <summary>
///
/// </summary>
public required string DocumentTitle { get; init; }
/// <summary>
///
/// </summary>
public required string NameReceiver { get; init; }
/// <summary>
///
/// </summary>
public required string NameSender { get; init; }
/// <summary>
/// [SIGNATURE_TYPE]
/// </summary>
public required bool IsReadAndConfirm { private get; init; }
/// <summary>
///
/// </summary>
public string SignatureType => IsReadAndConfirm ? "Signieren" : "Lesen und bestätigen";
/// <summary>
///
/// </summary>
public required bool AdvancedSignature { private get; init; }
/// <summary>
///
/// </summary>
public string DocumentProcess => AdvancedSignature ? " und elektronisch unterschreiben" : string.Empty;
/// <summary>
///
/// </summary>
public required Uri Origin { private get; init; }
/// <summary>
///
/// </summary>
public required string EnvelopeUuid { private get; init; }
/// <summary>
///
/// </summary>
public required string ReceiverSignature { private get; init; }
private Uri EnvelopeURL => new(Origin, $"Envelope/{(EnvelopeUuid, ReceiverSignature).ToEnvelopeKey()}");
/// <summary>
///
/// </summary>
public string LinkToDocument => EnvelopeURL.AbsoluteUri;
/// <summary>
///
/// </summary>
public string LinkToDocumentText
{
get
{
var urlText = EnvelopeURL.ToString();
return $"{urlText[..Math.Min(urlText.Length, 40)]}..";
}
}
/// <summary>
///
/// </summary>
public required string Message { get; init; }
/// <summary>
///
/// </summary>
public string NamePortal { get; set; } = "signFLOW Portal";
/// <summary>
///
/// </summary>
/// <returns></returns>
public override EmailTemplateType GetTemplateType()
{
return EmailTemplateType.DocumentReceived;
}
}

View File

@@ -1,59 +0,0 @@
using EnvelopeGenerator.Domain.Constants;
namespace EnvelopeGenerator.Application.Common.Interfaces.Services.Notification;
/// <summary>
///
/// </summary>
public record DocumentSignedNotification : EnvelopeNotificationBase
{
/// <summary>
///
/// </summary>
public required string DocumentTitle { get; init; }
/// <summary>
///
/// </summary>
public required string NameReceiver { get; init; }
/// <summary>
///
/// </summary>
public string NamePortal { get; init; } = "signFLOW Portal";
/// <summary>
///
/// </summary>
public required bool IsReadAndConfirm { private get; init; }
/// <summary>
///
/// </summary>
public string ReceiverAction => IsReadAndConfirm ? "bestätigt" : "unterschrieben";
/// <summary>
///
/// </summary>
public string FinalStatus => IsReadAndConfirm ? "Lesebestätigung" : "Signatur";
/// <summary>
///
/// </summary>
public string FinalAction => IsReadAndConfirm ? "Empfänger bestätigt" : "Vertragspartner unterzeichnet";
/// <summary>
///
/// </summary>
public override EmailTemplateType GetTemplateType() => EmailTemplateType.DocumentSigned;
}
/*
Umschlag abgeschlossen: '[DOCUMENT_TITLE]'
---
Guten Tag [NAME_RECEIVER],<br /> <br /> Der Vorgang <B><I>'[DOCUMENT_TITLE]'</I></B> wurde erfolgreich abgeschlossen.<br /> <br /> Sie erhalten das Dokument mit einem detaillierten Ergebnisbericht als Anhang zu dieser Email.<br /> <br /> Mit freundlichen Grüßen<br /> <br /> [NAME_PORTAL]
*/

View File

@@ -1,19 +0,0 @@
using EnvelopeGenerator.Domain.Constants;
namespace EnvelopeGenerator.Application.Common.Interfaces.Services.Notification;
/// <summary>
///
/// </summary>
public abstract record EnvelopeNotificationBase
{
/// <summary>
///
/// </summary>
public abstract EmailTemplateType GetTemplateType();
/// <summary>
///
/// </summary>
public IReadOnlyList<(string FileName, byte[] Content)>? Attachments { get; init; }
}

View File

@@ -1,18 +0,0 @@
namespace EnvelopeGenerator.Application.Common.Interfaces.Services.Notification;
/// <summary>
///
/// </summary>
public interface IEnvelopeNotificationService
{
/// <summary>
///
/// </summary>
/// <typeparam name="TEnvelopeNotificationBase"></typeparam>
/// <param name="receiverEmail"></param>
/// <param name="notification"></param>
/// <param name="cancel"></param>
/// <returns></returns>
public Task Notify<TEnvelopeNotificationBase>(string receiverEmail, TEnvelopeNotificationBase notification, CancellationToken cancel = default)
where TEnvelopeNotificationBase : EnvelopeNotificationBase;
}

View File

@@ -28,11 +28,8 @@ public class MappingProfile : Profile
CreateMap<DocReceiverElement, DocReceiverElementDto>(); CreateMap<DocReceiverElement, DocReceiverElementDto>();
CreateMap<DocumentStatus, DocumentStatusDto>(); CreateMap<DocumentStatus, DocumentStatusDto>();
CreateMap<EmailTemplate, EmailTemplateDto>(); CreateMap<EmailTemplate, EmailTemplateDto>();
CreateMap<Envelope, EnvelopeDto>() CreateMap<Envelope, EnvelopeDto>();
.ForMember(dest => dest.EnvelopeTypeTitle,
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>();

View File

@@ -1,21 +0,0 @@
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Domain.Entities;
using MediatR;
namespace EnvelopeGenerator.Application.Common.Notifications.EnvelopeDeleted;
public record EnvelopeDeletedNotification : INotification, ISendMailNotification
{
public required Envelope Envelope { get; init; }
public required EnvelopeReceiver EnvelopeReceiver { get; init; }
public required string Reason { get; init; }
public required string SenderReference { get; init; }
public EmailTemplateType TemplateType => EmailTemplateType.DocumentDeleted;
public string EmailAddress => EnvelopeReceiver.Receiver?.EmailAddress
?? throw new InvalidOperationException("Receiver email is missing for EnvelopeDeletedNotification.");
}

View File

@@ -1,42 +0,0 @@
using DigitalData.Core.Abstraction.Application.Repository;
using DigitalData.EmailProfilerDispatcher.Abstraction.Entities;
using EnvelopeGenerator.Application.Common.Configurations;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Domain.Entities;
using Microsoft.Extensions.Options;
namespace EnvelopeGenerator.Application.Common.Notifications.EnvelopeDeleted.Handlers;
public class SendDeletedMailHandler : SendMailHandler<EnvelopeDeletedNotification>
{
public SendDeletedMailHandler(
IRepository<EmailTemplate> tempRepo,
IRepository<EmailOut> emailOutRepo,
IOptions<MailParams> mailParamsOptions,
IOptions<DispatcherParams> dispatcherParamsOptions)
: base(tempRepo, emailOutRepo, mailParamsOptions, dispatcherParamsOptions)
{
}
protected override void ConfigureEmailOut(EnvelopeDeletedNotification notification, EmailOut emailOut)
{
emailOut.ReferenceString = notification.EmailAddress;
emailOut.ReferenceId = notification.EnvelopeReceiver.ReceiverId;
emailOut.WfId = (int)EnvelopeStatus.MessageDeletionSent;
}
protected override Dictionary<string, string> CreatePlaceHolders(EnvelopeDeletedNotification notification)
{
var senderName = notification.Envelope.User?.Email;
if (string.IsNullOrWhiteSpace(senderName))
senderName = notification.SenderReference;
return new Dictionary<string, string>
{
["[NAME_RECEIVER]"] = notification.EnvelopeReceiver.Name ?? string.Empty,
["[NAME_SENDER]"] = senderName ?? string.Empty,
["[DOCUMENT_TITLE]"] = notification.Envelope.Title ?? string.Empty,
["[REASON]"] = notification.Reason
};
}
}

View File

@@ -2,7 +2,6 @@
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;
@@ -97,10 +96,6 @@ public abstract class SendMailHandler<TNotification> : INotificationHandler<TNot
/// <exception cref="NotImplementedException"></exception> /// <exception cref="NotImplementedException"></exception>
public virtual async Task Handle(TNotification notification, CancellationToken cancel) public virtual async Task Handle(TNotification notification, CancellationToken cancel)
{ {
// do not send email if the envelope is deleted and the status is less than or equal to EnvelopeSaved
if (notification is EnvelopeDeletedNotification delNot && delNot.Envelope.Status <= EnvelopeStatus.EnvelopeSaved)
return;
var placeHolders = CreatePlaceHolders(notification); var placeHolders = CreatePlaceHolders(notification);
var temp = await TempRepo var temp = await TempRepo

View File

@@ -10,8 +10,6 @@ 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;
@@ -49,10 +47,8 @@ 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
@@ -64,31 +60,23 @@ public static class DependencyInjection
{ {
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>();
// 3. DocStatusBehavior - Creates document status // 2. DocStatusBehavior - Creates document status (executes third)
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, DocStatusBehavior>(); cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, DocStatusBehavior>();
// 4. HistoryBehavior - Records history // 3. HistoryBehavior - Records history (executes fourth)
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, HistoryBehavior>(); cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, HistoryBehavior>();
// 5. SendSignedMailBehavior - Sends notification email (executes LAST) // 4. SendSignedMailBehavior - Sends notification email (executes LAST, only if all previous succeed)
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, SendSignedMailBehavior>(); cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, SendSignedMailBehavior>();
}); });
services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
return services; return services;
} }
} }

View File

@@ -43,11 +43,7 @@ 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 var er = await _erRepo.Query.Where(request, notnull: true).SingleOrDefaultAsync(cancellationToken)
.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));

View File

@@ -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.EnvelopeReceiver.EnvelopeId) .Where(e => e.Document.EnvelopeId == request.Envelope.Id)
.Where(e => e.ReceiverId == request.EnvelopeReceiver.ReceiverId) .Where(e => e.ReceiverId == request.Receiver.Id)
.ToListAsync(cancel); .ToListAsync(cancel);
foreach (var element in elements) foreach (var element in elements)

View File

@@ -14,15 +14,6 @@ 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>
@@ -56,10 +47,7 @@ public class ReadDocReceiverElementQueryHandler : IRequestHandler<ReadDocReceive
{ {
var q = _repository.Query; var q = _repository.Query;
if (request.Id is not null) if(request.Envelope.Id is int envelopeId)
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)
@@ -71,9 +59,6 @@ public class ReadDocReceiverElementQueryHandler : IRequestHandler<ReadDocReceive
if (request.Receiver.Signature is string signature) if (request.Receiver.Signature is string signature)
q = q.Where(e => e.Receiver.Signature == signature); q = q.Where(e => e.Receiver.Signature == signature);
if( request.IncludeDocument)
q = q.Include(e => e.Document);
var elements = await q.ToListAsync(cancellationToken); var elements = await q.ToListAsync(cancellationToken);
return _mapper.Map<IEnumerable<DocReceiverElementDto>>(elements); return _mapper.Map<IEnumerable<DocReceiverElementDto>>(elements);

View File

@@ -1,102 +0,0 @@
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,
})
};
}
}

View File

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

View File

@@ -13,8 +13,7 @@ namespace EnvelopeGenerator.Application.Documents.Queries;
/// </summary> /// </summary>
/// <param name="Id">The unique identifier of the document. Optional.</param> /// <param name="Id">The unique identifier of the document. Optional.</param>
/// <param name="EnvelopeId">The identifier of the envelope associated with the document. Optional.</param> /// <param name="EnvelopeId">The identifier of the envelope associated with the document. Optional.</param>
/// <param name="UserId">The unique identifier of the sending user. Optional.</param> public record ReadDocumentQuery(int? Id = null, int? EnvelopeId = null) : IRequest<DocumentDto>
public record ReadDocumentQuery(int? Id = null, int? EnvelopeId = null, int? UserId = null) : IRequest<DocumentDto>
{ {
} }
@@ -54,10 +53,7 @@ public class ReadDocumentQueryHandler : IRequestHandler<ReadDocumentQuery, Docum
/// </exception> /// </exception>
public async Task<DocumentDto> Handle(ReadDocumentQuery query, CancellationToken cancel) public async Task<DocumentDto> Handle(ReadDocumentQuery query, CancellationToken cancel)
{ {
var docQuery = _repo.Query.Include(doc => doc.Elements).ThenInclude(e => e.Annotations).AsNoTracking(); var docQuery = _repo.Query.Include(doc => doc.Elements).ThenInclude(e => e.Annotations);
if (query.UserId is int userId)
docQuery = docQuery.Where(doc => doc.Envelope.UserId == userId);
if (query.Id is not null) if (query.Id is not null)
{ {

View File

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

View File

@@ -4,7 +4,6 @@
<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>
@@ -20,8 +19,6 @@
<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" />

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
using EnvelopeGenerator.Application.Common.Dto; using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Dto.Receiver; using EnvelopeGenerator.Application.Common.Dto.Receiver;
namespace EnvelopeGenerator.Application.EnvelopeReceivers.Commands.CreateEnvelopeReceiver; namespace EnvelopeGenerator.Application.EnvelopeReceivers.Commands;
/// <summary> /// <summary>
/// ///

View File

@@ -1,84 +0,0 @@
using DigitalData.Core.Abstraction.Application.Repository;
using DigitalData.Core.Exceptions;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Domain.Entities;
using EnvelopeGenerator.Domain.Exceptions;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace EnvelopeGenerator.Application.EnvelopeReceivers.Commands;
/// <summary>
///
/// </summary>
public record RemoveEnvelopeReceiverCommand : IRequest
{
/// <summary>
///
/// </summary>
public required int EnvelopeId { get; init; }
/// <summary>
///
/// </summary>
public required int ReceiverId { get; init; }
internal int? UserId { get; private set; }
/// <summary>
///
/// </summary>
/// <param name="userId"></param>
/// <returns></returns>
public RemoveEnvelopeReceiverCommand Authenticated(int userId)
{
UserId = userId;
return this;
}
}
/// <summary>
///
/// </summary>
/// <param name="Repo"></param>
public class RemoveEnvelopeReceiverCommandHandler(
IRepository<EnvelopeReceiver> Repo,
IRepository<DocReceiverElement> ElementRepo,
IRepository<ElementAnnotation> AnnotRepo) : IRequestHandler<RemoveEnvelopeReceiverCommand>
{
/// <summary>
///
/// </summary>
/// <param name="request"></param>
/// <param name="cancel"></param>
/// <returns></returns>
/// <exception cref="NotImplementedException"></exception>
public async Task Handle(RemoveEnvelopeReceiverCommand request, CancellationToken cancel)
{
var er = await Repo.Query
.Where(er => er.EnvelopeId == request.EnvelopeId && er.ReceiverId == request.ReceiverId)
.Include(er => er.Envelope)
.SingleOrDefaultAsync(cancel)
?? throw new NotFoundException();
if (er.Envelope is null)
throw new InvalidOperationException($"In {nameof(RemoveEnvelopeReceiverCommandHandler)} EnvelopeReceiver.Envelope is null." +
$"This should not happen." +
$"EnvelopeId: {request.EnvelopeId}, ReceiverId: {request.ReceiverId}");
if (request.UserId is int userId && er.Envelope.UserId != userId)
throw new AuthorizationException("Cannot remove receiver from envelope that you do not own.");
if (er.Envelope.Status > EnvelopeStatus.EnvelopeSaved)
throw new ForbiddenException("Cannot remove receiver from envelope that is already processed.");
// 1. Delete all ElementAnnotations for this EnvelopeReceiver
await AnnotRepo.DeleteAsync(ann => ann.Element.Document.EnvelopeId == request.EnvelopeId && ann.Element.ReceiverId == request.ReceiverId, cancel);
// 2. Delete all DocReceiverElements for this EnvelopeReceiver
await ElementRepo.DeleteAsync(el => el.Document.EnvelopeId == request.EnvelopeId && el.ReceiverId == request.ReceiverId, cancel);
// 3. Delete the EnvelopeReceiver
await Repo.DeleteAsync(er => er.EnvelopeId == request.EnvelopeId && er.ReceiverId == request.ReceiverId, cancel);
}
}

View File

@@ -16,9 +16,5 @@ 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));
} }
} }

View File

@@ -21,24 +21,7 @@ namespace EnvelopeGenerator.Application.EnvelopeReceivers.Queries;
/// </remarks> /// </remarks>
public record ReadEnvelopeReceiverSecretQuery public record ReadEnvelopeReceiverSecretQuery
: EnvelopeReceiverQueryBase<ReadEnvelopeQuery, ReadReceiverQuery>, : EnvelopeReceiverQueryBase<ReadEnvelopeQuery, ReadReceiverQuery>,
IRequest<EnvelopeReceiverSecretDto?> IRequest<EnvelopeReceiverSecretDto?>;
{
/// <summary>
///
/// </summary>
public ReadEnvelopeReceiverSecretQuery() { }
/// <summary>
///
/// </summary>
/// <param name="envelopeId"></param>
/// <param name="receiverId"></param>
public ReadEnvelopeReceiverSecretQuery(int envelopeId, int receiverId)
{
Envelope.Id = envelopeId;
Receiver.Id = receiverId;
}
}
/// <summary> /// <summary>
/// Extension methods for dispatching <see cref="ReadEnvelopeReceiverSecretQuery"/> via <see cref="IMediator"/>. /// Extension methods for dispatching <see cref="ReadEnvelopeReceiverSecretQuery"/> via <see cref="IMediator"/>.

View File

@@ -1,98 +0,0 @@
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);
}
}

View File

@@ -1,5 +1,6 @@
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;
@@ -17,14 +18,17 @@ 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>
public ReadEnvelopeTypesQueryHandler(IRepository<EnvelopeType> repository) /// <param name="mapper"></param>
public ReadEnvelopeTypesQueryHandler(IRepository<EnvelopeType> repository, IMapper mapper)
{ {
_repository = repository; _repository = repository;
_mapper = mapper;
} }
/// <summary> /// <summary>
@@ -35,28 +39,7 @@ 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)
{ {
// TODO: Temporary manual projection to avoid null-materialization crashes from legacy nullable DB columns in EnvelopeType. var types = await _repository.Query.ToListAsync(cancellationToken);
// Revisit and switch to AutoMapper query projection (ProjectTo) after schema nullability and mapping contracts are stabilized. return _mapper.Map<IEnumerable<EnvelopeTypeDto>>(types);
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);
} }
} }

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