Compare commits

..

6 Commits

Author SHA1 Message Date
732fe92952 Update DevExpress migration docs and plan
Added `ZoomLevelChanged` to available events in `DEVEXPRESS_V25_LIMITATIONS.md` and clarified missing events. Created a detailed migration plan in `MIGRATION_PLAN.md` for transitioning from PDF.js to DevExpress DxPdfViewer v25.2.3, including a hybrid approach, testing checklist, and rollback plan. Summarized research findings and API verification in `SESSION_SUMMARY.md`, highlighting limitations, risks, and recommendations for the migration.
2026-06-30 18:32:25 +02:00
99fbb33f1c Migrate PDF.js to DevExpress DxPdfViewer
Transitioned PDF rendering in `EnvelopeReceiverPage.razor`
from `PDF.js` to `DevExpress DxPdfViewer`. Updated code
and documentation to reflect the verified API of
`DevExpress.Blazor.PdfViewer` v25.2.3, addressing its
limitations (e.g., lack of `GoToPageAsync`, `PageNumberChanged`).

Implemented `CustomizeToolbar` for navigation/zoom controls
and manual state tracking for `_currentPage` and `_viewerZoomLevel`.
Replaced JavaScript interop for page count with the `PageCount`
property. Retained the custom thumbnail sidebar due to API
constraints.

Added temporary debug tools for DOM analysis and navigation
testing. Updated `TESTING_CHECKLIST.md` and added
`DEVEXPRESS_V25_LIMITATIONS.md` to document the new strategy,
API limitations, and testing scenarios. Cross-page signature
navigation implemented with state updates, though visible
page changes remain manual.

Prepared for future improvements while ensuring functional
migration to `DxPdfViewer`.
2026-06-30 16:12:05 +02:00
a10ee590c9 Remove unsupported ZoomLevelChanged from DxPdfViewer
Removed the `ZoomLevelChanged` parameter from the `DxPdfViewer`
component in `EnvelopeReceiverPage.razor` due to lack of support
in the installed `DevExpress.Blazor.PdfViewer` package version
`25.2.3`. This prevents runtime exceptions caused by the use of
an unsupported parameter.

Deleted the `OnViewerZoomLevelChanged` method, as it is no longer
needed. Updated `RECEIVER_PDF_VIEWER_CONTEXT.md` to reflect the
limitations of the installed package and adjusted the recovery
plan to bind zoom state directly to `DxPdfViewer.ZoomLevel`.

Simplified zoom handling by removing custom JavaScript logic for
`ctrl+wheel` zoom and retaining the overlay redraw pipeline.
Confirmed that the built-in DevExpress zoom UI now works without
runtime errors, and custom zoom duplication has been eliminated.
2026-06-29 14:09:14 +02:00
03367ebc4a Integrate DxPdfViewer and remove custom zoom controls
Replaced custom zoom controls in `EnvelopeReceiverPage.razor` with the built-in zoom functionality of the `DevExpress.Blazor.PdfViewer` component (`DxPdfViewer`).

- Removed custom zoom buttons, slider, and JavaScript zoom logic.
- Introduced `_viewerZoomLevel` to align with `DxPdfViewer.ZoomLevel`.
- Synchronized zoom state using `ZoomLevelChanged` to update `_currentZoom`.
- Updated overlay redraw logic to react to viewer zoom changes.
- Modified `FitToWidth` and `SetZoom` methods to work with `DxPdfViewer`.
- Updated documentation to reflect integration decisions and findings.

These changes simplify zoom handling, reduce UI redundancy, and ensure proper synchronization between the viewer and overlay logic.
2026-06-29 14:08:51 +02:00
1ac7188466 Update migration plan for PDF.js to DxPdfViewer
Revised `RECEIVER_PDF_VIEWER_CONTEXT.md` to reflect the current status and challenges of migrating from `PDF.js` to `DxPdfViewer`. Replaced the generic migration plan with a detailed post-migration status and recovery plan.

Added a breakdown of missing or unreliable features, including page navigation, zoom controls, overlay positioning, signature navigation, thumbnail behavior, and single-page mode. Identified the root cause as the difference between the old `PDF.js`-based platform and the new `DxPdfViewer` component-driven model.

Outlined an incremental recovery plan to restore features step-by-step, starting with verifying the `DxPdfViewer` API surface and restoring zoom functionality. Emphasized preserving stable workflow components and avoiding unnecessary refactoring.

Provided clear next steps and guidance to rebuild the viewer behavior bridge while maintaining the existing signing workflow.
2026-06-29 12:03:14 +02:00
db593cb46a Replace PDF.js with DevExpress DxPdfViewer
This commit replaces the existing PDF.js-based viewer with the DevExpress DxPdfViewer component, introducing significant improvements to the UI, state management, and signature handling.

Key changes:
- Integrated DevExpress.Blazor.PdfViewer and removed PDF.js dependencies.
- Updated HTML structure to use `DxPdfViewer` and new overlay layers.
- Refactored zoom and navigation logic to use DevExpress APIs.
- Overhauled signature button rendering and positioning logic.
- Added dynamic scaling for applied signatures based on zoom level.
- Introduced `requestOverlayRefresh` for efficient overlay updates.
- Added new CSS styles for the DevExpress viewer and overlays.
- Refactored `pdf-viewer.js` to remove legacy PDF.js logic.
- Improved performance with `requestAnimationFrame` and optimized event handling.
- Added a `dispose` method for proper cleanup of resources.
- Enhanced error handling and accessibility for signature buttons.
- Removed redundant code and improved overall maintainability.
2026-06-29 11:27:06 +02:00
295 changed files with 29888 additions and 11471 deletions

5
.gitignore vendored
View File

@@ -365,8 +365,3 @@ FodyWeavers.xsd
/EnvelopeGenerator.GeneratorAPI/ClientApp/envelope-generator-ui/.vscode
/EnvelopeGenerator.Tests.Application/Services/BugFixTests.cs
/EnvelopeGenerator.Tests.Application/annotations.json
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/TekH - SoftHSM Test.md
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/tekh_softHSM_test.md
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/publish-output
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/tekh_softHSM_test.md
/ignore/EnvelopeGenerator.Form

139
AGENTS.md
View File

@@ -4,90 +4,35 @@
- **`COPILOT_CONTEXT.md`** - Architecture, coordinate systems, migration status
- **`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.
---
### 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.
---
## Active Architecture (Post-Migration)
**Frontend:** Blazor Auto (Server+WASM hybrid)
- **EnvelopeGenerator.Server** (Server): `@rendermode InteractiveServer` - PDF viewers requiring DevExpress backend
- **EnvelopeGenerator.Server.Client** (WASM): `@rendermode InteractiveWebAssembly` - Login, dashboards, business logic
- **WebUI** (Server): `@rendermode InteractiveServer` - PDF viewers requiring DevExpress backend
- **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
- `EnvelopeGenerator.ReceiverUI` - Pure WASM (migrated to Server)
- `EnvelopeGenerator.Web` - Razor Pages (replaced by Server)
- `EnvelopeGenerator.ReceiverUI` - Pure WASM (migrated to WebUI)
- `EnvelopeGenerator.Web` - Razor Pages (replaced by WebUI)
- **VB.NET projects** (`Form`, `Service`, `BBTests`) - Legacy, read-only for reference
## Development Commands
### Run Active Host
### Run Both Projects (Required)
```powershell
# Terminal - Blazor + Web API host
cd EnvelopeGenerator.Server\EnvelopeGenerator.Server
# Terminal 1 - API Backend
cd EnvelopeGenerator.API
dotnet run
# Terminal 2 - Blazor Frontend
cd EnvelopeGenerator.WebUI\EnvelopeGenerator.WebUI
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
```powershell
@@ -100,26 +45,24 @@ dotnet build EnvelopeGenerator.sln
EnvelopeGenerator.Domain/ # Entities (Envelope, Receiver, Document, etc.)
EnvelopeGenerator.Application/ # MediatR CQRS (Commands, Queries, Handlers)
EnvelopeGenerator.Infrastructure/ # EF Core, SQL executors, repositories
EnvelopeGenerator.Server/
EnvelopeGenerator.Server/ # Server-side Blazor + API controllers
├─ Components/Pages/ # @rendermode InteractiveServer
├─ Controllers/ # Active Web API endpoints
EnvelopeGenerator.Server.Client/ # Client-side WASM components
├─ Pages/ # @rendermode InteractiveWebAssembly
├─ Services/ # HTTP API clients
├─ Models/ # DTOs
EnvelopeGenerator.API/ # Controllers, endpoints
EnvelopeGenerator.WebUI/ # Server-side Blazor components
├─ Components/Pages/ # @rendermode InteractiveServer
EnvelopeGenerator.WebUI.Client/ # Client-side WASM components
├─ Pages/ # @rendermode InteractiveWebAssembly
├─ Services/ # HTTP API clients
├─ Models/ # DTOs
```
## Route Structure (Critical)
| Route | File Location | Render Mode | Purpose |
|-------|--------------|-------------|---------|
| `/` | `Server.Client/Pages/Index.razor` | WASM | Landing page |
| `/sender/login` | `Server.Client/Pages/LoginSenderPage.razor` | WASM | Sender auth |
| `/sender` | `Server/Components/Pages/EnvelopeSenderPage.razor` | **Server** | Sender dashboard |
| `/sender/editor` | `Server/Components/Pages/EnvelopeSenderEditorPage.razor` | **Server** | Envelope editor + PDF viewer |
| `/envelope/login/{key}` | `Server.Client/Pages/LoginReceiverPage.razor` | WASM | Receiver auth |
| `/envelope/{key}` | `Server/Components/Pages/EnvelopeReceiverPage.razor` | **Server** | PDF viewer + signing |
| `/` | `WebUI.Client/Pages/Index.razor` | WASM | Landing page |
| `/sender/login` | `WebUI.Client/Pages/LoginSenderPage.razor` | WASM | Sender auth |
| `/sender` | `WebUI.Client/Pages/EnvelopeSenderPage.razor` | WASM | Sender dashboard |
| `/envelope/login/{key}` | `WebUI.Client/Pages/LoginReceiverPage.razor` | WASM | Receiver auth |
| `/envelope/{key}` | `WebUI/Components/Pages/EnvelopeReceiverPage.razor` | **Server** | PDF viewer + signing |
**Rule:** PDF viewers MUST use `@rendermode InteractiveServer` (DevExpress backend requirement). Everything else uses WASM.
@@ -152,20 +95,22 @@ float inches = (pixelX / canvasWidth) * pageWidthInches;
- **Use case:** External API consumers
- **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
POST /api/Envelope/{id}/document # Upload PDF
POST /api/Envelope/{id}/receivers # Add receiver
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.
## Status Color Coding
Form app uses DevExpress `CustomDrawCell`. EnvelopeGenerator.Server needs CSS:
Form app uses DevExpress `CustomDrawCell`. WebUI needs CSS:
```css
.envelope-row.status-partly-signed { background-color: #81C784; } /* GREEN_300 */
@@ -178,10 +123,10 @@ Form app uses DevExpress `CustomDrawCell`. EnvelopeGenerator.Server needs CSS:
## Configuration
### YARP Proxy (`EnvelopeGenerator.Server/yarp.json`)
### YARP Proxy (`WebUI/yarp.json`)
Routes `/api/*`, `/swagger/*`, `/openapi/*`, `/scalar/*` → `https://localhost:8088`
### PDF.js Settings (`EnvelopeGenerator.Server/wwwroot/appsettings.json`)
### PDF.js Settings (`WebUI/wwwroot/appsettings.json`)
```json
{
"PdfViewerOptions": {
@@ -193,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
- `AllowedOrigins` - CORS (includes `http://localhost:5131`, `http://localhost:7192`)
- `Cache:SignatureCacheExpiration` - Signature persistence timeout
@@ -231,15 +176,16 @@ Routes `/api/*`, `/swagger/*`, `/openapi/*`, `/scalar/*` → `https://localhost:
**No automated tests exist yet.**
Manual testing workflow:
1. Start EnvelopeGenerator.Server (`dotnet run` in `EnvelopeGenerator.Server\EnvelopeGenerator.Server`)
2. Navigate to `https://localhost:5131` (or check console output for port)
3. Test sender login at `/sender/login`
4. Test receiver flow at `/envelope/login/{envelopeKey}`
1. Start API (`dotnet run` in `EnvelopeGenerator.API`)
2. Start WebUI (`dotnet run` in `EnvelopeGenerator.WebUI\EnvelopeGenerator.WebUI`)
3. Navigate to `https://localhost:5131` (or check console output for port)
4. Test sender login at `/sender/login`
5. Test receiver flow at `/envelope/login/{envelopeKey}`
## Database
**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)
- Stored procedures: `PRSIG_*` prefix
@@ -299,7 +245,7 @@ Each envelope maintains independent authentication state.
**CDN:**
- 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
- `SkiaSharp.*` 3.119.1 (WASM rendering)
@@ -313,4 +259,5 @@ Each envelope maintains independent authentication state.
None required. All config in `appsettings.json`.
**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.Infrastructure` — EF Core and infrastructure services
- `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
- `EnvelopeGenerator.Service`
@@ -121,7 +121,7 @@ Current controller set includes:
- `SignatureController`
- `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.
---
@@ -185,11 +185,79 @@ For signature placeholders, the service:
Current receiver viewer characteristics:
- route: `/envelope/{EnvelopeKey}`
- render mode: `InteractiveServer`
- PDF rendering: `PDF.js`
- PDF rendering: **Migration in progress from `PDF.js` to `DxPdfViewer`**
- toolbar: page navigation, zoom, thumbnail toggle, signature navigation, signature reset
- signature popup: `DxPopup`
- thumbnail sidebar: resizable and stored in `localStorage`
### ⚠️ CRITICAL: DevExpress DxPdfViewer Control Requirements
**Verified API for installed `DevExpress.Blazor.PdfViewer` v25.2.3:**
| Property | Access | Notes |
|----------|--------|-------|
| `DocumentContent` | `[Parameter]` GET/SET | Feed PDF as `byte[]` |
| `ZoomLevel` | `[Parameter]` GET/SET | **Factor** (not percentage): `1.5` = 150% |
| `IsSinglePagePreview` | `[Parameter]` GET/SET | Single page mode |
| `CssClass` | `[Parameter]` GET/SET | CSS class |
| `DocumentName` | `[Parameter]` GET/SET | Download filename |
| `SizeMode` | `[Parameter]` GET/SET | `Small` / `Medium` / `Large` |
| `PageCount` | Read-only GET | Total pages — **no JS call needed** |
| `ActivePageIndex` | Read-only GET | Current page (0-based) — **cannot SET** |
| `CustomizeToolbar` | Event | Only available toolbar event |
**Does NOT exist in v25.2.3 — do NOT use:**
- `GoToPageAsync()` ❌
- `GoToNextPageAsync()` ❌
- `ZoomAsync()` ❌
- `PageNumberChanged` event ❌
- `ZoomLevelChanged` event ❌
- `ToolbarVisible` property ❌
**Correct approach:**
```razor
<DxPdfViewer @ref="_pdfViewer"
DocumentContent="@_pdfDocumentContent"
ZoomLevel="@_viewerZoomLevel"
IsSinglePagePreview="true"
CustomizeToolbar="OnCustomizeToolbar" />
```
```csharp
// ZoomLevel: always divide by 100 (factor, not percentage)
_viewerZoomLevel = _currentZoom / 100d; // 150 -> 1.5
// PageCount: read directly, no JS needed
_totalPages = _pdfViewer.PageCount;
// Page navigation: only via CustomizeToolbar buttons
protected void OnCustomizeToolbar(ToolbarModel toolbarModel)
{
toolbarModel.AllItems.Clear();
var nextButton = new ToolbarItem
{
IconCssClass = "dx-icon-chevronnext",
Enabled = _currentPage < _totalPages,
Click = async (args) =>
{
_currentPage++;
_viewerZoomLevel = _currentZoom / 100d;
await InvokeAsync(StateHasChanged);
await RenderSignatureButtonsAsync();
}
};
toolbarModel.AllItems.Add(nextButton);
}
```
**JavaScript role after migration:**
- Overlay geometry calculations only
- Thumbnail rendering via PDF.js helper
- Custom UI interactions (sidebar resize, signature canvas)
- **NOT** for controlling DxPdfViewer page or zoom
See `DEVEXPRESS_V25_LIMITATIONS.md` for complete verified API reference.
### JS Assets
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/js/pdf-viewer.js`
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/js/receiver-signature.js`

300
DEBUG_NOTES.md Normal file
View File

@@ -0,0 +1,300 @@
# Debug Tools for DevExpress DxPdfViewer Integration
## Purpose
This document describes temporary debug tools added to diagnose DevExpress DOM structure and page navigation issues.
## IMPORTANT: TEMPORARY DEBUG CODE
**These debug tools are TEMPORARY and should be REMOVED after resolving the page navigation issue.**
---
## Debug Tools Added
### 1. Debug UI Button (Toolbar)
**Location:** `EnvelopeReceiverPage.razor` - Toolbar Section
**Visual:** Orange button with "?" icon in the PDF viewer toolbar
**What it does:**
- Opens a floating overlay panel showing DevExpress DOM analysis
- Displays all input elements found in DxPdfViewer
- Shows which CSS selectors successfully find the page input
- Provides a "Test: Go to Page 2" button for live testing
**Code Location:**
```razor
@* DEBUG: DevExpress DOM Inspector *@
<div class="pdf-toolbar__section">
<button class="pdf-toolbar__btn" @onclick="ShowDebugUI" ...>
```
**C# Method:**
```csharp
async Task ShowDebugUI()
{
await JSRuntime.InvokeVoidAsync("dxPdfViewerShowDebugUI");
}
```
---
### 2. JavaScript Debug Functions
**Location:** `pdf-viewer.js`
**Functions Added:**
#### `window.dxPdfViewerDebugDOM()`
- Console-based debug function
- Logs detailed DOM analysis to browser console
- Returns analysis object for programmatic inspection
#### `window.dxPdfViewerShowDebugUI()`
- HTML overlay-based debug function
- Creates visual debug panel without console interaction
- No security warnings (no need to paste code)
---
## How to Use
### Step 1: Run Application
```powershell
dotnet run --project EnvelopeGenerator.Server/EnvelopeGenerator.Server
```
### Step 2: Open Receiver Page
Navigate to: `https://localhost:8088/envelope/{EnvelopeKey}`
### Step 3: Click Debug Button
- Look for the **orange "?" button** in the PDF toolbar (left side, after thumbnails toggle)
- Click it to open the debug overlay
### Step 4: Review Debug Information
The overlay shows:
- **Total Inputs**: Number of input elements found
- **Input Elements**: Details of each input (type, class, ID, value)
- **Selector Tests**: Which CSS selectors work (✓) and which don't (✗)
- **Toolbar**: Whether toolbar element was found
- **DxWidget**: Whether DevExpress widget element was found
### Step 5: Test Page Navigation
Click the **"Test: Go to Page 2"** button in the overlay
### Step 6: Report Results
**Copy the following information:**
1. **Total Inputs**: X
2. **Input Details**: (type, className, id for each input)
3. **Selector Test Results**: (which selectors show ✓ FOUND)
4. **Test Result**: Did PDF actually navigate to page 2? (Yes/No)
5. **Console Messages**: Any errors or warnings in F12 console
---
## What to Look For
### ✓ Success Indicators
- At least one selector shows **✓ FOUND**
- "Test: Go to Page 2" button actually changes PDF page
- Console shows: `✓ Found page input with selector: "..."`
### ✗ Problem Indicators
- All selectors show **✗ NOT FOUND**
- "Test: Go to Page 2" does nothing
- Console shows: `✗ Page input not found`
---
## After Diagnosis
Once the correct selector is identified:
### 1. Update `window.dxPdfViewerGoToPage()`
Update the `selectors` array in `pdf-viewer.js` to prioritize the working selector:
```javascript
const selectors = [
'WORKING_SELECTOR_HERE', // ✓ Move this to top
'input[type="number"]',
// ... rest
];
```
### 2. Remove Debug Code
**Files to clean up:**
#### `EnvelopeReceiverPage.razor`
Remove:
```razor
@* DEBUG: DevExpress DOM Inspector *@
<div class="pdf-toolbar__section">
<button class="pdf-toolbar__btn" @onclick="ShowDebugUI" ...>
</button>
</div>
```
Remove C# method:
```csharp
async Task ShowDebugUI() { ... }
```
#### `pdf-viewer.js`
Remove:
```javascript
// ⚠ AUTO-DEBUG: Display results in HTML overlay
window.dxPdfViewerShowDebugUI = function() { ... }
```
Keep:
- `window.dxPdfViewerDebugDOM()` - can be useful for future debugging (optional)
- `window.dxPdfViewerGoToPage()` - this is permanent (after fixing selector)
---
## Troubleshooting
### Debug UI doesn't open
- Check browser console (F12) for JavaScript errors
- Ensure `pdf-viewer.js` is loaded
- Verify DxPdfViewer has finished rendering
### "Page input not found" error
- DevExpress may not have rendered toolbar yet
- Try waiting 2-3 seconds after page load
- Check if DxPdfViewer is visible on screen
### Selector works but page doesn't change
- DevExpress may require different event sequence
- Try adding more events (focus, click, etc.)
- May need to find DevExpress client API instead
---
## SOLUTION: CustomizeToolbar + Manual State Tracking
**Identified root cause:**
- DevExpress v25.2.3 has no event support
- `PageNumberChanged` event does not exist
- `ZoomLevelChanged` event does not exist
- `ToolbarVisible` property does not exist
- `GoToPageAsync()` method does not exist
- Only `CustomizeToolbar` event is available
**Verified working API (v25.2.3):**
- `DocumentContent` byte[] – for feeding PDF ✓
- `ZoomLevel` double – zoom factor (1.5 = 150%) ✓
- `IsSinglePagePreview` bool – single page mode ✓
- `PageCount` int (GET only) – **replaces JS call** ✓
- `ActivePageIndex` int (GET only) – current page index ✓
- `CssClass`, `DocumentName`, `SizeMode` ✓
**Implemented strategy:**
- Create custom navigation/zoom buttons via `CustomizeToolbar`
- Manual state tracking with `_currentPage`, `_currentZoom`, `_viewerZoomLevel`
- Manually trigger overlay refresh after button clicks
- Replace JS getTotalPages() call with `_totalPages = _pdfViewer.PageCount`
**Correct code example:**
```csharp
protected void OnCustomizeToolbar(ToolbarModel toolbarModel)
{
toolbarModel.AllItems.Clear();
var prevButton = new ToolbarItem
{
Text = "Previous",
IconCssClass = "dx-icon-chevronprev",
Enabled = _currentPage > 1,
Click = async (args) =>
{
if (_currentPage > 1)
{
_currentPage--;
_viewerZoomLevel = _currentZoom / 100d; // 150 -> 1.5
await InvokeAsync(StateHasChanged);
await RenderSignatureButtonsAsync();
}
}
};
var nextButton = new ToolbarItem
{
Text = "Next",
IconCssClass = "dx-icon-chevronnext",
Enabled = _currentPage < _totalPages,
Click = async (args) =>
{
if (_currentPage < _totalPages)
{
_currentPage++;
_viewerZoomLevel = _currentZoom / 100d; // 150 -> 1.5
await InvokeAsync(StateHasChanged);
await RenderSignatureButtonsAsync();
}
}
};
toolbarModel.AllItems.Add(prevButton);
toolbarModel.AllItems.Add(nextButton);
}
```
**PageCount usage (instead of JS):**
```csharp
// In OnAfterRenderAsync
if (_pdfViewer is not null && _pdfViewer.PageCount > 0)
{
_totalPages = _pdfViewer.PageCount; // JS getTotalPages() no longer needed
_pdfLoaded = true;
await InvokeAsync(StateHasChanged);
}
```
**Known limitations:**
1. If user scrolls PDF, C# receives no notification, overlays may desync
2. Thumbnail navigation only updates state, cannot move viewer
3. Cross-page signature navigation limited without programmatic page switching
**See:** `DEVEXPRESS_V25_LIMITATIONS.md` – complete verified API reference
---
## Expected Timeline
1. ✓ **Day 1**: Add debug tools (DONE)
2. ✓ **Day 1**: Collect DOM analysis data (DONE)
3. ✓ **Day 1**: Identify root cause (DONE - v25.2.3 has no events)
4. ✓ **Day 1**: Define workaround strategy (DONE - Custom toolbar with manual tracking)
5. ✓ **Day 1**: Implement workaround (DONE)
6. ⚠ **Day 2**: Test and document limitations
7. ⚠ **Day 2**: Consider DevExpress upgrade or accept limitations
---
## Related Files
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeReceiverPage.razor`
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/js/pdf-viewer.js`
- `RECEIVER_PDF_VIEWER_CONTEXT.md` (main context document - **UPDATED with new strategy**)
---
## Notes
- Debug UI uses inline styles to avoid CSS conflicts
- Overlay is positioned at `z-index: 99999` to appear above everything
- Close button removes overlay from DOM completely
- All debug output also goes to browser console for advanced inspection
- **Debug findings led to complete strategy change - see RECEIVER_PDF_VIEWER_CONTEXT.md section 12-14**
---
**Remember: This is TEMPORARY debugging code. Delete after completing the new implementation strategy!**

View File

@@ -0,0 +1,231 @@
# DevExpress Blazor PdfViewer v25.2.3 - Verified API Reference
> **Source:** All information in this document has been verified from the actual source code of `DevExpress.Blazor.PdfViewer` v25.2.3 package.
> AI-generated API suggestions (GoToPageAsync, PageNumberChanged, etc.) are NOT real – do not use them.
---
## Verified Available Parameters
| Property | Type | Access | Default | Description |
|----------|------|--------|---------|-------------|
| `DocumentContent` | `byte[]` | `[Parameter]` GET/SET | – | Feeds PDF content as byte array |
| `CssClass` | `string` | `[Parameter]` GET/SET | – | Assigns CSS class to component |
| `DocumentName` | `string` | `[Parameter]` GET/SET | `"Document"` | Download filename |
| `IsSinglePagePreview` | `bool` | `[Parameter]` GET/SET | `false` | Single page mode |
| `SizeMode` | `SizeMode?` | `[Parameter]` GET/SET | `null` | `Small`, `Medium`, `Large` |
| `ZoomLevel` | `double` | `[Parameter]` GET/SET | `-1` | **Factor** (not percentage). `1.5` = 150% |
| `ActivePageIndex` | `int` | GET only | – | Active page index (0-based). No SET. |
| `PageCount` | `int` | GET only | – | Total page count in document |
---
## Available Events
- **`CustomizeToolbar`** – Allows toolbar customization
- **`ZoomLevelChanged`** – Fires when ZoomLevel property changes (EventCallback<double>)
## Missing Events (NOT AVAILABLE in v25.2.3)
- **`PageNumberChanged`** / **`ActivePageIndexChanged`** – Not available
- User scrolling or native toolbar page changes do not trigger C# code
---
## Missing Properties (NOT AVAILABLE in v25.2.3)
- **`ToolbarVisible`** – Not available (toolbar cannot be completely hidden)
- **`ActivePageIndex` (settable)** – Read-only; no programmatic page navigation
---
## Missing Methods (NOT AVAILABLE in v25.2.3)
- **`GoToPageAsync()`** – Not available
- **`GoToNextPageAsync()`** – Not available
- **`ZoomAsync()`** – Not available
---
## Critical Integration Notes
### ZoomLevel takes factor, not percentage
```csharp
// CORRECT
_viewerZoomLevel = 1.5; // viewer displays "150%"
_viewerZoomLevel = _currentZoom / 100d; // _currentZoom=150 -> 1.5
// WRONG
_viewerZoomLevel = 150; // viewer displays "15000%"
```
### PageCount replaces JS call
```csharp
// CORRECT - read directly from component (no JS needed)
_totalPages = _pdfViewer.PageCount;
// OLD method (no longer needed for this purpose)
// _totalPages = await JSRuntime.InvokeAsync<int>("pdfViewer.getTotalPages");
```
### ActivePageIndex is read-only
```csharp
// CORRECT - read for state synchronization
var currentPage = _pdfViewer.ActivePageIndex + 1; // convert to 1-based
// COMPILE ERROR - no setter
// _pdfViewer.ActivePageIndex = 3; // COMPILE ERROR
```
### DocumentContent byte[] feeding
```razor
<DxPdfViewer @ref="_pdfViewer"
DocumentContent="@_pdfDocumentContent"
ZoomLevel="@_viewerZoomLevel"
IsSinglePagePreview="true" />
@code {
DxPdfViewer? _pdfViewer;
byte[]? _pdfDocumentContent; // populate in OnInitializedAsync
double _viewerZoomLevel = 1.5; // 150%
}
```
---
## Impact on EnvelopeReceiverPage
### Features That Don't Work
1. **Event-driven overlay updates** – No page/zoom change events
2. **Thumbnail click navigation** – Cannot navigate viewer to specific page via C# API
3. **Cross-page signature navigation** – No programmatic page change API
4. **Automatic overlay synchronization** – User scroll/native toolbar doesn't trigger C#
### Features That Work
1. **ZoomLevel binding** – Custom zoom buttons can update viewer zoom
2. **PageCount** – Total pages can be read directly from component
3. **IsSinglePagePreview** – Single page mode works
4. **DocumentContent** – byte[] feeding works perfectly
5. **CustomizeToolbar** – Only way to add custom buttons to toolbar
---
## Workaround Strategy
CustomizeToolbar event is used to add custom navigation/zoom buttons.
Manual state tracking (`_currentPage`, `_currentZoom`, `_viewerZoomLevel`) is kept in C#.
Overlay refresh is manually triggered only after button clicks.
```csharp
protected void OnCustomizeToolbar(ToolbarModel toolbarModel)
{
toolbarModel.AllItems.Clear();
var prevButton = new ToolbarItem
{
Text = "Previous",
IconCssClass = "dx-icon-chevronprev",
Enabled = _currentPage > 1,
Click = async (args) =>
{
if (_currentPage > 1)
{
_currentPage--;
_viewerZoomLevel = _currentZoom / 100d;
await InvokeAsync(StateHasChanged);
await RenderSignatureButtonsAsync();
}
}
};
var nextButton = new ToolbarItem
{
Text = "Next",
IconCssClass = "dx-icon-chevronnext",
Enabled = _currentPage < _totalPages,
Click = async (args) =>
{
if (_currentPage < _totalPages)
{
_currentPage++;
_viewerZoomLevel = _currentZoom / 100d;
await InvokeAsync(StateHasChanged);
await RenderSignatureButtonsAsync();
}
}
};
var zoomInButton = new ToolbarItem
{
IconCssClass = "dx-icon-plus",
Enabled = _currentZoom < 300,
Click = async (args) =>
{
_currentZoom = Math.Min(_currentZoom + 10, 300);
_viewerZoomLevel = _currentZoom / 100d; // 150 -> 1.5
await InvokeAsync(StateHasChanged);
await RenderSignatureButtonsAsync();
}
};
var zoomOutButton = new ToolbarItem
{
IconCssClass = "dx-icon-minus",
Enabled = _currentZoom > 50,
Click = async (args) =>
{
_currentZoom = Math.Max(_currentZoom - 10, 50);
_viewerZoomLevel = _currentZoom / 100d; // 150 -> 1.5
await InvokeAsync(StateHasChanged);
await RenderSignatureButtonsAsync();
}
};
toolbarModel.AllItems.Add(prevButton);
toolbarModel.AllItems.Add(nextButton);
toolbarModel.AllItems.Add(zoomInButton);
toolbarModel.AllItems.Add(zoomOutButton);
}
```
### PageCount reading example (in OnAfterRenderAsync)
```csharp
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!_pdfLoaded && _pdfDocumentContent is { Length: > 0 })
{
await Task.Delay(300); // wait for DxPdfViewer to load
if (_pdfViewer is not null && _pdfViewer.PageCount > 0)
{
_totalPages = _pdfViewer.PageCount; // read directly instead of JS
_pdfLoaded = true;
await InvokeAsync(StateHasChanged);
await RenderThumbnailsAsync();
await RenderSignatureButtonsAsync();
}
}
}
```
---
## Known Acceptable Limitations
1. If user scrolls PDF, C# `_currentPage` does not synchronize
2. Thumbnail clicks update state but cannot move DevExpress viewer to target page
3. Browser zoom gestures do not trigger overlay updates
4. Custom toolbar buttons correctly trigger overlay updates
---
## References
- DevExpress official documentation: https://docs.devexpress.com/Blazor/DevExpress.Blazor.PdfViewer.DxPdfViewer
- Verified package: `DevExpress.Blazor.PdfViewer` v25.2.3
- **Note:** AI-suggested APIs (GoToPageAsync, PageNumberChanged, ActivePageIndexChanged, ZoomAsync, ToolbarVisible) are NOT real. Do not use.

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,275 @@
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);
}
}
#endregion
#region Create history
// ENV_UID, STATUS_ID, USER_ID,
string sql_hist = @"
USE [DD_ECM]
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);
}
}
#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

@@ -11,13 +11,10 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
<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.Server.zip</DesktopBuildPackageLocation>
<DesktopBuildPackageLocation>M:\App&amp;Service\0 DD - Smart UP\signFLOW\API\net8\$(Version)\EnvelopeGenerator.API.zip</DesktopBuildPackageLocation>
<PackageAsSingleFile>true</PackageAsSingleFile>
<DeployIisAppPath>EnvelopeGenerator</DeployIisAppPath>
<_TargetId>IISWebDeployPackage</_TargetId>
<TargetFramework>net8.0</TargetFramework>
<SelfContained>true</SelfContained>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<EnvironmentName>Production</EnvironmentName>
</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

@@ -60,11 +60,6 @@ public record EnvelopeReceiverDto
/// </summary>
public string? PrivateMessage { get; init; }
/// <summary>
///
/// </summary>
public string? AccessCode { get; init; }
/// <summary>
///
/// </summary>
@@ -88,4 +83,4 @@ public record EnvelopeReceiverDto
h.Receiver?.Id == ReceiverId &&
h.Status == EnvelopeStatus.DocumentSigned
) ?? false;
}
}

View File

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

View File

@@ -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,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

@@ -28,9 +28,7 @@ public class MappingProfile : Profile
CreateMap<DocReceiverElement, DocReceiverElementDto>();
CreateMap<DocumentStatus, DocumentStatusDto>();
CreateMap<EmailTemplate, EmailTemplateDto>();
CreateMap<Envelope, EnvelopeDto>()
.ForMember(dest => dest.EnvelopeTypeTitle,
opt => opt.MapFrom(src => src.Type != null ? src.Type.Title : null));
CreateMap<Envelope, EnvelopeDto>();
CreateMap<Document, DocumentDto>();
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));

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

@@ -1,6 +1,4 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace EnvelopeGenerator.Application.Common.Query;
namespace EnvelopeGenerator.Application.Common.Query;
/// <summary>
/// Stellt eine Abfrage dar, um die Details eines Empfängers zu lesen.
@@ -31,6 +29,5 @@ public record ReceiverQueryBase
/// <see cref="Id"/>, <see cref="EmailAddress"/>, or <see cref="Signature"/> is not null.
/// <para>Usage example: The query can be executed only if at least one criterion is specified.</para>
/// </remarks>
[NotMapped]
public virtual bool HasAnyCriteria => Id is not null || EmailAddress is not null || Signature is not null;
public bool HasAnyCriteria => Id is not null || EmailAddress is not null || Signature is not null;
}

View File

@@ -13,7 +13,7 @@ public class EnvelopeReceiverAddReadSQL : ISQL<Envelope>
/// ENV_UID, EMAIL_ADRESS, SALUTATION, PHONE,
/// </summary>
public string Raw => @"
DECLARE @OUT_RECEIVER_ID bigint
DECLARE @OUT_RECEIVER_ID int
EXEC [dbo].[PRSIG_API_CREATE_RECEIVER]
{0},

View File

@@ -65,21 +65,18 @@ public static class DependencyInjection
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, EnvelopeReceiverResolutionBehavior>();
// 1. AnnotationBehavior - Saves annotations (executes second)
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, SaveSignatureBehavior>();
// 2. AnnotationBehavior - Saves annotations for legacy web flow
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, AnnotationBehavior>();
// 3. DocStatusBehavior - Creates document status
// 2. DocStatusBehavior - Creates document status (executes third)
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, DocStatusBehavior>();
// 4. HistoryBehavior - Records history
// 3. HistoryBehavior - Records history (executes fourth)
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>();
});
return services;
}
}
}

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
using EnvelopeGenerator.Application.Common.Dto;
using DigitalData.Core.Abstraction.Application.Repository;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor;
using MediatR;
using System.ComponentModel.DataAnnotations;
@@ -12,29 +13,23 @@ namespace EnvelopeGenerator.Application.Envelopes.Commands;
/// </summary>
public record CreateEnvelopeCommand : IRequest<EnvelopeDto?>
{
public int? EnvelopeId { get; init; }
/// <summary>
/// Der Titel des Umschlags. Dies ist ein Pflichtfeld.
/// </summary>
public string? Title { get; set; }
[Required]
public required string Title { get; set; }
/// <summary>
/// Die Nachricht, die im Umschlag enthalten sein soll. Dies ist ein Pflichtfeld.
/// </summary>
public string? Message { get; set; }
[Required]
public required string Message { get; set; }
/// <summary>
/// Gibt an, ob die Zwei-Faktor-Authentifizierung für den Umschlag aktiviert ist. Standardmäßig false.
/// </summary>
public bool TFAEnabled { get; set; } = false;
public bool Send { get; init; }
public CreateEnvelopeDocumentDto? Document { get; init; }
public List<CreateEnvelopeReceiverDto> Receivers { get; init; } = new();
/// <summary>
/// ID des Absenders
/// </summary>
@@ -60,14 +55,4 @@ public record CreateEnvelopeCommand : IRequest<EnvelopeDto?>
[JsonIgnore]
[NotMapped]
public bool UseSQLExecutor { get; set; } = true;
}
public record CreateEnvelopeDocumentDto(string DataAsBase64);
public record CreateEnvelopeFieldDto(double X, double Y, int Page);
public record CreateEnvelopeReceiverDto(
string EmailAddress,
string? Name,
string? PhoneNumber,
List<CreateEnvelopeFieldDto> Fields);
}

View File

@@ -1,20 +1,10 @@
using AutoMapper;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using DigitalData.Core.Abstraction.Application.Repository;
using DigitalData.EmailProfilerDispatcher.Abstraction.Entities;
using DigitalData.Core.Exceptions;
using EnvelopeGenerator.Domain.Entities;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Configurations;
using EnvelopeGenerator.Application.Common.Extensions;
using EnvelopeGenerator.Application.Common.Interfaces.Services;
using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor;
using EnvelopeGenerator.Application.Histories.Commands;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Domain;
using Microsoft.EntityFrameworkCore;
namespace EnvelopeGenerator.Application.Envelopes.Commands;
@@ -29,26 +19,6 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
private IRepository<Envelope> Repository => _provider.GetRequiredService<IRepository<Envelope>>();
private IRepository<Document> DocumentRepository => _provider.GetRequiredService<IRepository<Document>>();
private IRepository<EnvelopeReceiver> EnvelopeReceiverRepository => _provider.GetRequiredService<IRepository<EnvelopeReceiver>>();
private IRepository<Receiver> ReceiverRepository => _provider.GetRequiredService<IRepository<Receiver>>();
private IRepository<DocReceiverElement> DocReceiverElementRepository => _provider.GetRequiredService<IRepository<DocReceiverElement>>();
private IRepository<EmailTemplate> EmailTemplateRepository => _provider.GetRequiredService<IRepository<EmailTemplate>>();
private IRepository<EmailOut> EmailOutRepository => _provider.GetRequiredService<IRepository<EmailOut>>();
private MailParams MailParams => _provider.GetRequiredService<IOptions<MailParams>>().Value;
private DispatcherParams DispatcherParams => _provider.GetRequiredService<IOptions<DispatcherParams>>().Value;
private IConfigService ConfigService => _provider.GetRequiredService<IConfigService>();
private IMediator Mediator => _provider.GetRequiredService<IMediator>();
private readonly IMapper _mapper;
/// <summary>
@@ -70,385 +40,10 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
/// <returns></returns>
public async Task<EnvelopeDto?> Handle(CreateEnvelopeCommand request, CancellationToken cancel)
{
var now = DateTime.Now;
var title = request.Title?.Trim() ?? string.Empty;
var message = request.Message?.Trim() ?? string.Empty;
Envelope envelope;
if (request.EnvelopeId is int envelopeId)
{
envelope = await Repository.Query
.Include(e => e.User)
.FirstOrDefaultAsync(e => e.Id == envelopeId, cancel)
?? throw new BadRequestException($"Envelope {envelopeId} not found.");
if (envelope.UserId != request.UserId)
throw new ForbiddenException();
await Repository.UpdateAsync(e =>
{
e.Title = title;
e.Message = message;
e.TfaEnabled = request.TFAEnabled;
e.Status = EnvelopeStatus.EnvelopeSaved;
e.ChangedWhen = now;
}, e => e.Id == envelopeId, cancel);
envelope.Title = title;
envelope.Message = message;
envelope.TfaEnabled = request.TFAEnabled;
envelope.Status = EnvelopeStatus.EnvelopeSaved;
envelope.ChangedWhen = now;
}
else
{
envelope = request.UseSQLExecutor
? await Executor.CreateEnvelopeAsync(request.UserId, title, message, request.TFAEnabled, cancel)
: await Repository.CreateAsync(new Envelope
{
UserId = request.UserId,
Status = EnvelopeStatus.EnvelopeCreated,
Uuid = Guid.NewGuid().ToString(),
Title = title,
Message = message,
AddedWhen = now,
ChangedWhen = now,
Comment = string.Empty,
Language = "de-DE",
TfaEnabled = request.TFAEnabled,
UseAccessCode = false,
SendReminderEmails = false
}, cancel);
await Mediator.Send(new CreateHistoryCommand
{
EnvelopeId = envelope.Id,
UserReference = await ResolveSenderReferenceAsync(envelope, cancel),
Status = EnvelopeStatus.EnvelopeCreated
}, cancel);
}
await UpsertDocumentAsync(envelope.Id, request.Document, now, cancel);
await UpsertReceiversAndFieldsAsync(envelope.Id, request.Receivers, now, cancel);
if (request.Send)
await SendEnvelopeAsync(envelope, cancel);
envelope = await Repository.Query
.Include(e => e.User)
.Include(e => e.EnvelopeReceivers)
.ThenInclude(er => er.Receiver)
.FirstAsync(e => e.Id == envelope.Id, cancel);
var envelope = request.UseSQLExecutor
? await Executor.CreateEnvelopeAsync(request.UserId, request.Title, request.Message, request.TFAEnabled, cancel)
: await Repository.CreateAsync(request, cancel);
return _mapper.Map<EnvelopeDto>(envelope);
}
private async Task<string> ResolveSenderReferenceAsync(Envelope envelope, CancellationToken cancel)
{
if (!string.IsNullOrWhiteSpace(envelope.User?.Email))
return envelope.User.Email;
var senderEmail = await Repository.Query
.Where(e => e.Id == envelope.Id)
.Select(e => e.User.Email)
.FirstOrDefaultAsync(cancel);
if (string.IsNullOrWhiteSpace(senderEmail))
throw new BadRequestException("Envelope sender email is missing.");
return senderEmail;
}
private async Task UpsertDocumentAsync(int envelopeId, CreateEnvelopeDocumentDto? document, DateTime now, CancellationToken cancel)
{
if (document is null || string.IsNullOrWhiteSpace(document.DataAsBase64))
return;
byte[] bytes;
try
{
bytes = Convert.FromBase64String(document.DataAsBase64);
}
catch (FormatException)
{
throw new BadRequestException("Document payload is not valid base64.");
}
var existingDocumentId = await DocumentRepository.Query
.Where(d => d.EnvelopeId == envelopeId)
.Select(d => (int?)d.Id)
.FirstOrDefaultAsync(cancel);
if (existingDocumentId is null)
{
await DocumentRepository.CreateAsync(new Document
{
EnvelopeId = envelopeId,
AddedWhen = now,
ByteData = bytes,
Filename = string.Empty,
Filepath = string.Empty,
FileNameOriginal = string.Empty
}, cancel);
return;
}
await DocumentRepository.UpdateAsync(d =>
{
d.ByteData = bytes;
}, d => d.Id == existingDocumentId.Value, cancel);
}
private async Task UpsertReceiversAndFieldsAsync(int envelopeId, IReadOnlyCollection<CreateEnvelopeReceiverDto> receivers, DateTime now, CancellationToken cancel)
{
var filteredReceivers = receivers
.Where(r => !string.IsNullOrWhiteSpace(r.EmailAddress))
.Select(r => r with
{
EmailAddress = r.EmailAddress.Trim().ToLowerInvariant(),
Name = r.Name?.Trim(),
PhoneNumber = string.IsNullOrWhiteSpace(r.PhoneNumber) ? null : r.PhoneNumber.Trim(),
Fields = r.Fields ?? new List<CreateEnvelopeFieldDto>()
})
.GroupBy(r => r.EmailAddress, StringComparer.OrdinalIgnoreCase)
.Select(g => g.First())
.ToList();
if (filteredReceivers.Count == 0)
return;
var document = await DocumentRepository.Query
.FirstOrDefaultAsync(d => d.EnvelopeId == envelopeId, cancel);
if (document is null)
return;
var currentLinks = await EnvelopeReceiverRepository.Query
.Include(er => er.Receiver)
.Where(er => er.EnvelopeId == envelopeId)
.ToListAsync(cancel);
var linksByEmail = currentLinks
.Where(er => !string.IsNullOrWhiteSpace(er.Receiver?.EmailAddress))
.GroupBy(er => er.Receiver!.EmailAddress, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key.ToLowerInvariant(), g => g.First(), StringComparer.OrdinalIgnoreCase);
var sequence = currentLinks.Count > 0 ? currentLinks.Max(er => er.Sequence) : 0;
foreach (var receiverDraft in filteredReceivers)
{
var linkResult = await GetOrCreateEnvelopeReceiverAsync(envelopeId, receiverDraft, linksByEmail, now, sequence, cancel);
var link = linkResult.Link;
sequence = linkResult.NextSequence;
await DocReceiverElementRepository.DeleteAsync(query => query
.Where(e => e.DocumentId == document.Id)
.Where(e => e.ReceiverId == link.ReceiverId), cancel);
foreach (var field in receiverDraft.Fields.Where(f => f.Page > 0))
{
await DocReceiverElementRepository.CreateAsync(new DocReceiverElement
{
DocumentId = document.Id,
ReceiverId = link.ReceiverId,
ElementType = 1,
X = field.X,
Y = field.Y,
Width = 1.77,
Height = 1.96,
Page = field.Page,
Required = true,
ReadOnly = false,
AnnotationIndex = 0,
AddedWhen = now,
FullName = link.Name,
Position = string.Empty,
Place = string.Empty,
Tooltip = string.Empty
}, cancel);
}
}
}
private async Task<(EnvelopeReceiver Link, int NextSequence)> GetOrCreateEnvelopeReceiverAsync(
int envelopeId,
CreateEnvelopeReceiverDto receiverDraft,
IDictionary<string, EnvelopeReceiver> linksByEmail,
DateTime now,
int sequence,
CancellationToken cancel)
{
if (linksByEmail.TryGetValue(receiverDraft.EmailAddress, out var existingLink))
{
var nextName = string.IsNullOrWhiteSpace(receiverDraft.Name) ? existingLink.Name : receiverDraft.Name!;
var nextPhoneNumber = receiverDraft.PhoneNumber ?? existingLink.PhoneNumber;
await EnvelopeReceiverRepository.UpdateAsync(er =>
{
er.Name = nextName;
er.PhoneNumber = nextPhoneNumber;
er.ChangedWhen = now;
}, er => er.EnvelopeId == existingLink.EnvelopeId && er.ReceiverId == existingLink.ReceiverId, cancel);
existingLink.Name = nextName;
existingLink.PhoneNumber = nextPhoneNumber;
return (existingLink, sequence);
}
var receiver = await ReceiverRepository.Query
.FirstOrDefaultAsync(r => r.EmailAddress != null && r.EmailAddress.ToLower() == receiverDraft.EmailAddress, cancel);
if (receiver is null)
{
receiver = await ReceiverRepository.CreateAsync(new Receiver
{
EmailAddress = receiverDraft.EmailAddress,
Signature = receiverDraft.EmailAddress.ToUpperInvariant().GetChecksum(),
AddedWhen = now,
TotpSecretkey = string.Empty,
TfaRegDeadline = null
}, cancel);
}
sequence++;
var link = await EnvelopeReceiverRepository.CreateAsync(new EnvelopeReceiver
{
EnvelopeId = envelopeId,
ReceiverId = receiver.Id,
Sequence = sequence,
Name = receiverDraft.Name ?? receiverDraft.EmailAddress,
JobTitle = string.Empty,
CompanyName = string.Empty,
PrivateMessage = string.Empty,
AccessCode = string.Empty,
AddedWhen = now,
ChangedWhen = now,
PhoneNumber = receiverDraft.PhoneNumber ?? string.Empty
}, cancel);
link.Receiver = receiver;
linksByEmail[receiverDraft.EmailAddress] = link;
return (link, sequence);
}
private async Task SendEnvelopeAsync(Envelope envelope, CancellationToken cancel)
{
var hasDocument = await DocumentRepository.Query
.AnyAsync(d => d.EnvelopeId == envelope.Id && d.ByteData != null && d.ByteData.Length > 0, cancel);
if (!hasDocument)
throw new BadRequestException("Cannot send envelope without a document.");
var links = await EnvelopeReceiverRepository.Query
.Include(er => er.Receiver)
.Where(er => er.EnvelopeId == envelope.Id)
.ToListAsync(cancel);
if (links.Count == 0)
throw new BadRequestException("Cannot send envelope without receivers.");
var receiverIds = links.Select(l => l.ReceiverId).Distinct().ToList();
var documentIds = await DocumentRepository.Query
.Where(d => d.EnvelopeId == envelope.Id)
.Select(d => d.Id)
.ToListAsync(cancel);
var receiverFieldCounts = await DocReceiverElementRepository.Query
.Where(e => documentIds.Contains(e.DocumentId))
.Where(e => receiverIds.Contains(e.ReceiverId))
.GroupBy(e => e.ReceiverId)
.Select(g => new { ReceiverId = g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.ReceiverId, x => x.Count, cancel);
var receiversWithoutFields = links
.Where(link => !receiverFieldCounts.TryGetValue(link.ReceiverId, out var count) || count <= 0)
.Select(link => link.Name)
.ToList();
if (receiversWithoutFields.Count > 0)
throw new BadRequestException("Cannot send envelope because some receivers have no signature fields.");
var changedWhen = DateTime.Now;
await Repository.UpdateAsync(e =>
{
e.Status = EnvelopeStatus.EnvelopeQueued;
e.ChangedWhen = changedWhen;
}, e => e.Id == envelope.Id, cancel);
var userReference = await ResolveSenderReferenceAsync(envelope, cancel);
await Mediator.Send(new CreateHistoryCommand
{
EnvelopeId = envelope.Id,
UserReference = userReference,
Status = EnvelopeStatus.EnvelopeQueued
}, cancel);
await QueueInvitationEmailsAsync(envelope, links, userReference, cancel);
envelope.Status = EnvelopeStatus.EnvelopeQueued;
}
private async Task QueueInvitationEmailsAsync(
Envelope envelope,
IReadOnlyCollection<EnvelopeReceiver> links,
string senderReference,
CancellationToken cancel)
{
var template = await EmailTemplateRepository.Query
.SingleOrDefaultAsync(x => x.Name == EmailTemplateType.DocumentReceived.ToString(), cancel)
?? throw new InvalidOperationException($"Email template not found: {EmailTemplateType.DocumentReceived}");
var signatureHost = await ConfigService.ReadDefaultSignatureHost();
var senderName = senderReference;
foreach (var link in links)
{
var receiver = link.Receiver;
if (receiver is null || string.IsNullOrWhiteSpace(receiver.EmailAddress) || string.IsNullOrWhiteSpace(receiver.Signature))
continue;
var envelopeKey = (envelope.Uuid, receiver.Signature).ToEnvelopeKey();
var documentLink = $"{signatureHost.TrimEnd('/')}/envelope/login/{Uri.EscapeDataString(envelopeKey)}";
var placeholders = new Dictionary<string, string>(MailParams.Placeholders)
{
["[NAME_RECEIVER]"] = link.Name ?? string.Empty,
["[NAME_SENDER]"] = senderName ?? string.Empty,
["[DOCUMENT_TITLE]"] = envelope.Title ?? string.Empty,
["[MESSAGE]"] = envelope.Message ?? string.Empty,
["[LINK_TO_DOCUMENT]"] = documentLink,
["[LINK_TO_DOCUMENT_TEXT]"] = documentLink[..Math.Min(40, documentLink.Length)] + ".."
};
var subject = ReplacePlaceHolders(template.Subject, placeholders);
var body = ReplacePlaceHolders(template.Body, placeholders);
var emailOut = new EmailOut
{
EmailAddress = receiver.EmailAddress,
EmailSubj = subject,
EmailBody = body,
AddedWhen = DateTime.Now,
AddedWho = DispatcherParams.AddedWho,
SendingProfile = DispatcherParams.SendingProfile,
ReminderTypeId = DispatcherParams.ReminderTypeId,
EmailAttmt1 = DispatcherParams.EmailAttmt1,
WfId = (int)EnvelopeStatus.MessageInvitationSent,
ReferenceString = receiver.EmailAddress,
ReferenceId = link.ReceiverId
};
await EmailOutRepository.CreateAsync(emailOut, cancel);
}
}
private static string ReplacePlaceHolders(string text, Dictionary<string, string> placeHolders)
{
foreach (var placeHolder in placeHolders)
text = text.Replace(placeHolder.Key, placeHolder.Value);
return text;
}
}
}

View File

@@ -1,25 +0,0 @@
using System.ComponentModel.DataAnnotations;
using EnvelopeGenerator.Domain.Constants;
using MediatR;
namespace EnvelopeGenerator.Application.Envelopes.Commands;
public record DeleteEnvelopeResultDto(int EnvelopeId, EnvelopeStatus AppliedStatus, bool IsNoOp);
public record DeleteEnvelopeCommand : IRequest<DeleteEnvelopeResultDto>
{
[Required]
public int EnvelopeId { get; init; }
[Required]
[MinLength(1)]
public string Reason { get; init; } = string.Empty;
internal int UserId { get; private set; }
public DeleteEnvelopeCommand WithAuth(int userId)
{
UserId = userId;
return this;
}
}

View File

@@ -1,89 +0,0 @@
using EnvelopeGenerator.Application.Common.Notifications.EnvelopeDeleted;
using DigitalData.Core.Abstraction.Application.Repository;
using DigitalData.Core.Exceptions;
using EnvelopeGenerator.Application.Histories.Commands;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Domain.Entities;
using MediatR;
using Microsoft.EntityFrameworkCore;
using System.Linq;
namespace EnvelopeGenerator.Application.Envelopes.Commands;
public class DeleteEnvelopeCommandHandler : IRequestHandler<DeleteEnvelopeCommand, DeleteEnvelopeResultDto>
{
private readonly IRepository<Envelope> _envelopeRepository;
private readonly IMediator _mediator;
public DeleteEnvelopeCommandHandler(
IRepository<Envelope> envelopeRepository,
IMediator mediator)
{
_envelopeRepository = envelopeRepository;
_mediator = mediator;
}
public async Task<DeleteEnvelopeResultDto> Handle(DeleteEnvelopeCommand request, CancellationToken cancel)
{
var reason = request.Reason?.Trim();
if (string.IsNullOrWhiteSpace(reason))
throw new BadRequestException("Please provide a reason.");
var envelope = await _envelopeRepository.Query
.Include(e => e.User)
.Include(e => e.EnvelopeReceivers)
.ThenInclude(er => er.Receiver)
.FirstOrDefaultAsync(e => e.Id == request.EnvelopeId, cancel)
?? throw new NotFoundException($"Envelope {request.EnvelopeId} not found.");
if (envelope.UserId != request.UserId)
throw new ForbiddenException();
if (envelope.Status is EnvelopeStatus.EnvelopeDeleted or EnvelopeStatus.EnvelopeWithdrawn)
return new DeleteEnvelopeResultDto(envelope.Id, envelope.Status, IsNoOp: true);
var targetStatus = envelope.Status > EnvelopeStatus.EnvelopeSaved
? EnvelopeStatus.EnvelopeWithdrawn
: EnvelopeStatus.EnvelopeDeleted;
var changedWhen = DateTime.Now;
envelope.Status = targetStatus;
envelope.Comment = reason;
envelope.ChangedWhen = changedWhen;
await _envelopeRepository.UpdateAsync(e =>
{
e.Status = targetStatus;
e.Comment = reason;
e.ChangedWhen = changedWhen;
}, e => e.Id == envelope.Id, cancel);
var userReference = envelope.User?.Email;
if (string.IsNullOrWhiteSpace(userReference))
throw new BadRequestException("Envelope sender email is missing.");
await _mediator.Send(new CreateHistoryCommand
{
EnvelopeId = envelope.Id,
UserReference = userReference,
Status = targetStatus,
Comment = reason
}, cancel);
foreach (var envelopeReceiver in envelope.EnvelopeReceivers ?? Enumerable.Empty<EnvelopeReceiver>())
{
if (envelopeReceiver.Receiver is null)
continue;
await _mediator.Publish(new EnvelopeDeletedNotification
{
Envelope = envelope,
EnvelopeReceiver = envelopeReceiver,
Reason = reason,
SenderReference = userReference
}, cancel);
}
return new DeleteEnvelopeResultDto(envelope.Id, targetStatus, IsNoOp: false);
}
}

View File

@@ -149,11 +149,9 @@ public class ReadEnvelopeQueryHandler : IRequestHandler<ReadEnvelopeQuery, IEnum
query = query.Where(e => Status.Completed.Contains(e.Status));
var envelopes = await query
.Include(e => e.Type)
.Include(e => e.EnvelopeReceivers).ThenInclude(er => er.Receiver)
.Include(e => e.Histories)
.ToListAsync(cancel);
return _mapper.Map<IEnumerable<EnvelopeDto>>(envelopes);
}
}
}

View File

@@ -5,7 +5,6 @@ using EnvelopeGenerator.Application.Common.Query;
using MediatR;
using EnvelopeGenerator.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations.Schema;
namespace EnvelopeGenerator.Application.Receivers.Queries;
@@ -13,24 +12,7 @@ namespace EnvelopeGenerator.Application.Receivers.Queries;
/// Stellt eine Abfrage dar, um die Details eines Empfängers zu lesen.
/// um spezifische Informationen über einen Empfänger abzurufen.
/// </summary>
public record ReadReceiverQuery : ReceiverQueryBase, IRequest<IEnumerable<ReceiverDto>>
{
/// <summary>
/// Suchbegriff für eine teilweise Übereinstimmung in der E-Mail Adresse des Empfängers
/// </summary>
public virtual string? EmailAddressSearch { get; set; }
/// <summary>
/// Checks whether any of the specified query criteria have a value.
/// </summary>
/// <remarks>
/// This property returns <c>true</c> if at least one of the fields
/// <see cref="ReceiverQueryBase.Id"/>, <see cref="ReceiverQueryBase.EmailAddress"/>, <see cref="EmailAddressSearch"/>, or <see cref="ReceiverQueryBase.Signature"/> is not null.
/// <para>Usage example: The query can be executed only if at least one criterion is specified.</para>
/// </remarks>
[NotMapped]
public override bool HasAnyCriteria => EmailAddressSearch is not null || base.HasAnyCriteria;
}
public record ReadReceiverQuery : ReceiverQueryBase, IRequest<IEnumerable<ReceiverDto>>;
/// <summary>
///
@@ -71,11 +53,6 @@ public class ReadReceiverQueryHandler : IRequestHandler<ReadReceiverQuery, IEnum
query = query.Where(r => r.EmailAddress == email);
}
if (!string.IsNullOrWhiteSpace(request.EmailAddressSearch))
{
query = query.Where(r => EF.Functions.Like(r.EmailAddress, $"%{request.EmailAddressSearch}%"));
}
if (request.Signature is string signature)
{
query = query.Where(r => r.Signature == signature);

View File

@@ -651,123 +651,4 @@
<data name="Status" xml:space="preserve">
<value>Status</value>
</data>
<data name="ChangedAt" xml:space="preserve"><value>Geändert am</value></data>
<data name="Close" xml:space="preserve"><value>Schließen</value></data>
<data name="CloseLanguageMenu" xml:space="preserve"><value>Sprachmenü schließen</value></data>
<data name="ConfirmSignature" xml:space="preserve"><value>Unterschrift bestätigen</value></data>
<data name="Created" xml:space="preserve"><value>Erstellt</value></data>
<data name="Delete" xml:space="preserve"><value>Löschen</value></data>
<data name="Document" xml:space="preserve"><value>Dokument</value></data>
<data name="DocumentCouldNotBeLoaded" xml:space="preserve"><value>Dokument konnte nicht geladen werden</value></data>
<data name="DocumentLoading" xml:space="preserve"><value>Dokument wird geladen...</value></data>
<data name="EmailAddress" xml:space="preserve"><value>E-Mail-Adresse</value></data>
<data name="Envelope" xml:space="preserve"><value>Umschlag</value></data>
<data name="Error" xml:space="preserve"><value>Fehler</value></data>
<data name="ErrorLoadingDocument_WithReason" xml:space="preserve"><value>Fehler beim Laden des Dokuments: {0}</value></data>
<data name="ErrorPage_DevelopmentMode" xml:space="preserve"><value>Entwicklungsmodus</value></data>
<data name="ErrorPage_DevelopmentMode_Detail" xml:space="preserve"><value>Dies kann dazu führen, dass vertrauliche Informationen aus Ausnahmen Endbenutzern angezeigt werden. Für lokales Debugging setzen Sie ASPNETCORE_ENVIRONMENT auf Development und starten die App neu.</value></data>
<data name="ErrorPage_DevelopmentMode_Info" xml:space="preserve"><value>Im Entwicklungsmodus werden detailliertere Fehlerinformationen angezeigt.</value></data>
<data name="ErrorPage_DevelopmentMode_Warning" xml:space="preserve"><value>Der Entwicklungsmodus sollte in bereitgestellten Anwendungen nicht aktiviert sein.</value></data>
<data name="ErrorPage_ProcessingRequest" xml:space="preserve"><value>Bei der Verarbeitung Ihrer Anfrage ist ein Fehler aufgetreten.</value></data>
<data name="ErrorPage_RequestId" xml:space="preserve"><value>Anfrage-ID</value></data>
<data name="ErrorWithReason" xml:space="preserve"><value>Fehler: {0}</value></data>
<data name="From" xml:space="preserve"><value>Von</value></data>
<data name="FullName" xml:space="preserve"><value>Vor- und Nachname</value></data>
<data name="History" xml:space="preserve"><value>Historie</value></data>
<data name="Index_DigitalSignature" xml:space="preserve"><value>Digitale Unterschrift</value></data>
<data name="Index_PdfExport" xml:space="preserve"><value>PDF-Export</value></data>
<data name="Index_SecureAccess" xml:space="preserve"><value>Sicherer Zugang</value></data>
<data name="Index_Subtitle" xml:space="preserve"><value>Willkommen im eSign-Portal</value></data>
<data name="Index_ToEnvelopeOverview" xml:space="preserve"><value>Zur Umschlag-Übersicht</value></data>
<data name="Languages" xml:space="preserve"><value>Sprachen</value></data>
<data name="Loading" xml:space="preserve"><value>Lädt...</value></data>
<data name="Login" xml:space="preserve"><value>Anmelden</value></data>
<data name="LoginInProgress" xml:space="preserve"><value>Anmelden ...</value></data>
<data name="NotAuthenticatedPleaseLoginAgain" xml:space="preserve"><value>Nicht authentifiziert. Bitte melden Sie sich erneut an.</value></data>
<data name="Only" xml:space="preserve"><value>Nur</value></data>
<data name="OpenDocument" xml:space="preserve"><value>Dokument öffnen</value></data>
<data name="Optional" xml:space="preserve"><value>optional</value></data>
<data name="Pending" xml:space="preserve"><value>Ausstehend</value></data>
<data name="Preview" xml:space="preserve"><value>Vorschau</value></data>
<data name="PrivacyPolicy" xml:space="preserve"><value>Datenschutz</value></data>
<data name="Receiver" xml:space="preserve"><value>Empfänger</value></data>
<data name="ReceiverLogin_AccessCode_Placeholder" xml:space="preserve"><value>Zugangscode eingeben</value></data>
<data name="ReceiverLogin_Description" xml:space="preserve"><value>Bitte geben Sie den Zugangscode ein, den Sie per E-Mail erhalten haben, um das Dokument sicher zu öffnen.</value></data>
<data name="ReceiverLogin_FooterHelp" xml:space="preserve"><value>Bei Problemen wenden Sie sich bitte an den Absender des Dokuments.</value></data>
<data name="ReceiverLogin_InvalidCode_Body" xml:space="preserve"><value>Der eingegebene Code ist falsch. Bitte versuchen Sie es erneut.</value></data>
<data name="ReceiverLogin_InvalidCode_Title" xml:space="preserve"><value>Ungültiger Zugangscode.</value></data>
<data name="ReceiverLogin_NotFound_Body" xml:space="preserve"><value>Der angegebene Zugangscode konnte keinem Dokument zugeordnet werden. Bitte prüfen Sie den Link in Ihrer E-Mail.</value></data>
<data name="ReceiverLogin_NotFound_Title" xml:space="preserve"><value>Dokument nicht gefunden.</value></data>
<data name="ReceiverLogin_Subtitle" xml:space="preserve"><value>Sicherer Zugang mit Zugangscode</value></data>
<data name="ReceiverSigned_ConfirmBody" xml:space="preserve"><value>Diese Aktion kann nicht rückgängig gemacht werden. Mit der Bestätigung erklären Sie, das oben angezeigte Dokument elektronisch unterzeichnet zu haben. Das unterzeichnete Dokument wird anschließend an alle beteiligten Parteien übermittelt.</value></data>
<data name="ReceiverSigned_ConfirmQuestion" xml:space="preserve"><value>Möchten Sie das Dokument verbindlich unterschreiben?</value></data>
<data name="ReceiverSigned_EnvelopeKeyMissing" xml:space="preserve"><value>Envelope-Schlüssel fehlt.</value></data>
<data name="Refresh" xml:space="preserve"><value>Aktualisieren</value></data>
<data name="RequiredField" xml:space="preserve"><value>Pflichtfeld</value></data>
<data name="Selected" xml:space="preserve"><value>ausgewählte</value></data>
<data name="Send" xml:space="preserve"><value>Senden</value></data>
<data name="Sender_ActiveEnvelopes" xml:space="preserve"><value>Aktive Umschläge</value></data>
<data name="Sender_CompletedEnvelopes" xml:space="preserve"><value>Abgeschlossene Umschläge</value></data>
<data name="Sender_CreateEnvelope" xml:space="preserve"><value>Neuer Umschlag</value></data>
<data name="Sender_CreateEnvelope_Title" xml:space="preserve"><value>Neuen Umschlag erstellen</value></data>
<data name="Sender_DeleteEnvelope_Header" xml:space="preserve"><value>Umschlag löschen</value></data>
<data name="Sender_DeleteEnvelope_ReasonPlaceholder" xml:space="preserve"><value>Begründung eingeben...</value></data>
<data name="Sender_DeleteEnvelope_ReasonPrompt" xml:space="preserve"><value>Bitte geben Sie einen Grund an, warum der Umschlag gelöscht/zurückgezogen werden soll.</value></data>
<data name="Sender_DeleteEnvelope_ReasonRequired" xml:space="preserve"><value>Bitte geben Sie einen Löschgrund an.</value></data>
<data name="Sender_DeleteEnvelope_Title" xml:space="preserve"><value>Ausgewählten Umschlag löschen</value></data>
<data name="Sender_Edit" xml:space="preserve"><value>Bearbeiten</value></data>
<data name="Sender_EditEnvelope_Title" xml:space="preserve"><value>Ausgewählten Umschlag bearbeiten</value></data>
<data name="Sender_ExportCurrentView_Title" xml:space="preserve"><value>Aktuelle Ansicht exportieren</value></data>
<data name="Sender_ExportFormat_Aria" xml:space="preserve"><value>Exportformat auswählen</value></data>
<data name="Sender_ExportView_Title" xml:space="preserve"><value>Export der aktuellen Tabellenansicht</value></data>
<data name="Sender_LoadError_Title" xml:space="preserve"><value>Fehler beim Laden der Umschläge</value></data>
<data name="Sender_LoadingEnvelopes" xml:space="preserve"><value>Umschläge werden geladen...</value></data>
<data name="Sender_NoHistory" xml:space="preserve"><value>Keine Historie vorhanden</value></data>
<data name="Sender_NoReceivers" xml:space="preserve"><value>Keine Empfänger</value></data>
<data name="Sender_Preview_Title" xml:space="preserve"><value>Ausgewählten Umschlag als PDF anzeigen</value></data>
<data name="Sender_PreviewDocumentNotFound" xml:space="preserve"><value>Das Dokument für Umschlag #{0} wurde nicht gefunden oder Sie haben keinen Zugriff darauf.</value></data>
<data name="SenderEditor_AddSignature" xml:space="preserve"><value>Signatur hinzufügen</value></data>
<data name="SenderEditor_ClickDocumentFor" xml:space="preserve"><value>Klicken Sie auf die Stelle im Dokument für</value></data>
<data name="SenderEditor_EmailSuggestionsHint" xml:space="preserve"><value>Bereits verwendete E-Mail-Adressen werden bei der Eingabe vorgeschlagen.</value></data>
<data name="SenderEditor_EmailSuggestionsLoading" xml:space="preserve"><value>E-Mail-Vorschläge werden geladen...</value></data>
<data name="SenderEditor_ErrorCreatingEnvelope" xml:space="preserve"><value>Fehler beim Erstellen des Umschlags</value></data>
<data name="SenderEditor_LoadEnvelopeError" xml:space="preserve"><value>Fehler beim Laden des Umschlags #{0}: {1}</value></data>
<data name="SenderEditor_LoadFileError" xml:space="preserve"><value>Fehler beim Laden der Datei: {0}</value></data>
<data name="SenderEditor_Message_Placeholder" xml:space="preserve"><value>Bitte unterzeichnen Sie das beigefügte Dokument.</value></data>
<data name="SenderEditor_NoDocumentLoaded" xml:space="preserve"><value>Kein Dokument geladen</value></data>
<data name="SenderEditor_NoReceiversAdded" xml:space="preserve"><value>Noch keine Empfänger hinzugefügt.</value></data>
<data name="SenderEditor_PlaceSignatureField" xml:space="preserve"><value>Signaturfeld platzieren</value></data>
<data name="SenderEditor_RemoveAllSignatureFields" xml:space="preserve"><value>Alle Signaturfelder entfernen</value></data>
<data name="SenderEditor_SelectPdf" xml:space="preserve"><value>Bitte wählen Sie eine PDF-Datei aus.</value></data>
<data name="SenderEditor_Sent_Body" xml:space="preserve"><value>Der bestehende Entwurf wurde versendet.</value></data>
<data name="SenderEditor_Sent_Header" xml:space="preserve"><value>Umschlag gesendet</value></data>
<data name="SenderEditor_Sent_Title" xml:space="preserve"><value>Umschlag wurde erfolgreich gesendet.</value></data>
<data name="SenderEditor_SignatureField" xml:space="preserve"><value>Signaturfeld</value></data>
<data name="SenderEditor_Title_Placeholder" xml:space="preserve"><value>z.B. Arbeitsvertrag 2025</value></data>
<data name="SenderEditor_ToOverview" xml:space="preserve"><value>Zur Übersicht</value></data>
<data name="SenderEditor_UploadPdfToPlaceFields" xml:space="preserve"><value>Laden Sie eine PDF-Datei hoch, um Signaturfelder zu platzieren.</value></data>
<data name="SenderEditor_Validation_EmailDuplicate" xml:space="preserve"><value>Diese E-Mail-Adresse wurde bereits hinzugefügt.</value></data>
<data name="SenderEditor_Validation_EmailInvalid" xml:space="preserve"><value>Bitte geben Sie eine gültige E-Mail-Adresse ein.</value></data>
<data name="SenderEditor_Validation_EmailRequired" xml:space="preserve"><value>Bitte geben Sie eine E-Mail-Adresse ein.</value></data>
<data name="SenderEditor_Validation_FullNameRequired" xml:space="preserve"><value>Bitte geben Sie einen Vor- und Nachnamen ein.</value></data>
<data name="SenderEditor_Validation_ReceiverRequired" xml:space="preserve"><value>Bitte fügen Sie mindestens einen Empfänger hinzu.</value></data>
<data name="SenderEditor_Validation_SignatureFieldRequired" xml:space="preserve"><value>Bitte platzieren Sie mindestens ein Signaturfeld.</value></data>
<data name="SenderEditor_Validation_TitleRequired" xml:space="preserve"><value>Bitte geben Sie einen Titel ein.</value></data>
<data name="SenderEditor_Validation_UploadPdf" xml:space="preserve"><value>Bitte laden Sie zuerst ein PDF-Dokument hoch.</value></data>
<data name="SenderLogin_Description" xml:space="preserve"><value>Bitte melden Sie sich mit Ihren Zugangsdaten an, um auf das Sender-Dashboard zuzugreifen.</value></data>
<data name="SenderLogin_FooterHelp" xml:space="preserve"><value>Bei Problemen wenden Sie sich bitte an den Administrator.</value></data>
<data name="SenderLogin_InvalidCredentials_Body" xml:space="preserve"><value>Benutzername oder Passwort ist falsch. Bitte versuchen Sie es erneut.</value></data>
<data name="SenderLogin_InvalidCredentials_Title" xml:space="preserve"><value>Ungültige Anmeldedaten.</value></data>
<data name="SenderLogin_Password_Placeholder" xml:space="preserve"><value>Passwort eingeben</value></data>
<data name="SenderLogin_Subtitle" xml:space="preserve"><value>Sicherer Zugang zum Sender-Dashboard</value></data>
<data name="SenderLogin_Title" xml:space="preserve"><value>Sender Anmeldung</value></data>
<data name="SenderLogin_Username_Placeholder" xml:space="preserve"><value>Benutzername eingeben</value></data>
<data name="ServerError" xml:space="preserve"><value>Serverfehler.</value></data>
<data name="Signed" xml:space="preserve"><value>Unterschrieben</value></data>
<data name="SignedDocument" xml:space="preserve"><value>Signiertes Dokument</value></data>
<data name="System" xml:space="preserve"><value>System</value></data>
<data name="UnexpectedErrorTryAgainLater" xml:space="preserve"><value>Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.</value></data>
<data name="UploadPdf" xml:space="preserve"><value>PDF hochladen</value></data>
<data name="UserName" xml:space="preserve"><value>Benutzername</value></data>
<data name="VerifyInProgress" xml:space="preserve"><value>Überprüfen ...</value></data>
</root>
</root>

View File

@@ -651,123 +651,4 @@
<data name="Status" xml:space="preserve">
<value>Status</value>
</data>
<data name="ChangedAt" xml:space="preserve"><value>Changed at</value></data>
<data name="Close" xml:space="preserve"><value>Close</value></data>
<data name="CloseLanguageMenu" xml:space="preserve"><value>Close language menu</value></data>
<data name="ConfirmSignature" xml:space="preserve"><value>Confirm signature</value></data>
<data name="Created" xml:space="preserve"><value>Created</value></data>
<data name="Delete" xml:space="preserve"><value>Delete</value></data>
<data name="Document" xml:space="preserve"><value>Document</value></data>
<data name="DocumentCouldNotBeLoaded" xml:space="preserve"><value>Document could not be loaded</value></data>
<data name="DocumentLoading" xml:space="preserve"><value>Document is loading...</value></data>
<data name="EmailAddress" xml:space="preserve"><value>Email address</value></data>
<data name="Envelope" xml:space="preserve"><value>Envelope</value></data>
<data name="Error" xml:space="preserve"><value>Error</value></data>
<data name="ErrorLoadingDocument_WithReason" xml:space="preserve"><value>Error loading document: {0}</value></data>
<data name="ErrorPage_DevelopmentMode" xml:space="preserve"><value>Development Mode</value></data>
<data name="ErrorPage_DevelopmentMode_Detail" xml:space="preserve"><value>This can result in sensitive exception information being shown to end users. For local debugging, set ASPNETCORE_ENVIRONMENT to Development and restart the app.</value></data>
<data name="ErrorPage_DevelopmentMode_Info" xml:space="preserve"><value>Switching to Development will display more detailed error information.</value></data>
<data name="ErrorPage_DevelopmentMode_Warning" xml:space="preserve"><value>The Development environment should not be enabled in deployed applications.</value></data>
<data name="ErrorPage_ProcessingRequest" xml:space="preserve"><value>An error occurred while processing your request.</value></data>
<data name="ErrorPage_RequestId" xml:space="preserve"><value>Request ID</value></data>
<data name="ErrorWithReason" xml:space="preserve"><value>Error: {0}</value></data>
<data name="From" xml:space="preserve"><value>From</value></data>
<data name="FullName" xml:space="preserve"><value>First and last name</value></data>
<data name="History" xml:space="preserve"><value>History</value></data>
<data name="Index_DigitalSignature" xml:space="preserve"><value>Digital signature</value></data>
<data name="Index_PdfExport" xml:space="preserve"><value>PDF export</value></data>
<data name="Index_SecureAccess" xml:space="preserve"><value>Secure access</value></data>
<data name="Index_Subtitle" xml:space="preserve"><value>Welcome to the eSign portal</value></data>
<data name="Index_ToEnvelopeOverview" xml:space="preserve"><value>Go to envelope overview</value></data>
<data name="Languages" xml:space="preserve"><value>Languages</value></data>
<data name="Loading" xml:space="preserve"><value>Loading...</value></data>
<data name="Login" xml:space="preserve"><value>Sign in</value></data>
<data name="LoginInProgress" xml:space="preserve"><value>Signing in ...</value></data>
<data name="NotAuthenticatedPleaseLoginAgain" xml:space="preserve"><value>Not authenticated. Please sign in again.</value></data>
<data name="Only" xml:space="preserve"><value>Only</value></data>
<data name="OpenDocument" xml:space="preserve"><value>Open document</value></data>
<data name="Optional" xml:space="preserve"><value>optional</value></data>
<data name="Pending" xml:space="preserve"><value>Pending</value></data>
<data name="Preview" xml:space="preserve"><value>Preview</value></data>
<data name="PrivacyPolicy" xml:space="preserve"><value>Privacy policy</value></data>
<data name="Receiver" xml:space="preserve"><value>Receiver</value></data>
<data name="ReceiverLogin_AccessCode_Placeholder" xml:space="preserve"><value>Enter access code</value></data>
<data name="ReceiverLogin_Description" xml:space="preserve"><value>Please enter the access code you received by email to securely open the document.</value></data>
<data name="ReceiverLogin_FooterHelp" xml:space="preserve"><value>If you have issues, please contact the document sender.</value></data>
<data name="ReceiverLogin_InvalidCode_Body" xml:space="preserve"><value>The entered code is incorrect. Please try again.</value></data>
<data name="ReceiverLogin_InvalidCode_Title" xml:space="preserve"><value>Invalid access code.</value></data>
<data name="ReceiverLogin_NotFound_Body" xml:space="preserve"><value>The provided access code could not be matched to a document. Please verify the link in your email.</value></data>
<data name="ReceiverLogin_NotFound_Title" xml:space="preserve"><value>Document not found.</value></data>
<data name="ReceiverLogin_Subtitle" xml:space="preserve"><value>Secure access with access code</value></data>
<data name="ReceiverSigned_ConfirmBody" xml:space="preserve"><value>This action cannot be undone. By confirming, you declare that you have electronically signed the document shown above. The signed document will then be delivered to all involved parties.</value></data>
<data name="ReceiverSigned_ConfirmQuestion" xml:space="preserve"><value>Do you want to sign this document as binding?</value></data>
<data name="ReceiverSigned_EnvelopeKeyMissing" xml:space="preserve"><value>Envelope key is missing.</value></data>
<data name="Refresh" xml:space="preserve"><value>Refresh</value></data>
<data name="RequiredField" xml:space="preserve"><value>Required field</value></data>
<data name="Selected" xml:space="preserve"><value>selected</value></data>
<data name="Send" xml:space="preserve"><value>Send</value></data>
<data name="Sender_ActiveEnvelopes" xml:space="preserve"><value>Active envelopes</value></data>
<data name="Sender_CompletedEnvelopes" xml:space="preserve"><value>Completed envelopes</value></data>
<data name="Sender_CreateEnvelope" xml:space="preserve"><value>New envelope</value></data>
<data name="Sender_CreateEnvelope_Title" xml:space="preserve"><value>Create new envelope</value></data>
<data name="Sender_DeleteEnvelope_Header" xml:space="preserve"><value>Delete envelope</value></data>
<data name="Sender_DeleteEnvelope_ReasonPlaceholder" xml:space="preserve"><value>Enter reason...</value></data>
<data name="Sender_DeleteEnvelope_ReasonPrompt" xml:space="preserve"><value>Please provide a reason why the envelope should be deleted/withdrawn.</value></data>
<data name="Sender_DeleteEnvelope_ReasonRequired" xml:space="preserve"><value>Please provide a deletion reason.</value></data>
<data name="Sender_DeleteEnvelope_Title" xml:space="preserve"><value>Delete selected envelope</value></data>
<data name="Sender_Edit" xml:space="preserve"><value>Edit</value></data>
<data name="Sender_EditEnvelope_Title" xml:space="preserve"><value>Edit selected envelope</value></data>
<data name="Sender_ExportCurrentView_Title" xml:space="preserve"><value>Export current view</value></data>
<data name="Sender_ExportFormat_Aria" xml:space="preserve"><value>Select export format</value></data>
<data name="Sender_ExportView_Title" xml:space="preserve"><value>Export current table view</value></data>
<data name="Sender_LoadError_Title" xml:space="preserve"><value>Error loading envelopes</value></data>
<data name="Sender_LoadingEnvelopes" xml:space="preserve"><value>Loading envelopes...</value></data>
<data name="Sender_NoHistory" xml:space="preserve"><value>No history available</value></data>
<data name="Sender_NoReceivers" xml:space="preserve"><value>No receivers</value></data>
<data name="Sender_Preview_Title" xml:space="preserve"><value>Show selected envelope as PDF</value></data>
<data name="Sender_PreviewDocumentNotFound" xml:space="preserve"><value>The document for envelope #{0} was not found or you do not have access.</value></data>
<data name="SenderEditor_AddSignature" xml:space="preserve"><value>Add signature</value></data>
<data name="SenderEditor_ClickDocumentFor" xml:space="preserve"><value>Click on the location in the document for</value></data>
<data name="SenderEditor_EmailSuggestionsHint" xml:space="preserve"><value>Previously used email addresses are suggested while typing.</value></data>
<data name="SenderEditor_EmailSuggestionsLoading" xml:space="preserve"><value>Loading email suggestions...</value></data>
<data name="SenderEditor_ErrorCreatingEnvelope" xml:space="preserve"><value>Error creating envelope</value></data>
<data name="SenderEditor_LoadEnvelopeError" xml:space="preserve"><value>Error loading envelope #{0}: {1}</value></data>
<data name="SenderEditor_LoadFileError" xml:space="preserve"><value>Error loading file: {0}</value></data>
<data name="SenderEditor_Message_Placeholder" xml:space="preserve"><value>Please sign the attached document.</value></data>
<data name="SenderEditor_NoDocumentLoaded" xml:space="preserve"><value>No document loaded</value></data>
<data name="SenderEditor_NoReceiversAdded" xml:space="preserve"><value>No receivers added yet.</value></data>
<data name="SenderEditor_PlaceSignatureField" xml:space="preserve"><value>Place signature field</value></data>
<data name="SenderEditor_RemoveAllSignatureFields" xml:space="preserve"><value>Remove all signature fields</value></data>
<data name="SenderEditor_SelectPdf" xml:space="preserve"><value>Please select a PDF file.</value></data>
<data name="SenderEditor_Sent_Body" xml:space="preserve"><value>The existing draft has been sent.</value></data>
<data name="SenderEditor_Sent_Header" xml:space="preserve"><value>Envelope sent</value></data>
<data name="SenderEditor_Sent_Title" xml:space="preserve"><value>Envelope was sent successfully.</value></data>
<data name="SenderEditor_SignatureField" xml:space="preserve"><value>Signature field</value></data>
<data name="SenderEditor_Title_Placeholder" xml:space="preserve"><value>e.g. Employment contract 2025</value></data>
<data name="SenderEditor_ToOverview" xml:space="preserve"><value>To overview</value></data>
<data name="SenderEditor_UploadPdfToPlaceFields" xml:space="preserve"><value>Upload a PDF file to place signature fields.</value></data>
<data name="SenderEditor_Validation_EmailDuplicate" xml:space="preserve"><value>This email address has already been added.</value></data>
<data name="SenderEditor_Validation_EmailInvalid" xml:space="preserve"><value>Please enter a valid email address.</value></data>
<data name="SenderEditor_Validation_EmailRequired" xml:space="preserve"><value>Please enter an email address.</value></data>
<data name="SenderEditor_Validation_FullNameRequired" xml:space="preserve"><value>Please enter first and last name.</value></data>
<data name="SenderEditor_Validation_ReceiverRequired" xml:space="preserve"><value>Please add at least one receiver.</value></data>
<data name="SenderEditor_Validation_SignatureFieldRequired" xml:space="preserve"><value>Please place at least one signature field.</value></data>
<data name="SenderEditor_Validation_TitleRequired" xml:space="preserve"><value>Please enter a title.</value></data>
<data name="SenderEditor_Validation_UploadPdf" xml:space="preserve"><value>Please upload a PDF document first.</value></data>
<data name="SenderLogin_Description" xml:space="preserve"><value>Please sign in with your credentials to access the sender dashboard.</value></data>
<data name="SenderLogin_FooterHelp" xml:space="preserve"><value>If you have issues, please contact the administrator.</value></data>
<data name="SenderLogin_InvalidCredentials_Body" xml:space="preserve"><value>Username or password is incorrect. Please try again.</value></data>
<data name="SenderLogin_InvalidCredentials_Title" xml:space="preserve"><value>Invalid credentials.</value></data>
<data name="SenderLogin_Password_Placeholder" xml:space="preserve"><value>Enter password</value></data>
<data name="SenderLogin_Subtitle" xml:space="preserve"><value>Secure access to sender dashboard</value></data>
<data name="SenderLogin_Title" xml:space="preserve"><value>Sender login</value></data>
<data name="SenderLogin_Username_Placeholder" xml:space="preserve"><value>Enter username</value></data>
<data name="ServerError" xml:space="preserve"><value>Server error.</value></data>
<data name="Signed" xml:space="preserve"><value>Signed</value></data>
<data name="SignedDocument" xml:space="preserve"><value>Signed document</value></data>
<data name="System" xml:space="preserve"><value>System</value></data>
<data name="UnexpectedErrorTryAgainLater" xml:space="preserve"><value>An unexpected error occurred. Please try again later.</value></data>
<data name="UploadPdf" xml:space="preserve"><value>Upload PDF</value></data>
<data name="UserName" xml:space="preserve"><value>Username</value></data>
<data name="VerifyInProgress" xml:space="preserve"><value>Verifying ...</value></data>
</root>
</root>

View File

@@ -651,123 +651,4 @@
<data name="Status" xml:space="preserve">
<value>Statut</value>
</data>
<data name="ChangedAt" xml:space="preserve"><value>Modifié le</value></data>
<data name="Close" xml:space="preserve"><value>Fermer</value></data>
<data name="CloseLanguageMenu" xml:space="preserve"><value>Fermer le menu des langues</value></data>
<data name="ConfirmSignature" xml:space="preserve"><value>Confirmer la signature</value></data>
<data name="Created" xml:space="preserve"><value>Créé</value></data>
<data name="Delete" xml:space="preserve"><value>Supprimer</value></data>
<data name="Document" xml:space="preserve"><value>Document</value></data>
<data name="DocumentCouldNotBeLoaded" xml:space="preserve"><value>Le document n'a pas pu être chargé</value></data>
<data name="DocumentLoading" xml:space="preserve"><value>Chargement du document...</value></data>
<data name="EmailAddress" xml:space="preserve"><value>Adresse e-mail</value></data>
<data name="Envelope" xml:space="preserve"><value>Enveloppe</value></data>
<data name="Error" xml:space="preserve"><value>Erreur</value></data>
<data name="ErrorLoadingDocument_WithReason" xml:space="preserve"><value>Erreur lors du chargement du document : {0}</value></data>
<data name="ErrorPage_DevelopmentMode" xml:space="preserve"><value>Mode développement</value></data>
<data name="ErrorPage_DevelopmentMode_Detail" xml:space="preserve"><value>Cela peut afficher des informations sensibles d'exception aux utilisateurs finaux. Pour le débogage local, définissez ASPNETCORE_ENVIRONMENT sur Development et redémarrez l'application.</value></data>
<data name="ErrorPage_DevelopmentMode_Info" xml:space="preserve"><value>Le mode développement affiche des informations d'erreur plus détaillées.</value></data>
<data name="ErrorPage_DevelopmentMode_Warning" xml:space="preserve"><value>Le mode développement ne doit pas être activé dans les applications déployées.</value></data>
<data name="ErrorPage_ProcessingRequest" xml:space="preserve"><value>Une erreur s'est produite lors du traitement de votre demande.</value></data>
<data name="ErrorPage_RequestId" xml:space="preserve"><value>ID de requête</value></data>
<data name="ErrorWithReason" xml:space="preserve"><value>Erreur : {0}</value></data>
<data name="From" xml:space="preserve"><value>De</value></data>
<data name="FullName" xml:space="preserve"><value>Nom et prénom</value></data>
<data name="History" xml:space="preserve"><value>Historique</value></data>
<data name="Index_DigitalSignature" xml:space="preserve"><value>Signature numérique</value></data>
<data name="Index_PdfExport" xml:space="preserve"><value>Export PDF</value></data>
<data name="Index_SecureAccess" xml:space="preserve"><value>Accès sécurisé</value></data>
<data name="Index_Subtitle" xml:space="preserve"><value>Bienvenue sur le portail eSign</value></data>
<data name="Index_ToEnvelopeOverview" xml:space="preserve"><value>Aller à l'aperçu des enveloppes</value></data>
<data name="Languages" xml:space="preserve"><value>Langues</value></data>
<data name="Loading" xml:space="preserve"><value>Chargement...</value></data>
<data name="Login" xml:space="preserve"><value>Connexion</value></data>
<data name="LoginInProgress" xml:space="preserve"><value>Connexion ...</value></data>
<data name="NotAuthenticatedPleaseLoginAgain" xml:space="preserve"><value>Non authentifié. Veuillez vous reconnecter.</value></data>
<data name="Only" xml:space="preserve"><value>Uniquement</value></data>
<data name="OpenDocument" xml:space="preserve"><value>Ouvrir le document</value></data>
<data name="Optional" xml:space="preserve"><value>optionnel</value></data>
<data name="Pending" xml:space="preserve"><value>En attente</value></data>
<data name="Preview" xml:space="preserve"><value>Aperçu</value></data>
<data name="PrivacyPolicy" xml:space="preserve"><value>Politique de confidentialité</value></data>
<data name="Receiver" xml:space="preserve"><value>Destinataire</value></data>
<data name="ReceiverLogin_AccessCode_Placeholder" xml:space="preserve"><value>Saisir le code d'accès</value></data>
<data name="ReceiverLogin_Description" xml:space="preserve"><value>Veuillez saisir le code d'accès reçu par e-mail pour ouvrir le document en toute sécurité.</value></data>
<data name="ReceiverLogin_FooterHelp" xml:space="preserve"><value>En cas de problème, veuillez contacter l'expéditeur du document.</value></data>
<data name="ReceiverLogin_InvalidCode_Body" xml:space="preserve"><value>Le code saisi est incorrect. Veuillez réessayer.</value></data>
<data name="ReceiverLogin_InvalidCode_Title" xml:space="preserve"><value>Code d'accès invalide.</value></data>
<data name="ReceiverLogin_NotFound_Body" xml:space="preserve"><value>Le code d'accès indiqué ne correspond à aucun document. Veuillez vérifier le lien dans votre e-mail.</value></data>
<data name="ReceiverLogin_NotFound_Title" xml:space="preserve"><value>Document introuvable.</value></data>
<data name="ReceiverLogin_Subtitle" xml:space="preserve"><value>Accès sécurisé avec code d'accès</value></data>
<data name="ReceiverSigned_ConfirmBody" xml:space="preserve"><value>Cette action est irréversible. En confirmant, vous déclarez avoir signé électroniquement le document affiché ci-dessus. Le document signé sera ensuite transmis à toutes les parties concernées.</value></data>
<data name="ReceiverSigned_ConfirmQuestion" xml:space="preserve"><value>Voulez-vous signer ce document de manière contraignante ?</value></data>
<data name="ReceiverSigned_EnvelopeKeyMissing" xml:space="preserve"><value>La clé d'enveloppe est manquante.</value></data>
<data name="Refresh" xml:space="preserve"><value>Actualiser</value></data>
<data name="RequiredField" xml:space="preserve"><value>Champ obligatoire</value></data>
<data name="Selected" xml:space="preserve"><value>sélectionnées</value></data>
<data name="Send" xml:space="preserve"><value>Envoyer</value></data>
<data name="Sender_ActiveEnvelopes" xml:space="preserve"><value>Enveloppes actives</value></data>
<data name="Sender_CompletedEnvelopes" xml:space="preserve"><value>Enveloppes terminées</value></data>
<data name="Sender_CreateEnvelope" xml:space="preserve"><value>Nouvelle enveloppe</value></data>
<data name="Sender_CreateEnvelope_Title" xml:space="preserve"><value>Créer une nouvelle enveloppe</value></data>
<data name="Sender_DeleteEnvelope_Header" xml:space="preserve"><value>Supprimer l'enveloppe</value></data>
<data name="Sender_DeleteEnvelope_ReasonPlaceholder" xml:space="preserve"><value>Saisir la raison...</value></data>
<data name="Sender_DeleteEnvelope_ReasonPrompt" xml:space="preserve"><value>Veuillez indiquer la raison pour laquelle l'enveloppe doit être supprimée/retirée.</value></data>
<data name="Sender_DeleteEnvelope_ReasonRequired" xml:space="preserve"><value>Veuillez indiquer un motif de suppression.</value></data>
<data name="Sender_DeleteEnvelope_Title" xml:space="preserve"><value>Supprimer l'enveloppe sélectionnée</value></data>
<data name="Sender_Edit" xml:space="preserve"><value>Modifier</value></data>
<data name="Sender_EditEnvelope_Title" xml:space="preserve"><value>Modifier l'enveloppe sélectionnée</value></data>
<data name="Sender_ExportCurrentView_Title" xml:space="preserve"><value>Exporter la vue actuelle</value></data>
<data name="Sender_ExportFormat_Aria" xml:space="preserve"><value>Sélectionner le format d'export</value></data>
<data name="Sender_ExportView_Title" xml:space="preserve"><value>Exporter la vue actuelle du tableau</value></data>
<data name="Sender_LoadError_Title" xml:space="preserve"><value>Erreur lors du chargement des enveloppes</value></data>
<data name="Sender_LoadingEnvelopes" xml:space="preserve"><value>Chargement des enveloppes...</value></data>
<data name="Sender_NoHistory" xml:space="preserve"><value>Aucun historique disponible</value></data>
<data name="Sender_NoReceivers" xml:space="preserve"><value>Aucun destinataire</value></data>
<data name="Sender_Preview_Title" xml:space="preserve"><value>Afficher l'enveloppe sélectionnée en PDF</value></data>
<data name="Sender_PreviewDocumentNotFound" xml:space="preserve"><value>Le document de l'enveloppe #{0} est introuvable ou vous n'avez pas l'autorisation d'y accéder.</value></data>
<data name="SenderEditor_AddSignature" xml:space="preserve"><value>Ajouter une signature</value></data>
<data name="SenderEditor_ClickDocumentFor" xml:space="preserve"><value>Cliquez sur l'emplacement dans le document pour</value></data>
<data name="SenderEditor_EmailSuggestionsHint" xml:space="preserve"><value>Les adresses e-mail déjà utilisées sont proposées pendant la saisie.</value></data>
<data name="SenderEditor_EmailSuggestionsLoading" xml:space="preserve"><value>Chargement des suggestions d'e-mail...</value></data>
<data name="SenderEditor_ErrorCreatingEnvelope" xml:space="preserve"><value>Erreur lors de la création de l'enveloppe</value></data>
<data name="SenderEditor_LoadEnvelopeError" xml:space="preserve"><value>Erreur lors du chargement de l'enveloppe #{0} : {1}</value></data>
<data name="SenderEditor_LoadFileError" xml:space="preserve"><value>Erreur lors du chargement du fichier : {0}</value></data>
<data name="SenderEditor_Message_Placeholder" xml:space="preserve"><value>Veuillez signer le document ci-joint.</value></data>
<data name="SenderEditor_NoDocumentLoaded" xml:space="preserve"><value>Aucun document chargé</value></data>
<data name="SenderEditor_NoReceiversAdded" xml:space="preserve"><value>Aucun destinataire ajouté pour le moment.</value></data>
<data name="SenderEditor_PlaceSignatureField" xml:space="preserve"><value>Placer un champ de signature</value></data>
<data name="SenderEditor_RemoveAllSignatureFields" xml:space="preserve"><value>Supprimer tous les champs de signature</value></data>
<data name="SenderEditor_SelectPdf" xml:space="preserve"><value>Veuillez sélectionner un fichier PDF.</value></data>
<data name="SenderEditor_Sent_Body" xml:space="preserve"><value>Le brouillon existant a été envoyé.</value></data>
<data name="SenderEditor_Sent_Header" xml:space="preserve"><value>Enveloppe envoyée</value></data>
<data name="SenderEditor_Sent_Title" xml:space="preserve"><value>L'enveloppe a été envoyée avec succès.</value></data>
<data name="SenderEditor_SignatureField" xml:space="preserve"><value>Champ de signature</value></data>
<data name="SenderEditor_Title_Placeholder" xml:space="preserve"><value>p. ex. Contrat de travail 2025</value></data>
<data name="SenderEditor_ToOverview" xml:space="preserve"><value>Vers l'aperçu</value></data>
<data name="SenderEditor_UploadPdfToPlaceFields" xml:space="preserve"><value>Téléversez un fichier PDF pour placer des champs de signature.</value></data>
<data name="SenderEditor_Validation_EmailDuplicate" xml:space="preserve"><value>Cette adresse e-mail a déjà été ajoutée.</value></data>
<data name="SenderEditor_Validation_EmailInvalid" xml:space="preserve"><value>Veuillez saisir une adresse e-mail valide.</value></data>
<data name="SenderEditor_Validation_EmailRequired" xml:space="preserve"><value>Veuillez saisir une adresse e-mail.</value></data>
<data name="SenderEditor_Validation_FullNameRequired" xml:space="preserve"><value>Veuillez saisir le nom et le prénom.</value></data>
<data name="SenderEditor_Validation_ReceiverRequired" xml:space="preserve"><value>Veuillez ajouter au moins un destinataire.</value></data>
<data name="SenderEditor_Validation_SignatureFieldRequired" xml:space="preserve"><value>Veuillez placer au moins un champ de signature.</value></data>
<data name="SenderEditor_Validation_TitleRequired" xml:space="preserve"><value>Veuillez saisir un titre.</value></data>
<data name="SenderEditor_Validation_UploadPdf" xml:space="preserve"><value>Veuillez d'abord téléverser un document PDF.</value></data>
<data name="SenderLogin_Description" xml:space="preserve"><value>Veuillez vous connecter avec vos identifiants pour accéder au tableau de bord expéditeur.</value></data>
<data name="SenderLogin_FooterHelp" xml:space="preserve"><value>En cas de problème, veuillez contacter l'administrateur.</value></data>
<data name="SenderLogin_InvalidCredentials_Body" xml:space="preserve"><value>Le nom d'utilisateur ou le mot de passe est incorrect. Veuillez réessayer.</value></data>
<data name="SenderLogin_InvalidCredentials_Title" xml:space="preserve"><value>Identifiants invalides.</value></data>
<data name="SenderLogin_Password_Placeholder" xml:space="preserve"><value>Saisir le mot de passe</value></data>
<data name="SenderLogin_Subtitle" xml:space="preserve"><value>Accès sécurisé au tableau de bord expéditeur</value></data>
<data name="SenderLogin_Title" xml:space="preserve"><value>Connexion expéditeur</value></data>
<data name="SenderLogin_Username_Placeholder" xml:space="preserve"><value>Saisir le nom d'utilisateur</value></data>
<data name="ServerError" xml:space="preserve"><value>Erreur serveur.</value></data>
<data name="Signed" xml:space="preserve"><value>Signé</value></data>
<data name="SignedDocument" xml:space="preserve"><value>Document signé</value></data>
<data name="System" xml:space="preserve"><value>Système</value></data>
<data name="UnexpectedErrorTryAgainLater" xml:space="preserve"><value>Une erreur inattendue s'est produite. Veuillez réessayer plus tard.</value></data>
<data name="UploadPdf" xml:space="preserve"><value>Téléverser un PDF</value></data>
<data name="UserName" xml:space="preserve"><value>Nom d'utilisateur</value></data>
<data name="VerifyInProgress" xml:space="preserve"><value>Vérification ...</value></data>
</root>
</root>

View File

@@ -1,7 +1,6 @@
Imports System.IO
Imports DigitalData.Modules.Database
Imports DigitalData.Modules.Logging
Imports EnvelopeGenerator.CommonServices
Imports EnvelopeGenerator.CommonServices.Jobs
Imports EnvelopeGenerator.CommonServices.Jobs.FinalizeDocument
Imports GdPicture14
@@ -146,91 +145,64 @@ Public Class frmFinalizePDF
log.AppendLine($"Document bytes: {oBuffer.Length}")
' 3. Check what BurnAnnotsToPDF will do internally
Dim oEnvelopeEntity As Envelope = Nothing
Using scope = Factory.Shared.ScopeFactory.CreateScope()
Dim envRepo = scope.ServiceProvider.Repository(Of Envelope)()
oEnvelopeEntity = envRepo.Where(Function(env) env.Id = envelopeId).FirstOrDefault()
Dim envelope = envRepo.Where(Function(env) env.Id = envelopeId).FirstOrDefault()
If oEnvelopeEntity Is Nothing Then
If envelope Is Nothing Then
log.AppendLine("ERROR: Envelope not found in EF Core!")
txtResult.Text = log.ToString()
Return
End If
log.AppendLine($"Envelope found: Id={oEnvelopeEntity.Id}, EnvelopeTypeId={oEnvelopeEntity.EnvelopeTypeId}")
log.AppendLine($"ReadOnly (IsReadAndConfirm): {oEnvelopeEntity.ReadOnly}")
log.AppendLine($"Envelope found: Id={envelope.Id}, EnvelopeTypeId={envelope.EnvelopeTypeId}")
log.AppendLine($"ReadOnly (IsReadAndConfirm): {envelope.ReadOnly}")
If oEnvelopeEntity.ReadOnly Then
log.AppendLine(">>> ReadOnly=True: original PDF will be used without burning (annotations skipped)")
If envelope.ReadOnly Then
log.AppendLine(">>> EARLY RETURN: ReadOnly=True, original PDF returned without burning")
txtResult.Text = log.ToString()
Return
End If
Dim sigRepo = scope.ServiceProvider.Repository(Of DocReceiverElement)()
Dim elements = sigRepo _
.Where(Function(sig) sig.Document.EnvelopeId = envelopeId) _
.Include(Function(sig) sig.Annotations) _
.ToList()
log.AppendLine($"Elements (Signature) count: {elements.Count}")
If elements.Any() Then
log.AppendLine(">>> PATH: BurnElementAnnotsToPDF (new element-based path)")
For Each elem In elements
Dim annotCount = If(elem.Annotations IsNot Nothing, elem.Annotations.Count(), 0)
log.AppendLine($" Element Id={elem.Id}, Page={elem.Page}, X={elem.X}, Y={elem.Y}, W={elem.Width}, H={elem.Height}, Annotations={annotCount}")
If elem.Annotations IsNot Nothing Then
For Each annot In elem.Annotations
log.AppendLine($" Annot: Name={annot.Name}, Type={annot.Type}, X={annot.X}, Y={annot.Y}, W={annot.Width}, H={annot.Height}")
Next
End If
Next
Else
Dim sigRepo = scope.ServiceProvider.Repository(Of DocReceiverElement)()
Dim elements = sigRepo _
.Where(Function(sig) sig.Document.EnvelopeId = envelopeId) _
.Include(Function(sig) sig.Annotations) _
.ToList()
log.AppendLine($"Elements (Signature) count: {elements.Count}")
If elements.Any() Then
log.AppendLine(">>> PATH: BurnElementAnnotsToPDF (new element-based path)")
For Each elem In elements
Dim annotCount = If(elem.Annotations IsNot Nothing, elem.Annotations.Count(), 0)
log.AppendLine($" Element Id={elem.Id}, Page={elem.Page}, X={elem.X}, Y={elem.Y}, W={elem.Width}, H={elem.Height}, Annotations={annotCount}")
If elem.Annotations IsNot Nothing Then
For Each annot In elem.Annotations
log.AppendLine($" Annot: Name={annot.Name}, Type={annot.Type}, X={annot.X}, Y={annot.Y}, W={annot.Width}, H={annot.Height}")
Next
End If
Next
Else
log.AppendLine(">>> PATH: BurnInstantJSONAnnotsToPDF (old JSON-based path)")
End If
log.AppendLine(">>> PATH: BurnInstantJSONAnnotsToPDF (old JSON-based path)")
End If
End Using
' 4. Burn annotations (ReadOnly envelopes return original PDF unchanged)
' 4. Actually call BurnAnnotsToPDF (same as Service)
log.AppendLine("")
log.AppendLine("Calling BurnAnnotsToPDF...")
Dim oBurnedBuffer As Byte() = PDFBurner.BurnAnnotsToPDF(oBuffer, oJsonList, envelopeId)
log.AppendLine($"Burned bytes: {oBurnedBuffer.Length}")
log.AppendLine($"Unchanged (ReadOnly): {oBuffer.Length = oBurnedBuffer.Length AndAlso oBuffer.SequenceEqual(oBurnedBuffer)}")
Dim oNewBuffer = PDFBurner.BurnAnnotsToPDF(oBuffer, oJsonList, envelopeId)
log.AppendLine($"Result bytes: {oNewBuffer.Length}")
log.AppendLine($"Same as input: {oBuffer.Length = oNewBuffer.Length AndAlso oBuffer.SequenceEqual(oNewBuffer)}")
' 5. Build State and load Envelope domain model for ReportCreator
log.AppendLine("")
log.AppendLine("Loading envelope domain model for report...")
Dim oState As New State() With {
.Database = Database,
.LogConfig = LogConfig
}
Dim oEnvelopeModel As New EnvelopeModel(oState)
Dim oEnvelope = oEnvelopeModel.GetById(envelopeId)
If oEnvelope Is Nothing Then
log.AppendLine("ERROR: Envelope not found via EnvelopeModel!")
txtResult.Text = log.ToString()
Return
End If
log.AppendLine($"Envelope loaded: {oEnvelope.Id}")
' 6. Create signing certificate report
log.AppendLine("Creating signing certificate report...")
Dim oReportCreator As New ReportCreator(LogConfig, oState)
Dim oReportBuffer As Byte() = oReportCreator.CreateReport(oEnvelope)
log.AppendLine($"Report bytes: {oReportBuffer.Length}")
' 7. Merge burned document + report (last page)
log.AppendLine("Merging documents...")
Dim oMerger As New PDFMerger(LogConfig, pGDPictureLicenseKey)
Dim oMergedBuffer As Byte() = oMerger.MergeDocuments(oBurnedBuffer, oReportBuffer)
log.AppendLine($"Merged bytes: {oMergedBuffer.Length}")
' 8. Save and open
' 5. Write output
Dim desktopPath As String = Environment.GetFolderPath(Environment.SpecialFolder.Desktop)
Dim oMergedPath = Path.Combine(desktopPath, $"E{txtEnvelope.Text}_FullTest.merged.pdf")
File.WriteAllBytes(oMergedPath, oMergedBuffer)
log.AppendLine($"Output: {oMergedPath}")
Dim oNewPath = Path.Combine(desktopPath, $"E{txtEnvelope.Text}_FullTest.burned.pdf")
File.WriteAllBytes(oNewPath, oNewBuffer)
log.AppendLine($"Output: {oNewPath}")
txtResult.Text = log.ToString()
Process.Start(oMergedPath)
Process.Start(oNewPath)
Catch ex As Exception
txtResult.Text = $"ERROR: {ex.Message}{vbCrLf}{vbCrLf}{ex.ToString()}"

View File

@@ -119,7 +119,7 @@ Namespace Jobs.FinalizeDocument
Dim y = frameY + yOffsetofFF
If annot.Type = AnnotationType.FormField Then
AddFormFieldValue(annot.X / inchFactor, y, annot.Width / inchFactor, annot.Height / inchFactor, element.Page, annot.Value, annot.Name)
AddFormFieldValue(annot.X / inchFactor, y, annot.Width / inchFactor, annot.Height / inchFactor, element.Page, annot.Value)
ElseIf annot.Type = AnnotationType.Image Then
AddImageAnnotation(
annot.X / inchFactor,
@@ -269,14 +269,11 @@ Namespace Jobs.FinalizeDocument
Next
End Sub
Private Sub AddFormFieldValue(x As Double, y As Double, width As Double, height As Double, page As Integer, value As String, Optional egName As String = "")
Private Sub AddFormFieldValue(x As Double, y As Double, width As Double, height As Double, page As Integer, value As String)
Manager.SelectPage(page)
' Truncate if needed
Dim displayValue As String = TruncateText(value, egName)
' Add the text annotation
Dim ant = Manager.AddTextAnnot(x, y, width, height, displayValue)
Dim ant = Manager.AddTextAnnot(x, y, width, height, value)
' Set the font properties
ant.FontName = _pdfBurnerParams.FontName
@@ -297,9 +294,8 @@ Namespace Jobs.FinalizeDocument
Dim oHeight = oBounds.Item(3)
Manager.SelectPage(pAnnotation.pageIndex + 1)
' Add the text annotation (truncate if needed)
Dim displayValue As String = TruncateText(formFieldValue.value, pAnnotation.egName)
Dim ant = Manager.AddTextAnnot(oX, oY, oWidth, oHeight, displayValue)
' Add the text annotation
Dim ant = Manager.AddTextAnnot(oX, oY, oWidth, oHeight, formFieldValue.value)
' Set the font properties
ant.FontName = _pdfBurnerParams.FontName
@@ -310,48 +306,6 @@ Namespace Jobs.FinalizeDocument
#End Region
#Region "Helpers"
''' <summary>
''' Truncates a field value so its rendered width fits within the configured max width (inches).
''' Uses Graphics.MeasureString with the configured font for pixel-accurate measurement.
''' Appends TruncationSuffix when the value is cut.
''' </summary>
Private Function TruncateText(value As String, fieldEgName As String) As String
If String.IsNullOrEmpty(value) Then Return value
If _pdfBurnerParams.TextMaxWidths Is Nothing Then Return value
Dim maxWidthInches As Single = 0
If Not _pdfBurnerParams.TextMaxWidths.TryGetValue(fieldEgName, maxWidthInches) Then Return value
If maxWidthInches <= 0 Then Return value
Dim suffix As String = If(_pdfBurnerParams.TruncationSuffix, "")
Using bmp As New Bitmap(1, 1)
Using g As Graphics = Graphics.FromImage(bmp)
Dim font As New Font(_pdfBurnerParams.FontName, _pdfBurnerParams.FontSize, _pdfBurnerParams.FontStyle, GraphicsUnit.Point)
Dim maxWidthPx As Single = maxWidthInches * g.DpiX
' Already fits — no truncation needed
If g.MeasureString(value, font).Width <= maxWidthPx Then Return value
' Binary search for the longest prefix that fits (with suffix)
Dim lo As Integer = 0
Dim hi As Integer = value.Length
While lo < hi
Dim mid As Integer = (lo + hi + 1) \ 2
Dim candidate As String = value.Substring(0, mid) & suffix
If g.MeasureString(candidate, font).Width <= maxWidthPx Then
lo = mid
Else
hi = mid - 1
End If
End While
Return value.Substring(0, lo) & suffix
End Using
End Using
End Function
Private Function ToPointF(pPoints As List(Of Single)) As PointF
Dim oPoints = pPoints.Select(AddressOf ToInches).ToList()
Return New PointF(oPoints.Item(0), oPoints.Item(1))

View File

@@ -12,24 +12,5 @@ Namespace Jobs.FinalizeDocument
Public Property FontSize As Integer = 8
Public Property FontStyle As FontStyle = FontStyle.Italic
''' <summary>
''' The suffix appended to a truncated field value. Defaults to "..".
''' Can be changed to any string (e.g. "…", "...", "").
''' </summary>
Public Property TruncationSuffix As String = ".."
''' <summary>
''' Per-field maximum render width in inches.
''' Text is measured using the configured Font/FontSize via Graphics.MeasureString (pixel-accurate).
''' If the rendered width exceeds the limit, the text is cut and TruncationSuffix is appended.
''' Keys must match egName values used in PDFBurner (e.g. "name", "position", "city", "date").
''' Set a value to 0 to disable truncation for that field.
''' </summary>
Public Property TextMaxWidths As New Dictionary(Of String, Single) From {
{"position", 1.41F},
{"city", 1.41F},
{"date", 2.0F}
}
End Class
End Namespace

View File

@@ -1,6 +1,5 @@
Imports EnvelopeGenerator.Domain.Constants
Imports EnvelopeGenerator.Domain.Entities
Imports EnvelopeGenerator.Domain.Interfaces
Public Class ReportItem
@@ -12,15 +11,8 @@ Public Class ReportItem
Public Property ItemStatus As EnvelopeStatus
Public ReadOnly Property ItemStatusTranslated As String
Get
Dim oStatusKey = ItemStatus.ToString()
If ItemStatus = EnvelopeStatus.DocumentSigned AndAlso
Envelope IsNot Nothing AndAlso
Envelope.IsReadAndConfirm() Then
oStatusKey = "DocumentSignedRaC"
End If
Return My.Resources.Model.ResourceManager.GetString(oStatusKey)
Dim oStatus = ItemStatus.ToString()
Return My.Resources.Model.ResourceManager.GetString(oStatus)
End Get
End Property

View File

@@ -168,9 +168,6 @@
<data name="DocumentSigned" xml:space="preserve">
<value>Document signed</value>
</data>
<data name="DocumentSignedRaC" xml:space="preserve">
<value>Read and confirmed</value>
</data>
<data name="Draft" xml:space="preserve">
<value>Draft</value>
</data>

View File

@@ -168,9 +168,6 @@
<data name="DocumentSigned" xml:space="preserve">
<value>Dokument unterzeichnet</value>
</data>
<data name="DocumentSignedRaC" xml:space="preserve">
<value>Gelesen und bestätigt</value>
</data>
<data name="Draft" xml:space="preserve">
<value>Entwurf</value>
</data>

View File

@@ -1,6 +1,5 @@
#if NETFRAMEWORK
using System;
using System.Collections.Generic;
#endif
namespace EnvelopeGenerator.Domain.Constants
@@ -16,11 +15,7 @@ namespace EnvelopeGenerator.Domain.Constants
public static class Receiver
{
public const string TFA = "EGReceiverTFA";
[Obsolete("Use Receiver.Authorized")]
public const string Full = "EGReceiver";
public static readonly IReadOnlyList<string> Authorized = new[] { "receiver", "EGReceiver" };
}
public const string Sender = "EGSender";

View File

@@ -213,15 +213,6 @@ namespace My.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to Gelesen und bestätigt.
/// </summary>
public static string DocumentSignedRaC {
get {
return ResourceManager.GetString("DocumentSignedRaC", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Entwurf.
/// </summary>

View File

@@ -1,7 +0,0 @@
namespace EnvelopeGenerator.Domain.Resources
{
// Marker type for IStringLocalizer lookup of Resources/Model.resx.
public class Model
{
}
}

View File

@@ -1,116 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="AccessCodeIncorrect" xml:space="preserve">
<value>Code d'acces incorrect</value>
</data>
<data name="DocumentRejected" xml:space="preserve">
<value>Document rejete</value>
</data>
<data name="DocumentSigned" xml:space="preserve">
<value>Document signe</value>
</data>
<data name="Draft" xml:space="preserve">
<value>Brouillon</value>
</data>
<data name="EnvelopeCompletelySigned" xml:space="preserve">
<value>Entierement signe</value>
</data>
<data name="EnvelopeCreated" xml:space="preserve">
<value>Enveloppe creee</value>
</data>
<data name="EnvelopeDeleted" xml:space="preserve">
<value>Enveloppe supprimee</value>
</data>
<data name="EnvelopePartlySigned" xml:space="preserve">
<value>Partiellement signee</value>
</data>
<data name="EnvelopeQueued" xml:space="preserve">
<value>Enveloppe en file d'attente</value>
</data>
<data name="EnvelopeRejected" xml:space="preserve">
<value>Enveloppe rejetee</value>
</data>
<data name="EnvelopeSaved" xml:space="preserve">
<value>Enregistree</value>
</data>
<data name="EnvelopeSent" xml:space="preserve">
<value>Envoyee</value>
</data>
<data name="EnvelopeWithdrawn" xml:space="preserve">
<value>Enveloppe retiree</value>
</data>
<data name="MessageAccessCodeSent" xml:space="preserve">
<value>Code d'acces envoye</value>
</data>
<data name="MessageCompletionSent" xml:space="preserve">
<value>E-mail de cloture envoye</value>
</data>
<data name="MessageConfirmationSent" xml:space="preserve">
<value>Confirmation finale envoyee</value>
</data>
<data name="MessageDeletionSent" xml:space="preserve">
<value>Notification de suppression envoyee</value>
</data>
<data name="MessageInvitationSent" xml:space="preserve">
<value>Invitation envoyee</value>
</data>
</root>

View File

@@ -168,9 +168,6 @@
<data name="DocumentSigned" xml:space="preserve">
<value>Dokument unterzeichnet</value>
</data>
<data name="DocumentSignedRaC" xml:space="preserve">
<value>Gelesen und bestätigt</value>
</data>
<data name="Draft" xml:space="preserve">
<value>Entwurf</value>
</data>

View File

@@ -14,7 +14,6 @@ using EnvelopeGenerator.Infrastructure.Repositories;
using EnvelopeGenerator.Infrastructure.Executor;
using EnvelopeGenerator.Application.Common.Interfaces.Repositories;
using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor;
using EnvelopeGenerator.Application.Common.Interfaces.Services;
using DigitalData.EmailProfilerDispatcher.Abstraction.Entities;
#elif NETFRAMEWORK
using System;
@@ -56,9 +55,6 @@ namespace EnvelopeGenerator.Infrastructure
services.TryAddScoped<IEnvelopeTypeRepository, EnvelopeTypeRepository>();
services.TryAddScoped<IReceiverRepository, ReceiverRepository>();
services.TryAddScoped<IEnvelopeReceiverReadOnlyRepository, EnvelopeReceiverReadOnlyRepository>();
// User Preferences — distributed cache (dbo.TBDD_CACHE)
services.TryAddScoped<IUserPreferencesService, UserPreferencesService>();
#endif
services.AddDbRepository(opt =>
@@ -214,4 +210,4 @@ namespace EnvelopeGenerator.Infrastructure
#endif
}
}
}
}

View File

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

View File

@@ -26,7 +26,6 @@
<PackageReference Include="DigitalData.Core.Infrastructure" Version="2.6.1" />
<PackageReference Include="HtmlSanitizer" Version="9.0.892" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.2" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.6" />
<PackageReference Include="Microsoft.Identity.Client" Version="4.82.1" />
<PackageReference Include="QuestPDF" Version="2025.7.1" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />

View File

@@ -1,46 +0,0 @@
#if NET
using System.Text.Json;
using Microsoft.Extensions.Caching.Distributed;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Interfaces.Services;
namespace EnvelopeGenerator.Infrastructure;
/// <summary>
/// Implements user preferences storage using the distributed cache (dbo.TBDD_CACHE).
/// No DB schema changes required — uses the existing SQL Server distributed cache table.
/// Cache key schema: "signflow.user-preferences:{username}"
/// Expiration: indefinite (no TTL) — preferences persist until explicitly overwritten.
/// </summary>
public sealed class UserPreferencesService(IDistributedCache cache) : IUserPreferencesService
{
private const string KeyPrefix = "signflow.user-preferences:";
private static string BuildKey(string id) => $"{KeyPrefix}{id}";
/// <inheritdoc />
public async Task<UserPreferencesDto> GetOrCreateAsync(string id, CancellationToken ct = default)
{
var json = await cache.GetStringAsync(BuildKey(id), ct);
if(json is not null && JsonSerializer.Deserialize<UserPreferencesDto>(json) is UserPreferencesDto pref)
{
return pref;
}
else
{
// init new
await SaveAsync(id, UserPreferencesDto.Default, ct);
return UserPreferencesDto.Default;
}
}
/// <inheritdoc />
public async Task SaveAsync(string id, UserPreferencesDto preferences, CancellationToken ct = default)
{
var json = JsonSerializer.Serialize(preferences);
// No expiration — preferences are stored indefinitely.
await cache.SetStringAsync(BuildKey(id), json, new DistributedCacheEntryOptions(), ct);
}
}
#endif

View File

@@ -0,0 +1,12 @@
@using System.Globalization
<Router AppAssembly="@typeof(Program).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
</Found>
<NotFound>
<LayoutView Layout="@typeof(MainLayout)">
<p>Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>

View File

@@ -0,0 +1,44 @@
namespace EnvelopeGenerator.ReceiverUI.Data {
public class Adjustment
{
public static Adjustment CreateBalanceForward(DateTime dt, int random)
{
var rnd = new DeterministicRandom(random);
Adjustment res = new Adjustment();
res.currentDateTime = dt;
res.currentDescription = "Balance Forward";
res.currentAmount = rnd.Random(10, 300) * 10;
return res;
}
public static Adjustment CreatePayment(DateTime dt, int random)
{
var rnd = new DeterministicRandom(random);
Adjustment res = new Adjustment();
res.currentDateTime = dt;
res.currentDescription = "Payment";
res.currentAmount = -rnd.Random(1, 40) * 10;
return res;
}
public static Adjustment CreateCharge(DateTime dt, int random)
{
var rnd = new DeterministicRandom(random);
Adjustment res = new Adjustment();
res.currentDateTime = dt;
res.currentDescription = rnd.GetRandomItem(bills);
res.currentAmount = rnd.Random(10, 50) * 10;
return res;
}
DateTime currentDateTime;
string currentDescription = "";
double currentAmount = 0;
static readonly string[] bills = new string[] { "Bill - Insurance", "Bill - Electricity", "Bill - Rent", "Bill - Phone", "Bill - Office Supplies" };
public DateTime Date { get { return currentDateTime; } }
public string Description { get { return currentDescription; } }
public double Amount { get { return currentAmount; } }
public Adjustment()
{
}
}
}

View File

@@ -0,0 +1,64 @@
using DevExpress.DataAccess.Sql;
using DevExpress.DataAccess.Sql.DataApi;
namespace EnvelopeGenerator.ReceiverUI.Data {
public class Customer {
static List<Customer> currentCustomers = new List<Customer>();
public static List<Customer> Customers { get { return currentCustomers; } }
static Customer() {
try {
SqlDataSource ds = new SqlDataSource("NWindConnectionString");
SelectQuery query = SelectQueryFluentBuilder
.AddTable("Customers")
.SelectAllColumns()
.Build("Customers");
ds.Queries.Add(query);
ds.RebuildResultSchema();
ds.Fill();
ITable src = ds.Result["Customers"];
foreach(var row in src) {
currentCustomers.Add(new Customer() {
CustomerID = row.GetValue<string>("CustomerID"),
Address = row.GetValue<string>("Address"),
CompanyName = row.GetValue<string>("CompanyName"),
ContactName = row.GetValue<string>("ContactName"),
ContactTitle = row.GetValue<string>("ContactTitle"),
Country = row.GetValue<string>("Country"),
City = row.GetValue<string>("City"),
Fax = row.GetValue<string>("Fax"),
Phone = row.GetValue<string>("Phone"),
PostalCode = row.GetValue<string>("PostalCode"),
Region = row.GetValue<string>("Region")
});
}
} catch {
currentCustomers.Add(new Customer() {
Address = "Obere Str. 57",
City = "Berlin",
CompanyName = "Alfreds Futterkiste",
ContactName = "Maria Anders",
ContactTitle = "Sales Representative",
Country = "Germany",
CustomerID = "ALFKI",
Fax = "030-0076545",
Phone = "030-0074321",
PostalCode = "12209"
});
}
}
public string CustomerID { get; set; }
public string CompanyName { get; set; }
public string ContactName { get; set; }
public string ContactTitle { get; set; }
public string Address { get; set; }
public string City { get; set; }
public string PostalCode { get; set; }
public string Region { get; set; }
public string Country { get; set; }
public string Phone { get; set; }
public string Fax { get; set; }
}
}

View File

@@ -0,0 +1,71 @@
namespace EnvelopeGenerator.ReceiverUI.Data {
public class DataItem {
static readonly string[] accountType = new string[] { "Energy", "Manufacturing", "Estate", "Food", "Services" };
public string CustomerID { get; set; }
public string CompanyName { get; set; }
public string ContactName { get; set; }
public string ContactTitle { get; set; }
public string Address { get; set; }
public string City { get; set; }
public string PostalCode { get; set; }
public string Region { get; set; }
public string Country { get; set; }
public string Phone { get; set; }
public string Fax { get; set; }
public string Email { get; set; }
public string Invoice { get; set; }
public string CustomerAccount { get; set; }
public string CustomerIdentifiers { get; set; }
public DateTime BillingDate { get; set; }
public DateTime BillingPeriodStart { get; set; }
public DateTime BillingPeriodEnd { get; set; }
public string Terms { get; set; }
public string TermsID { get; set; }
public Adjustment[] Adjustments { get; set; }
public DataItem(int i) {
var rnd = new DeterministicRandom(i);
Customer c = rnd.GetRandomItem(Customer.Customers);
CustomerID = c.CustomerID;
CompanyName = c.CompanyName;
ContactName = c.ContactName;
ContactTitle = c.ContactTitle;
Address = c.Address;
City = c.City;
PostalCode = c.PostalCode;
Region = c.Region;
Country = c.Country;
Phone = c.Phone;
Fax = c.Fax;
Email = ContactName.Split(' ')[0].Replace(' ', '.').ToLower() + "@" + CompanyName.Split(' ')[0].ToLower() + ".com";
Invoice = string.Format("{0}{1}-{2}", rnd.RandomChar, rnd.Random(100, 1000), rnd.Random(100, 1000));
CustomerAccount = rnd.GetRandomItem(accountType);
CustomerIdentifiers = string.Format("{0}-{1}", rnd.Random(1000, 10000), rnd.Random(10, 100));
BillingPeriodStart = rnd.RandomTime();
BillingPeriodEnd = rnd.RandomTime(BillingPeriodStart, 7 * 24, 30 * 24);
BillingDate = rnd.RandomTime(BillingPeriodEnd, 7 * 24, 30 * 24);
Term currentTerm = rnd.GetRandomItem(Term.Terms);
Terms = currentTerm.Name;
int adjustmentsCount = rnd.Random(6) + 4;
Adjustments = new Adjustment[adjustmentsCount];
int h = (int)((BillingPeriodEnd - BillingPeriodStart).TotalHours / adjustmentsCount);
Adjustments[0] = Adjustment.CreateBalanceForward(rnd.RandomTime(BillingPeriodStart, 0, h), rnd.Random(10000));
int[] items = rnd.RandomList(adjustmentsCount - 1, 2);
for(int j = 1; j < Adjustments.Length; j++) {
DateTime nextDate = rnd.RandomTime(BillingPeriodStart.AddHours(h * j), 0, h);
switch(items[j - 1]) {
case 0:
Adjustments[j] = Adjustment.CreateCharge(nextDate, rnd.Random(10000));
break;
case 1:
Adjustments[j] = Adjustment.CreatePayment(nextDate, rnd.Random(10000));
break;
}
}
}
}
}

View File

@@ -0,0 +1,70 @@
using System.Collections;
namespace EnvelopeGenerator.ReceiverUI.Data {
public class DataItemList : IList<DataItem>, IList {
readonly int rowCount;
public DataItem this[int index] { get { return new DataItem(index); } set { } }
public int Count { get { return rowCount; } }
public bool IsReadOnly { get { return false; } }
public bool IsFixedSize { get { return false; } }
public object SyncRoot { get { return true; } }
public bool IsSynchronized { get { return true; } }
object IList.this[int index] { get { return new DataItem(index); } set { } }
public DataItemList(int rowCount) {
this.rowCount = rowCount;
}
public IEnumerator<DataItem> GetEnumerator() {
throw new NotImplementedException();
}
public int Add(object value) {
throw new NotImplementedException();
}
public bool Contains(object value) {
throw new NotImplementedException();
}
public void Clear() {
throw new NotImplementedException();
}
public int IndexOf(object value) {
throw new NotImplementedException();
}
public void Insert(int index, object value) {
throw new NotImplementedException();
}
public void Remove(object value) {
throw new NotImplementedException();
}
public void RemoveAt(int index) {
throw new NotImplementedException();
}
public void CopyTo(Array array, int index) {
throw new NotImplementedException();
}
IEnumerator IEnumerable.GetEnumerator() {
throw new NotImplementedException();
}
public int IndexOf(DataItem item) {
throw new NotImplementedException();
}
public void Insert(int index, DataItem item) {
throw new NotImplementedException();
}
public void Add(DataItem item) {
throw new NotImplementedException();
}
public bool Contains(DataItem item) {
throw new NotImplementedException();
}
public void CopyTo(DataItem[] array, int arrayIndex) {
throw new NotImplementedException();
}
public bool Remove(DataItem item) {
throw new NotImplementedException();
}
void ICollection<DataItem>.CopyTo(DataItem[] array, int arrayIndex) {
CopyTo(array, arrayIndex);
}
}
}

View File

@@ -0,0 +1,60 @@
namespace EnvelopeGenerator.ReceiverUI.Data {
class DeterministicRandom {
const int randomCount = 10000;
static readonly int[] deterministicRandomNumbers;
static readonly DateTime time;
int rnd;
int Next {
get {
rnd = deterministicRandomNumbers[rnd % randomCount];
return rnd;
}
}
public char RandomChar {
get {
return (char)((int)'A' + Random(0, 26));
}
}
public int[] RandomList(int count, int to) {
int[] res = new int[count];
for(int i = 0; i < Math.Min(count, to); i++)
res[i] = i;
for(int i = to; i < count; i++)
res[i] = Random(to);
for(int i = 0; i < count; i++) {
int ind = Random(count);
int temp = res[ind];
res[ind] = res[i];
res[i] = temp;
}
return res;
}
public int Random(int to) {
return Random(0, to);
}
public int Random(int from, int to) {
return Next % Math.Max(1, to - from) + from;
}
public T GetRandomItem<T>(IList<T> list) {
return list[Next % list.Count];
}
public DateTime RandomTime() {
return RandomTime(time, 0, 30 * 24);
}
public DateTime RandomTime(DateTime from, int fromHours, int toHours) {
return from.AddHours(Next % (toHours - fromHours) + fromHours);
}
static DeterministicRandom() {
time = DateTime.Now.AddDays(-62);
Random currentRandom = new Random(randomCount);
deterministicRandomNumbers = new int[randomCount];
for(int i = 0; i < randomCount; i++)
deterministicRandomNumbers[i] = currentRandom.Next(randomCount);
}
public DeterministicRandom(int i) {
this.rnd = i + (i >> 10) + (i >> 20);
}
}
}

View File

@@ -0,0 +1,15 @@
namespace EnvelopeGenerator.ReceiverUI.Data {
public struct Term {
public static readonly Term[] Terms = new Term[] {
new Term("Payment seven days after invoice date" ),
new Term("Payment ten days after invoice date" ),
new Term("End of month" ),
new Term("21st of the month following invoice date" ),
};
readonly string currentName;
public string Name { get { return currentName; } }
public Term(string currentName) {
this.currentName = currentName;
}
}
}

View File

@@ -0,0 +1,56 @@
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<WasmBuildNative>true</WasmBuildNative>
<InvariantGlobalization>false</InvariantGlobalization>
<BlazorWebAssemblyLoadAllGlobalizationData>true</BlazorWebAssemblyLoadAllGlobalizationData>
<Nullable>enable</Nullable>
<PackageId>EnvelopeGenerator.ReceiverUI</PackageId>
<Authors>Digital Data GmbH</Authors>
<Company>Digital Data GmbH</Company>
<Product>EnvelopeGenerator.ReceiverUI</Product>
<PackageIcon>Assets\icon.ico</PackageIcon>
<PackageTags>digital data envelope generator web</PackageTags>
<Description>EnvelopeGenerator.ReceiverUI is a Blazor WebAssembly application developed to manage signing processes. It uses Entity Framework Core (EF Core) for database operations. The user interface for signing processes is developed with Razor View Engine (.cshtml files) and JavaScript under wwwroot, integrated with PSPDFKit. This integration allows users to view and sign documents seamlessly.</Description>
<Version>1.4.2</Version>
<!-- NuGet package version -->
<AssemblyVersion>1.4.2.0</AssemblyVersion>
<!-- Assembly version for API compatibility -->
<FileVersion>1.4.2.0</FileVersion>
<!-- Windows file version -->
<Copyright>Copyright © 2026 Digital Data GmbH. All rights reserved.</Copyright>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DevExpress.Blazor.PdfViewer" Version="25.2.8" />
<PackageReference Include="DevExpress.Blazor.Reporting.JSBasedControls" Version="25.2.8" />
<PackageReference Include="DevExpress.Blazor.Reporting.Viewer" Version="25.2.8" />
<PackageReference Include="DevExpress.Drawing.Skia" Version="25.2.8" />
<PackageReference Include="HarfBuzzSharp.NativeAssets.WebAssembly" Version="8.3.1.2" />
<PackageReference Include="Microsoft.AspNetCore.WebUtilities" Version="8.0.28" />
<PackageReference Include="Microsoft.Extensions.Localization" Version="8.0.11" />
<PackageReference Include="SkiaSharp.NativeAssets.WebAssembly" Version="3.119.1" />
<PackageReference Include="SkiaSharp.Views.Blazor" Version="3.119.1" />
<NativeFileReference Include="$(HarfBuzzSharpStaticLibraryPath)\2.0.23\*.a" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="8.0.11" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="8.0.11" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<Folder Include="Properties\PublishProfiles\" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\EnvelopeGenerator.Application\EnvelopeGenerator.Application.csproj" />
</ItemGroup>
<ItemGroup>
<Content Update="wwwroot\docs\privacy-policy.en-US.html">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Update="wwwroot\docs\privacy-policy.fr-FR.html">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,8 @@
namespace EnvelopeGenerator.ReceiverUI.Models.Constants
{
public enum SenderAppType
{
LegacyFormApp = 0,
ReceiverUIBlazorApp = 1
}
}

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