Compare commits
35
Commits
0ab08fd50b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24b94b05c2 | ||
|
|
4443bba63c | ||
|
|
4145ab699c | ||
|
|
e2faf52cd2 | ||
|
|
546648533b | ||
|
|
d0002ccdad | ||
|
|
0a8384ab33 | ||
|
|
c741535aec | ||
|
|
7357f60120 | ||
|
|
63b3683725 | ||
|
|
a36f0d859d | ||
|
|
4bf56c9699 | ||
|
|
a334497c82 | ||
|
|
65034ba2e8 | ||
|
|
e628454030 | ||
|
|
9569c1b65e | ||
|
|
249a945f5e | ||
|
|
86f6a60860 | ||
|
|
944a5bb442 | ||
|
|
a60d4c2eaf | ||
|
|
dcfc459693 | ||
|
|
4cac109283 | ||
|
|
99bf5337f8 | ||
|
|
b5613bee0d | ||
|
|
bcf1f2dbb6 | ||
|
|
a1e6002c3b | ||
|
|
fce83c528f | ||
|
|
38e0d55b09 | ||
|
|
2f39440b05 | ||
|
|
b26c24ef98 | ||
|
|
8bcec409c5 | ||
|
|
1a5ac73f3c | ||
|
|
c757533503 | ||
|
|
107b7f54f1 | ||
|
|
f36f65789e |
@@ -0,0 +1,7 @@
|
||||
root = true
|
||||
|
||||
[*.cs]
|
||||
# CA1416: Validate platform compatibility. This app targets Windows only (System.Drawing,
|
||||
# registry, etc. usage throughout Fuchs/OCORE); the platform-compat analyzer's warnings here
|
||||
# are expected noise, not signal. See CLAUDE.md "Build & Test" for the pre-existing-warnings note.
|
||||
dotnet_diagnostic.CA1416.severity = none
|
||||
@@ -13,8 +13,10 @@
|
||||
- **Fuchs Intranet** is an ASP.NET Core (.NET 10) web application — the intranet IS the entire website, served from `/`.
|
||||
- Routes: `/{fn?}/{id?}/{code?}` → `IntranetController.Index`; `/do/{fn?}/{id?}/{code?}` → `IntranetController.Do`.
|
||||
- Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`.
|
||||
- Build the database project whenever anything under `Fuchs_Database/` changes: `msbuild Fuchs_Database/FuchsDatabase.sqlproj -p:Configuration=Debug` (MSBuild from the Visual Studio install, e.g. `"C:\Program Files\Microsoft Visual Studio\18\Community\MSBuild\Current\Bin\MSBuild.exe"`; `dotnet build` cannot build the legacy SSDT project). T-SQL warnings are build errors there (ADR 0036): an unresolved or ambiguous column (SQL71502) fails the build, and a red database build is a failure like a red C# build. The working-tree SQL is **not** on any database until it is published, so this build is the only check the SQL gets before review.
|
||||
- Frontend assets are source-built: run the gulp tasks in `Fuchs/` (`npx gulp min`, or `npx gulp all` when copied/static assets also need refreshing) whenever JS or SCSS/CSS sources change. The generated files under `Fuchs/wwwroot/web/` are what the app serves.
|
||||
- Test: `dotnet test Fuchs.Tests/Fuchs.Tests.csproj -c Debug`.
|
||||
- Test: `dotnet test --project Fuchs.Tests/Fuchs.Tests.csproj -c Debug` (the root `global.json` opts `dotnet test` into the Microsoft.Testing.Platform runner that xunit.v3 needs on the .NET 10 SDK; the positional-path form no longer works).
|
||||
- Always build **and** run the test suite before committing. CA1416 (platform-compatibility) is suppressed solution-wide via the root `.editorconfig` — the app is Windows-only. The build may still emit other pre-existing analyzer warnings; only treat `: error` lines as failures.
|
||||
- Submodules include the OCORE projects and `eRechnungLib` (ZUGFeRD/Factur-X + XRechnung generation) — invoices are moving to eRechnung output.
|
||||
- Project structure (relative to `Fuchs/`):
|
||||
- `Controllers/` — `IntranetController` partials (no area)
|
||||
@@ -48,7 +50,7 @@
|
||||
|
||||
## Services & Dependency Injection
|
||||
- Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces; inject them into `IntranetController` (constructor injection). Do **not** reintroduce static God-classes or pass the whole controller into helpers.
|
||||
- `IComService` (email/SMS via ProcessWeb Mailer API, attachments sent inline as base64; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService` (MigraDoc render), `IInvoiceService`, `IReminderService`, `IReportService` (SQL report engine via `FuchsVisualization`), `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService` (Admin module diagnostics: config snapshot + DB/Key Vault/blob/MFR connectivity probes + test-email; restricted to `fds_sys` > 4).
|
||||
- `IComService` (email/SMS via ProcessWeb Mailer API — contract at **https://processweb.de/api/mailer/fc**, read it and `Fuchs/Docs/Concepts/processweb-mailer-interface.md` before changing it. `ProcessWebComService` is a thin adapter over the OCORE_web `IPWMailerClientService` (`OCORE_web/comm/pwmailer/`, owns the HTTP contract; ADR 0029) with `FuchsMailerClient` overriding `ValidateMessage` for attachment limits; a send only counts as successful when the receipt's `attachmentsProcessed` matches the number sent (ADR 0028); honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService` (MigraDoc render), `IInvoiceService`, `IReminderService`, `IReportService` (SQL report engine via `FuchsVisualization`), `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService` (Admin module diagnostics: config snapshot + DB/Key Vault/blob/MFR connectivity probes + test-email; restricted to `fds_sys` > 4).
|
||||
- Lifetimes: stateless services (`IPdfService`, `IBankingService`, `IMfrClientFactory`) are singletons; request-scoped DB services (`IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IComService`, `ISystemStatusService`) are scoped. Register in `Program.cs`.
|
||||
- `FdsInvoiceData` / `FdsReminderData` are **pure data holders** (parse + properties). Loading, persistence and PDF generation belong in the services — never `Task.Run(...).Wait()` sync-over-async.
|
||||
- Data access stays SQL-first via OCORE helpers (`getSQLDataSet_async`, `setSQLValue_async`) + stored procedures; no EF Core.
|
||||
@@ -60,6 +62,8 @@
|
||||
## Database
|
||||
- The SQL schema source of truth is the **`Fuchs_Database`** SSDT project. The backend is SQL-first (stored procedures, table types like `fds__tt__bankingtransactions`, functions via OCORE helpers — no EF Core).
|
||||
- When you change a stored proc name/params or a table type, update **both** the SSDT project and the calling C# in the same change. Verify every `[dbo].[…]` the backend calls actually exists in `Fuchs_Database`.
|
||||
- Objects the SQL references must be **in the project** (a referenced-but-missing function only shows up as an SSDT build error now; the `ocms_*` helpers are vendored copies from the OCMSsharp `ocms_mt_database` project). `Security/Logins.sql` holds placeholder logins only so the users bind — real passwords live in Key Vault.
|
||||
- `Fuchs.Tests/SqlContractTests` checks every `[dbo].[…]` named in C# against the SSDT files (exists, `EXECUTE` binds, function arg counts; ADR 0036) — a proc signature change that breaks a caller fails the test suite.
|
||||
|
||||
## Bank statement parsing (MT940 + CAMT)
|
||||
- Two parsers feed the same banking pipeline: the external `MT940Parser` (SWIFT text) and the in-repo **`CAMTParser`** project (ISO 20022 camt.052/053/054 XML).
|
||||
@@ -68,8 +72,9 @@
|
||||
|
||||
## Observability
|
||||
- Use **OpenTelemetry**. The app's instrumentation is centralised in `Fuchs/Observability/FuchsTelemetry.cs` (one `ActivitySource` + one `Meter`).
|
||||
- When adding a meaningful operation: start an activity (`FuchsTelemetry.StartActivity(...)`), record the relevant counter/histogram, and log entry/result/timing/errors via the injected `ILogger<T>`. Prefer structured logging (named placeholders), never string interpolation in log messages.
|
||||
- When adding a meaningful operation: start an activity (`FuchsTelemetry.StartActivity(...)`), record the relevant counter/histogram, and log entry/result/timing/errors via the injected `ILogger<T>`. Prefer structured logging (named placeholders), never string interpolation in log messages — and never `{@Foo}`: plain `Microsoft.Extensions.Logging` (unlike Serilog) does not strip the `@`, it becomes a literal part of the key.
|
||||
- Tracing/metrics are always collected; OTLP export is opt-in via `Fuchs:Telemetry:OtlpEndpoint`. Don't add exporters that fail hard when no collector is present.
|
||||
- **DB log sink (ADR 0024):** every `_logger.LogWarning`/`-Error`/`-Critical` call additionally lands in `[dbo].[fds__debuglog]` via the OCORE MultiLogger SQL sink (`fds__admin_logdebug_ext`), gated by `Fuchs:Logging:SqlSink:Enabled` (default on). `RequestLogScopeMiddleware` (`Fuchs/Logging/`) attributes every log line in a request to a request id/path/authenticated user automatically. Give a log call an explicit `EventId` (its `Name` becomes the row's `CodeReference`) when the call site should be individually greppable — see `FIS_SQLOptions`, `IntranetController.Do`'s catch-all, `PdfPreviewAsync`, and the banking-upload handlers for the pattern. Do not also call the retired `Fuchs_intranet.debug_log` — one `ILogger` call is the single write path; a second explicit DB write duplicates the row.
|
||||
|
||||
## Testing
|
||||
- xUnit in `Fuchs.Tests`. Testing must be **extensive**, not superficial:
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
*.user
|
||||
*.suo
|
||||
|
||||
# Claude Code local settings (machine-specific, not shared)
|
||||
.claude/
|
||||
|
||||
# Build Output
|
||||
bin/
|
||||
obj/
|
||||
|
||||
Vendored
+24
-1
@@ -2,7 +2,7 @@
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Fuchs: Debug ASP.NET Core",
|
||||
"name": "Fuchs: Debug (Debug config, Development env)",
|
||||
"type": "coreclr",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "build Fuchs",
|
||||
@@ -23,6 +23,29 @@
|
||||
"sourceFileMap": {
|
||||
"/Views": "${workspaceFolder}/Fuchs/Views"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Fuchs: Launch (Release config, Production env)",
|
||||
"type": "coreclr",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "build Fuchs Release",
|
||||
"program": "${workspaceFolder}/Fuchs/bin/Release/net10.0/Fuchs.dll",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}/Fuchs",
|
||||
"stopAtEntry": false,
|
||||
"justMyCode": true,
|
||||
"serverReadyAction": {
|
||||
"action": "openExternally",
|
||||
"pattern": "\\bNow listening on:\\s+(https?://\\S+)",
|
||||
"uriFormat": "%s"
|
||||
},
|
||||
"env": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Production",
|
||||
"ASPNETCORE_URLS": "https://localhost:63661;http://localhost:63662"
|
||||
},
|
||||
"sourceFileMap": {
|
||||
"/Views": "${workspaceFolder}/Fuchs/Views"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
+15
@@ -19,6 +19,21 @@
|
||||
"isDefault": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "build Fuchs Release",
|
||||
"type": "process",
|
||||
"command": "dotnet",
|
||||
"args": [
|
||||
"build",
|
||||
"${workspaceFolder}/Fuchs/Fuchs.csproj",
|
||||
"--configuration",
|
||||
"Release",
|
||||
"/p:RestoreIgnoreFailedSources=true",
|
||||
"/p:RestoreConfigFile=${workspaceFolder}/NuGet.config"
|
||||
],
|
||||
"problemMatcher": "$msCompile",
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"label": "gulp: all",
|
||||
"type": "shell",
|
||||
|
||||
@@ -15,9 +15,10 @@
|
||||
|
||||
## Build & Test (workflow)
|
||||
- Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`.
|
||||
- Build the database project whenever anything under `Fuchs_Database/` changes: `msbuild Fuchs_Database/FuchsDatabase.sqlproj -p:Configuration=Debug` (MSBuild from the Visual Studio install, e.g. `"C:\Program Files\Microsoft Visual Studio\18\Community\MSBuild\Current\Bin\MSBuild.exe"`; `dotnet build` cannot build the legacy SSDT project). T-SQL warnings are build errors there (ADR 0036): an unresolved or ambiguous column (SQL71502) fails the build, and a red database build is a failure like a red C# build. The working-tree SQL is **not** on any database until it is published, so this build is the only check the SQL gets before review.
|
||||
- Frontend assets are source-built: run the gulp tasks in `Fuchs/` (`npx gulp min`, or `npx gulp all` when copied/static assets also need refreshing) whenever JS or SCSS/CSS sources change. The generated files under `Fuchs/wwwroot/web/` are what the app serves.
|
||||
- Test: `dotnet test Fuchs.Tests/Fuchs.Tests.csproj -c Debug`.
|
||||
- Always build **and** run the test suite before committing. The build emits many pre-existing analyzer/platform warnings (CA1416 etc.) — those are expected; only treat `: error` lines as failures.
|
||||
- Test: `dotnet test --project Fuchs.Tests/Fuchs.Tests.csproj -c Debug` (the root `global.json` opts `dotnet test` into the Microsoft.Testing.Platform runner that xunit.v3 needs on the .NET 10 SDK; the positional-path form no longer works).
|
||||
- Always build **and** run the test suite before committing. CA1416 (platform-compatibility) is suppressed solution-wide via the root `.editorconfig` — the app is Windows-only. The build may still emit other pre-existing analyzer warnings; only treat `: error` lines as failures.
|
||||
- Commit only when asked. Co-author trailer: `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`.
|
||||
- The working tree may contain an untracked `Fuchs_Database/` SQL project — it is **not** part of app changes; never `git add -A` it into an unrelated commit. Stage explicit paths.
|
||||
|
||||
@@ -38,7 +39,7 @@
|
||||
|
||||
## Services & Dependency Injection
|
||||
- Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces, injected into `IntranetController`. Do not reintroduce static God-classes or pass the controller into helpers.
|
||||
- Services: `IComService` (ProcessWeb Mailer API; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService` (Admin module diagnostics: config snapshot + DB/Key Vault/blob/MFR connectivity probes + test-email; restricted to `fds_sys` > 4). Stateless ones are singletons; DB/request-scoped ones are scoped (see `Program.cs`).
|
||||
- Services: `IComService` (ProcessWeb Mailer API — contract at **https://processweb.de/api/mailer/fc**, read it and `Fuchs/Docs/Concepts/processweb-mailer-interface.md` before changing it. `ProcessWebComService` is a thin adapter over the OCORE_web `IPWMailerClientService` (`OCORE_web/comm/pwmailer/`, owns the HTTP contract; ADR 0029) with `FuchsMailerClient` overriding `ValidateMessage` for attachment limits; a send only counts as successful when the receipt's `attachmentsProcessed` matches the number sent (ADR 0028); honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService` (Admin module diagnostics: config snapshot + DB/Key Vault/blob/MFR connectivity probes + test-email; restricted to `fds_sys` > 4). Stateless ones are singletons; DB/request-scoped ones are scoped (see `Program.cs`).
|
||||
- `FdsInvoiceData` / `FdsReminderData` are **pure data holders**; load/persist/render belongs in services. No `Task.Run(...).Wait()` sync-over-async.
|
||||
- Data access is SQL-first via OCORE helpers + stored procedures (no EF Core).
|
||||
|
||||
@@ -49,6 +50,8 @@
|
||||
## Database
|
||||
- Schema source of truth: **`Fuchs_Database`** SSDT project. SQL-first backend (stored procs, table types e.g. `fds__tt__bankingtransactions`, functions via OCORE — no EF Core).
|
||||
- Changing a proc signature or table type → update the SSDT project **and** the calling C# together; verify every `[dbo].[…]` the backend calls exists in `Fuchs_Database`.
|
||||
- Objects the SQL references must be **in the project** (a referenced-but-missing function only shows up as an SSDT build error now; the `ocms_*` helpers are vendored copies from the OCMSsharp `ocms_mt_database` project). `Security/Logins.sql` holds placeholder logins only so the users bind — real passwords live in Key Vault.
|
||||
- `Fuchs.Tests/SqlContractTests` checks every `[dbo].[…]` named in C# against the SSDT files (exists, `EXECUTE` binds, function arg counts; ADR 0036) — a proc signature change that breaks a caller fails the test suite.
|
||||
|
||||
## Bank statement parsing (MT940 + CAMT)
|
||||
- `MT940Parser` (external, SWIFT text) and **`CAMTParser`** (in-repo, ISO 20022 camt.052/053/054 XML) feed the same pipeline.
|
||||
@@ -56,8 +59,9 @@
|
||||
|
||||
## Observability
|
||||
- OpenTelemetry. Instrumentation is centralised in `Fuchs/Observability/FuchsTelemetry.cs` (one `ActivitySource`, one `Meter`).
|
||||
- For meaningful operations: start an activity, record the matching counter/histogram, and log entry/result/timing/errors via injected `ILogger<T>` using **structured** placeholders (never interpolated strings).
|
||||
- For meaningful operations: start an activity, record the matching counter/histogram, and log entry/result/timing/errors via injected `ILogger<T>` using **structured** placeholders (never interpolated strings) — never `{@Foo}`; plain `Microsoft.Extensions.Logging` (unlike Serilog) does not strip the `@`, it becomes a literal part of the key.
|
||||
- Always collected; OTLP export opt-in via `Fuchs:Telemetry:OtlpEndpoint`. No exporters that hard-fail without a collector.
|
||||
- **DB log sink (ADR 0024):** every `_logger.LogWarning`/`-Error`/`-Critical` call additionally lands in `[dbo].[fds__debuglog]` via the OCORE MultiLogger SQL sink (`fds__admin_logdebug_ext`), gated by `Fuchs:Logging:SqlSink:Enabled` (default on). `RequestLogScopeMiddleware` (`Fuchs/Logging/`) attributes every log line in a request to a request id/path/authenticated user automatically. Give a log call an explicit `EventId` (its `Name` becomes the row's `CodeReference`) when the call site should be individually greppable — see `FIS_SQLOptions`, `IntranetController.Do`'s catch-all, `PdfPreviewAsync`, and the banking-upload handlers for the pattern. Do not also call the retired `Fuchs_intranet.debug_log` — one `ILogger` call is the single write path; a second explicit DB write duplicates the row.
|
||||
|
||||
## Testing
|
||||
- xUnit in `Fuchs.Tests`. Testing must be **extensive**, not superficial:
|
||||
|
||||
@@ -15,9 +15,10 @@
|
||||
|
||||
## Build & Test (workflow)
|
||||
- Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`.
|
||||
- Build the database project whenever anything under `Fuchs_Database/` changes: `msbuild Fuchs_Database/FuchsDatabase.sqlproj -p:Configuration=Debug` (MSBuild from the Visual Studio install, e.g. `"C:\Program Files\Microsoft Visual Studio\18\Community\MSBuild\Current\Bin\MSBuild.exe"`; `dotnet build` cannot build the legacy SSDT project). T-SQL warnings are build errors there (ADR 0036): an unresolved or ambiguous column (SQL71502) fails the build, and a red database build is a failure like a red C# build. The working-tree SQL is **not** on any database until it is published, so this build is the only check the SQL gets before review.
|
||||
- Frontend assets are source-built: run the gulp tasks in `Fuchs/` (`npx gulp min`, or `npx gulp all` when copied/static assets also need refreshing) whenever JS or SCSS/CSS sources change. The generated files under `Fuchs/wwwroot/web/` are what the app serves.
|
||||
- Test: `dotnet test Fuchs.Tests/Fuchs.Tests.csproj -c Debug`.
|
||||
- Always build **and** run the test suite before committing. The build emits many pre-existing analyzer/platform warnings (CA1416 etc.) — those are expected; only treat `: error` lines as failures.
|
||||
- Test: `dotnet test --project Fuchs.Tests/Fuchs.Tests.csproj -c Debug` (the root `global.json` opts `dotnet test` into the Microsoft.Testing.Platform runner that xunit.v3 needs on the .NET 10 SDK; the positional-path form no longer works).
|
||||
- Always build **and** run the test suite before committing. CA1416 (platform-compatibility) is suppressed solution-wide via the root `.editorconfig` — the app is Windows-only. The build may still emit other pre-existing analyzer warnings; only treat `: error` lines as failures.
|
||||
- Commit only when asked. Co-author trailer, when requested: `Co-Authored-By: Codex <noreply@openai.com>`.
|
||||
- The working tree may contain an untracked `Fuchs_Database/` SQL project — it is **not** part of app changes; never `git add -A` it into an unrelated commit. Stage explicit paths.
|
||||
|
||||
@@ -38,7 +39,7 @@
|
||||
|
||||
## Services & Dependency Injection
|
||||
- Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces, injected into `IntranetController`. Do not reintroduce static God-classes or pass the controller into helpers.
|
||||
- Services: `IComService` (ProcessWeb Mailer API; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService` (Admin module diagnostics: config snapshot + DB/Key Vault/blob/MFR connectivity probes + test-email; restricted to `fds_sys` > 4). Stateless ones are singletons; DB/request-scoped ones are scoped (see `Program.cs`).
|
||||
- Services: `IComService` (ProcessWeb Mailer API — contract at **https://processweb.de/api/mailer/fc**, read it and `Fuchs/Docs/Concepts/processweb-mailer-interface.md` before changing it. `ProcessWebComService` is a thin adapter over the OCORE_web `IPWMailerClientService` (`OCORE_web/comm/pwmailer/`, owns the HTTP contract; ADR 0029) with `FuchsMailerClient` overriding `ValidateMessage` for attachment limits; a send only counts as successful when the receipt's `attachmentsProcessed` matches the number sent (ADR 0028); honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService` (Admin module diagnostics: config snapshot + DB/Key Vault/blob/MFR connectivity probes + test-email; restricted to `fds_sys` > 4). Stateless ones are singletons; DB/request-scoped ones are scoped (see `Program.cs`).
|
||||
- `FdsInvoiceData` / `FdsReminderData` are **pure data holders**; load/persist/render belongs in services. No `Task.Run(...).Wait()` sync-over-async.
|
||||
- Data access is SQL-first via OCORE helpers + stored procedures (no EF Core).
|
||||
|
||||
@@ -49,6 +50,8 @@
|
||||
## Database
|
||||
- Schema source of truth: **`Fuchs_Database`** SSDT project. SQL-first backend (stored procs, table types e.g. `fds__tt__bankingtransactions`, functions via OCORE — no EF Core).
|
||||
- Changing a proc signature or table type -> update the SSDT project **and** the calling C# together; verify every `[dbo].[…]` the backend calls exists in `Fuchs_Database`.
|
||||
- Objects the SQL references must be **in the project** (a referenced-but-missing function only shows up as an SSDT build error now; the `ocms_*` helpers are vendored copies from the OCMSsharp `ocms_mt_database` project). `Security/Logins.sql` holds placeholder logins only so the users bind — real passwords live in Key Vault.
|
||||
- `Fuchs.Tests/SqlContractTests` checks every `[dbo].[…]` named in C# against the SSDT files (exists, `EXECUTE` binds, function arg counts; ADR 0036) — a proc signature change that breaks a caller fails the test suite.
|
||||
|
||||
## Bank statement parsing (MT940 + CAMT)
|
||||
- `MT940Parser` (external, SWIFT text) and **`CAMTParser`** (in-repo, ISO 20022 camt.052/053/054 XML) feed the same pipeline.
|
||||
@@ -56,8 +59,9 @@
|
||||
|
||||
## Observability
|
||||
- OpenTelemetry. Instrumentation is centralised in `Fuchs/Observability/FuchsTelemetry.cs` (one `ActivitySource`, one `Meter`).
|
||||
- For meaningful operations: start an activity, record the matching counter/histogram, and log entry/result/timing/errors via injected `ILogger<T>` using **structured** placeholders (never interpolated strings).
|
||||
- For meaningful operations: start an activity, record the matching counter/histogram, and log entry/result/timing/errors via injected `ILogger<T>` using **structured** placeholders (never interpolated strings) — never `{@Foo}`; plain `Microsoft.Extensions.Logging` (unlike Serilog) does not strip the `@`, it becomes a literal part of the key.
|
||||
- Always collected; OTLP export opt-in via `Fuchs:Telemetry:OtlpEndpoint`. No exporters that hard-fail without a collector.
|
||||
- **DB log sink (ADR 0024):** every `_logger.LogWarning`/`-Error`/`-Critical` call additionally lands in `[dbo].[fds__debuglog]` via the OCORE MultiLogger SQL sink (`fds__admin_logdebug_ext`), gated by `Fuchs:Logging:SqlSink:Enabled` (default on). `RequestLogScopeMiddleware` (`Fuchs/Logging/`) attributes every log line in a request to a request id/path/authenticated user automatically. Give a log call an explicit `EventId` (its `Name` becomes the row's `CodeReference`) when the call site should be individually greppable — see `FIS_SQLOptions`, `IntranetController.Do`'s catch-all, `PdfPreviewAsync`, and the banking-upload handlers for the pattern. Do not also call the retired `Fuchs_intranet.debug_log` — one `ILogger` call is the single write path; a second explicit DB write duplicates the row.
|
||||
|
||||
## Testing
|
||||
- xUnit in `Fuchs.Tests`. Testing must be **extensive**, not superficial:
|
||||
|
||||
@@ -172,4 +172,203 @@ public class AzureBlobStorageServiceTests
|
||||
Assert.Null(result);
|
||||
Assert.True(delta >= 1, "fuchs.blobstorage.uploads.failed counter should have been incremented on a failed upload.");
|
||||
}
|
||||
|
||||
// ── Cancellation: a caller-cancelled token (app shutdown during the startup backfill) must
|
||||
// surface as OperationCanceledException, not be logged/reported as a storage failure ────
|
||||
[Fact]
|
||||
public async Task UploadInvoicePdfAsync_CallerCancelled_ThrowsOperationCanceled()
|
||||
{
|
||||
var (service, _, blob) = CreateMockedClientChain();
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
blob.Setup(b => b.UploadAsync(It.IsAny<Stream>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new TaskCanceledException("The operation was canceled."));
|
||||
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||
() => svc.UploadInvoicePdfAsync("INV9", "Rechnung_INV9.pdf", new byte[] { 1 }, null, cts.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadInvoicePdfAsync_TimeoutWithoutCallerCancel_ReturnsNull()
|
||||
{
|
||||
var (service, _, blob) = CreateMockedClientChain();
|
||||
blob.Setup(b => b.UploadAsync(It.IsAny<Stream>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new TaskCanceledException("network timeout"));
|
||||
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
|
||||
|
||||
Uri? result = await svc.UploadInvoicePdfAsync("INV9", "Rechnung_INV9.pdf", new byte[] { 1 });
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExistsAsync_CallerCancelled_ThrowsOperationCanceled()
|
||||
{
|
||||
var (service, _, blob) = CreateMockedClientChain();
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
blob.Setup(b => b.ExistsAsync(It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new TaskCanceledException("The operation was canceled."));
|
||||
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||
() => svc.ExistsAsync("fuchs-invoices", "INV9", "Rechnung_INV9.pdf", cts.Token));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)] // storage error
|
||||
[InlineData(true)] // timeout-style cancellation while the caller's token is still live
|
||||
public async Task ExistsAsync_CheckFailsWithoutCallerCancel_ReturnsFalse(bool timeout)
|
||||
{
|
||||
var (service, _, blob) = CreateMockedClientChain();
|
||||
Exception ex = timeout ? new TaskCanceledException("timeout") : new RequestFailedException("boom");
|
||||
blob.Setup(b => b.ExistsAsync(It.IsAny<CancellationToken>())).ThrowsAsync(ex);
|
||||
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
|
||||
|
||||
Assert.False(await svc.ExistsAsync("fuchs-invoices", "INV9", "Rechnung_INV9.pdf"));
|
||||
}
|
||||
|
||||
// ── CopyInvoicePdfToBackupAsync ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task CopyInvoicePdfToBackupAsync_NoClient_ReturnsNullWithoutThrowing()
|
||||
{
|
||||
var svc = new AzureBlobStorageService(client: null, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
|
||||
|
||||
Uri? result = await svc.CopyInvoicePdfToBackupAsync("INV1", "Rechnung_INV1.pdf");
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CopyInvoicePdfToBackupAsync_NoExistingBlob_ReturnsNullWithoutCopying()
|
||||
{
|
||||
var (service, container, blob) = CreateMockedClientChain();
|
||||
blob.Setup(b => b.ExistsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Response.FromValue(false, Mock.Of<Response>()));
|
||||
|
||||
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
|
||||
Uri? result = await svc.CopyInvoicePdfToBackupAsync("INV1", "Rechnung_INV1.pdf");
|
||||
|
||||
Assert.Null(result);
|
||||
blob.Verify(b => b.StartCopyFromUriAsync(It.IsAny<Uri>(), It.IsAny<BlobCopyFromUriOptions>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
blob.Verify(b => b.DeleteIfExistsAsync(
|
||||
It.IsAny<DeleteSnapshotsOption>(), It.IsAny<BlobRequestConditions>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source blob exists; the backup blob accepts a server-side copy whose operation completes
|
||||
/// immediately. Returns the mocks needed for verification.
|
||||
/// </summary>
|
||||
private static (Mock<BlobServiceClient> service, Mock<BlobClient> sourceBlob, Mock<BlobClient> backupBlob, Mock<CopyFromUriOperation> copy)
|
||||
CreateCopyChain(Uri sourceUri, Uri backupUri)
|
||||
{
|
||||
var (service, container, sourceBlob) = CreateMockedClientChain();
|
||||
sourceBlob.Setup(b => b.Uri).Returns(sourceUri);
|
||||
sourceBlob.Setup(b => b.ExistsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Response.FromValue(true, Mock.Of<Response>()));
|
||||
|
||||
var copy = new Mock<CopyFromUriOperation>();
|
||||
copy.Setup(c => c.WaitForCompletionAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Response.FromValue(3L, Mock.Of<Response>()));
|
||||
|
||||
var backupBlob = new Mock<BlobClient>();
|
||||
backupBlob.Setup(b => b.Uri).Returns(backupUri);
|
||||
backupBlob.Setup(b => b.StartCopyFromUriAsync(It.IsAny<Uri>(), It.IsAny<BlobCopyFromUriOptions>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(copy.Object);
|
||||
container.Setup(c => c.GetBlobClient(It.Is<string>(n => n.StartsWith("INV1/backup_"))))
|
||||
.Returns(backupBlob.Object);
|
||||
|
||||
return (service, sourceBlob, backupBlob, copy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CopyInvoicePdfToBackupAsync_ExistingBlob_CopiesServerSideUnderBackupNameAndKeepsOriginal()
|
||||
{
|
||||
var sourceUri = new Uri("https://test.blob.core.windows.net/test-invoices/INV1/Rechnung_INV1.pdf");
|
||||
var backupUri = new Uri("https://test.blob.core.windows.net/test-invoices/INV1/backup_20260917_120000_Rechnung_INV1.pdf");
|
||||
var (service, sourceBlob, backupBlob, copy) = CreateCopyChain(sourceUri, backupUri);
|
||||
|
||||
long succeeded = 0;
|
||||
using var listener = new MeterListener
|
||||
{
|
||||
InstrumentPublished = (inst, l) =>
|
||||
{
|
||||
if (inst.Meter.Name == FuchsMeterName && inst.Name == "fuchs.blobstorage.backupcopies")
|
||||
l.EnableMeasurementEvents(inst);
|
||||
}
|
||||
};
|
||||
listener.SetMeasurementEventCallback<long>((_, value, _, _) => Interlocked.Add(ref succeeded, value));
|
||||
listener.Start();
|
||||
|
||||
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
|
||||
Uri? result = await svc.CopyInvoicePdfToBackupAsync("INV1", "Rechnung_INV1.pdf");
|
||||
|
||||
Assert.Equal(backupUri, result);
|
||||
// The bytes must never pass through the process: server-side copy from the source URI only.
|
||||
backupBlob.Verify(b => b.StartCopyFromUriAsync(sourceUri, It.IsAny<BlobCopyFromUriOptions>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
copy.Verify(c => c.WaitForCompletionAsync(It.IsAny<CancellationToken>()), Times.Once);
|
||||
sourceBlob.Verify(b => b.DownloadContentAsync(It.IsAny<CancellationToken>()), Times.Never);
|
||||
backupBlob.Verify(b => b.UploadAsync(It.IsAny<Stream>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
// The original stays: it remains the current archive until the next regeneration overwrites it.
|
||||
sourceBlob.Verify(b => b.DeleteIfExistsAsync(
|
||||
It.IsAny<DeleteSnapshotsOption>(), It.IsAny<BlobRequestConditions>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
Assert.True(succeeded >= 1, "fuchs.blobstorage.backupcopies counter should have been incremented.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CopyInvoicePdfToBackupAsync_CopyFails_ReturnsNullAndIncrementsFailedCounter()
|
||||
{
|
||||
var sourceUri = new Uri("https://test.blob.core.windows.net/test-invoices/INV1/Rechnung_INV1.pdf");
|
||||
var backupUri = new Uri("https://test.blob.core.windows.net/test-invoices/INV1/backup_x_Rechnung_INV1.pdf");
|
||||
var (service, sourceBlob, backupBlob, copy) = CreateCopyChain(sourceUri, backupUri);
|
||||
copy.Setup(c => c.WaitForCompletionAsync(It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new RequestFailedException("copy aborted"));
|
||||
|
||||
long failed = 0;
|
||||
using var listener = new MeterListener
|
||||
{
|
||||
InstrumentPublished = (inst, l) =>
|
||||
{
|
||||
if (inst.Meter.Name == FuchsMeterName && inst.Name == "fuchs.blobstorage.backupcopies.failed")
|
||||
l.EnableMeasurementEvents(inst);
|
||||
}
|
||||
};
|
||||
listener.SetMeasurementEventCallback<long>((_, value, _, _) => Interlocked.Add(ref failed, value));
|
||||
listener.Start();
|
||||
|
||||
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
|
||||
Uri? result = await svc.CopyInvoicePdfToBackupAsync("INV1", "Rechnung_INV1.pdf");
|
||||
|
||||
Assert.Null(result);
|
||||
Assert.True(failed >= 1, "fuchs.blobstorage.backupcopies.failed counter should have been incremented.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CopyInvoicePdfToBackupAsync_ClientThrows_ReturnsNullAndIncrementsFailedCounter()
|
||||
{
|
||||
var (service, container, blob) = CreateMockedClientChain();
|
||||
blob.Setup(b => b.ExistsAsync(It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new RequestFailedException("simulated storage failure"));
|
||||
|
||||
long delta = 0;
|
||||
using var listener = new MeterListener
|
||||
{
|
||||
InstrumentPublished = (inst, l) =>
|
||||
{
|
||||
if (inst.Meter.Name == FuchsMeterName && inst.Name == "fuchs.blobstorage.backupcopies.failed")
|
||||
l.EnableMeasurementEvents(inst);
|
||||
}
|
||||
};
|
||||
listener.SetMeasurementEventCallback<long>((_, value, _, _) => Interlocked.Add(ref delta, value));
|
||||
listener.Start();
|
||||
|
||||
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
|
||||
Uri? result = await svc.CopyInvoicePdfToBackupAsync("INV2", "Rechnung_INV2.pdf");
|
||||
|
||||
Assert.Null(result);
|
||||
Assert.True(delta >= 1, "fuchs.blobstorage.backupcopies.failed counter should have been incremented.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using Fuchs.Controllers;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <c>inv/datev</c> (HandleDatev) called <c>fds__getDatevExports @tgtdate, @mode, @authuser</c> positionally
|
||||
/// against the signature (@tgtdate, @mode, @files, @authuser): the user id landed in <c>@files bit</c> and
|
||||
/// <c>@authuser</c> was never supplied, so every call failed (8114 / 201). The command now binds by name.
|
||||
/// These tests check the command against the real SSDT procedure so a future signature change is caught.
|
||||
/// </summary>
|
||||
public class DatevOverviewCommandTests
|
||||
{
|
||||
private const string ProcFile = "Stored Procedures/fds__getDatevExports.sql";
|
||||
|
||||
private static readonly Regex ParamDecl = new(
|
||||
@"@(?<name>\w+)\s+\w+(\s*\([^)]*\))?\s*(?<def>=)?",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex ExecArg = new(
|
||||
@"(?<name>@\w+)\s*(?<named>=)?",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
private static string? FindDatabaseProject()
|
||||
{
|
||||
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir != null; dir = dir.Parent)
|
||||
{
|
||||
string candidate = Path.Combine(dir.FullName, "Fuchs_Database", "dbo");
|
||||
if (Directory.Exists(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>(name, hasDefault) of each parameter, in declaration order.</summary>
|
||||
private static List<(string Name, bool HasDefault)> ProcParameters()
|
||||
{
|
||||
string? root = FindDatabaseProject();
|
||||
if (root == null) Assert.Skip("Fuchs_Database SSDT project not present in this checkout.");
|
||||
string sql = File.ReadAllText(Path.Combine(root!, ProcFile));
|
||||
int start = sql.IndexOf("[fds__getDatevExports]", StringComparison.OrdinalIgnoreCase);
|
||||
int end = Regex.Match(sql[start..], @"^\s*AS\s*$", RegexOptions.Multiline).Index + start;
|
||||
return ParamDecl.Matches(sql[start..end])
|
||||
.Select(m => ("@" + m.Groups["name"].Value, m.Groups["def"].Success))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Binding problems of an EXECUTE text against the procedure: positional arguments, names the proc
|
||||
/// does not declare, and required (no default) parameters that are not supplied.
|
||||
/// </summary>
|
||||
internal static List<string> BindingProblems(string execSql, List<(string Name, bool HasDefault)> procParams)
|
||||
{
|
||||
string args = execSql[(execSql.IndexOf(']', execSql.IndexOf("fds__getDatevExports", StringComparison.Ordinal)) + 1)..];
|
||||
var problems = new List<string>();
|
||||
var supplied = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var part in args.TrimEnd(';', ' ').Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var m = ExecArg.Match(part);
|
||||
if (!m.Success || !m.Groups["named"].Success) { problems.Add($"positional argument '{part}'"); continue; }
|
||||
string name = m.Groups["name"].Value;
|
||||
if (!procParams.Any(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase)))
|
||||
problems.Add($"unknown parameter {name}");
|
||||
supplied.Add(name);
|
||||
}
|
||||
problems.AddRange(procParams.Where(p => !p.HasDefault && !supplied.Contains(p.Name))
|
||||
.Select(p => $"required parameter {p.Name} not supplied"));
|
||||
return problems;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcParameters_RealSsdtFile_MatchesKnownSignature()
|
||||
{
|
||||
var ps = ProcParameters();
|
||||
Assert.Equal(new[] { "@tgtdate", "@mode", "@files", "@authuser" }, ps.Select(p => p.Name));
|
||||
Assert.Equal(new[] { false, true, false, false }, ps.Select(p => p.HasDefault));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DatevOverviewSql_AgainstRealProc_BindsEveryRequiredParameterByName()
|
||||
=> Assert.Empty(BindingProblems(IntranetController.DatevOverviewSql, ProcParameters()));
|
||||
|
||||
[Theory]
|
||||
[InlineData("EXECUTE [dbo].[fds__getDatevExports] @tgtdate, @mode, @authuser;")] // the former call
|
||||
[InlineData("EXECUTE [dbo].[fds__getDatevExports] @tgtdate = @tgtdate, @mode = @mode, @files = 0;")] // @authuser missing
|
||||
[InlineData("EXECUTE [dbo].[fds__getDatevExports] @tgtdate = @tgtdate, @files = 0, @user = @authuser;")] // unknown name
|
||||
public void BindingProblems_MisboundCommand_IsReported(string execSql)
|
||||
=> Assert.NotEmpty(BindingProblems(execSql, ProcParameters()));
|
||||
|
||||
[Fact]
|
||||
public void DatevOverviewTables_NamesAllFourResultSetsInProcOrder()
|
||||
// fds__getDatevExports returns header, invoice documents, bookings, debtors — the same four sets
|
||||
// FdsMfr.GetDatevZip names admin/inv/buchungen/debitoren. Three names shifted every set by one.
|
||||
=> Assert.Equal(new[] { "admin", "files", "invoices", "debits" }, IntranetController.DatevOverviewTables);
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Services;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using MigraDoc.DocumentObjectModel;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using OCORE.security;
|
||||
using Xunit;
|
||||
using static OCORE.OCORE_dictionaries;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The editor fires patches, state fetches, previews and saves concurrently for one draft token. The
|
||||
/// session payload (<c>JArray</c>/<c>JObject</c>, history list) is not thread-safe, so the draft
|
||||
/// services serialise every access on <c>SyncRoot</c> and hand out snapshots. Regression for the
|
||||
/// production "Collection was modified" crash in <c>InvoiceSetStructure.RecomputeLevels</c>
|
||||
/// (inv/dpatch, 2026-10-01). Flushes are additionally serialised on <c>FlushGate</c>: the
|
||||
/// create/update decision in <c>FlushToDbAsync</c> is a read-then-write across an await, so two
|
||||
/// overlapping saves of a new draft would otherwise both create a DB row.
|
||||
/// </summary>
|
||||
public class DraftSessionConcurrencyTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Registration double: records every call, hands out a fresh id per <b>create</b> and echoes the
|
||||
/// given id for an update — like the DB, so a duplicate create is visible as a second distinct id.
|
||||
/// <see cref="Block"/>, when set, parks the first registration until released, which lets a test
|
||||
/// pin down the exact interleaving instead of hoping for one.
|
||||
/// </summary>
|
||||
private sealed class FakeInvoiceService : IInvoiceService
|
||||
{
|
||||
private int _ids;
|
||||
public readonly ConcurrentQueue<(bool change, string invId)> Calls = new();
|
||||
public TaskCompletionSource? Block;
|
||||
public readonly TaskCompletionSource Entered = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
public Exception? Throw;
|
||||
|
||||
public async Task<FdsInvoiceData> RegisterInvoiceAsync(FdsInvoiceData invoice, bool change, string invId, string userAccountId, DatabaseSecurity dbSec)
|
||||
{
|
||||
Calls.Enqueue((change, invId));
|
||||
Entered.TrySetResult();
|
||||
if (Block != null) { var b = Block; Block = null; await b.Task; }
|
||||
if (Throw != null) { var ex = Throw; Throw = null; throw ex; }
|
||||
string id = change ? invId : "INV" + Interlocked.Increment(ref _ids);
|
||||
invoice.InvoiceRegistration = new GenericObjectDictionary(new Dictionary<string, object> { ["Id"] = id });
|
||||
return invoice;
|
||||
}
|
||||
public Task<FdsInvoiceData> LoadInvoiceAsync(string id, string u, DatabaseSecurity s) => throw new NotSupportedException();
|
||||
public Task<Document> GenerateInvoicePdfAsync(FdsInvoiceData i, bool d) => Task.FromResult(new Document());
|
||||
public Task<byte[]> RenderInvoicePdfBytesAsync(FdsInvoiceData i, bool d) => throw new NotSupportedException();
|
||||
public Task<byte[]> StoreInvoiceDocumentFileAsync(FdsInvoiceData i, bool d, string u, DatabaseSecurity s) => throw new NotSupportedException();
|
||||
public Task<byte[]?> GetInvoiceFileAsync(FdsInvoiceData i, bool d, fds.IFdsMfr m) => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
private sealed class FakeReminderService : IReminderService
|
||||
{
|
||||
private int _ids;
|
||||
public readonly ConcurrentQueue<(bool change, string remId)> Calls = new();
|
||||
public TaskCompletionSource? Block;
|
||||
public readonly TaskCompletionSource Entered = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public async Task<FdsReminderData> RegisterReminderAsync(FdsReminderData reminder, bool change, string remId, string userAccountId, DatabaseSecurity dbSec)
|
||||
{
|
||||
Calls.Enqueue((change, remId));
|
||||
Entered.TrySetResult();
|
||||
if (Block != null) { var b = Block; Block = null; await b.Task; }
|
||||
string id = change ? remId : "REM" + Interlocked.Increment(ref _ids);
|
||||
reminder.ReminderRegistration = new GenericObjectDictionary(new Dictionary<string, object> { ["Id"] = id });
|
||||
return reminder;
|
||||
}
|
||||
public Document GenerateReminderPdf(FdsReminderData reminder, bool draft) => new();
|
||||
public Task<FdsReminderData> LoadReminderAsync(string id, string u, DatabaseSecurity s) => throw new NotSupportedException();
|
||||
public Task<byte[]> RenderReminderPdfBytesAsync(FdsReminderData r, bool d) => throw new NotSupportedException();
|
||||
public Task<byte[]> StoreReminderDocumentFileAsync(FdsReminderData r, bool d, string u, DatabaseSecurity s) => throw new NotSupportedException();
|
||||
public Task<byte[]> GetReminderFileAsync(FdsReminderData r, bool d, fds.IFdsMfr m, string u, DatabaseSecurity s) => throw new NotSupportedException();
|
||||
public Task<(System.IO.FileInfo? file, byte[]? content)> GetStoredFileAsync(string id, string u, DatabaseSecurity s) => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
private static (InvoiceDraftEditService svc, FakeInvoiceService inv) NewInvoiceService()
|
||||
{
|
||||
var inv = new FakeInvoiceService();
|
||||
return (new InvoiceDraftEditService(new InvoiceDraftCache(new ConfigurationBuilder().Build()), inv, NullLogger<InvoiceDraftEditService>.Instance), inv);
|
||||
}
|
||||
|
||||
private static (ReminderDraftEditService svc, FakeReminderService rem) NewReminderService()
|
||||
{
|
||||
var rem = new FakeReminderService();
|
||||
return (new ReminderDraftEditService(new ReminderDraftCache(new ConfigurationBuilder().Build()), rem, NullLogger<ReminderDraftEditService>.Instance), rem);
|
||||
}
|
||||
|
||||
private static JObject InvoicePayload() => JObject.Parse(@"{
|
||||
'admin':{'p13b':false,'type':'r','paymentterms':'10wd'},
|
||||
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','invoicetitle':'Rechnung'},
|
||||
'req':[{'Id':'1','text':'Auftrag','itm':[{'id':'900','typ':'set','vt':0,'vv':0,'vat':'19%'},
|
||||
{'id':'901','typ':'material','vt':100,'vv':19,'vat':'19%','SetItmId':'900'}],
|
||||
'items':[{'id':'900','type':'set','total_net':0,'vat':'19%'},{'id':'901','type':'material','total_net':100,'vat':'19%'}]}]
|
||||
}");
|
||||
|
||||
private static JObject ReminderPayload() => JObject.Parse(@"{
|
||||
'rem':{'invid':'INV5','type':'R','invoiceid':'R2026-1','invoicedate':'2026-06-01'},
|
||||
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','subject':'Zahlungserinnerung','amount':119,'amount_payed':0}
|
||||
}");
|
||||
|
||||
private static JObject Block(string id, decimal net) => JObject.Parse(
|
||||
"{'Id':'" + id + "','text':'Auftrag " + id + "','itm':[{'id':'i" + id + "','typ':'material','vt':" + net + ",'vv':0,'vat':'19%'}]," +
|
||||
"'items':[{'id':'i" + id + "','type':'material','total_net':" + net + ",'vat':'19%'}]}");
|
||||
|
||||
/// <summary>Runs <paramref name="writers"/> and <paramref name="readers"/> concurrently from a common start gate.</summary>
|
||||
private static async Task RunConcurrently(IEnumerable<Action> writers, IEnumerable<Action> readers)
|
||||
{
|
||||
using var gate = new ManualResetEventSlim(false);
|
||||
var tasks = writers.Concat(readers)
|
||||
.Select(a => Task.Factory.StartNew(() => { gate.Wait(); a(); }, TaskCreationOptions.LongRunning))
|
||||
.ToList();
|
||||
gate.Set();
|
||||
await Task.WhenAll(tasks);
|
||||
}
|
||||
|
||||
private static Task WithTimeout(Task t) => t.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
private static Task<T> WithTimeout<T>(Task<T> t) => t.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
|
||||
// ── payload lock ───────────────────────────────────────────────────────────
|
||||
[Theory]
|
||||
[InlineData("block.replace")] // inserts new blocks → s.Req.Add (the crash: RecomputeLevels enumerating Req)
|
||||
[InlineData("block.order")] // s.Req.Clear() + re-add while others enumerate
|
||||
public async Task ApplyPatch_ConcurrentPatchesAndStateReads_NeverThrowAndCountEveryPatch(string target)
|
||||
{
|
||||
const int patches = 200;
|
||||
var (svc, _) = NewInvoiceService();
|
||||
var s = svc.OpenFromPayload(InvoicePayload(), "user1");
|
||||
if (target == "block.order")
|
||||
for (int i = 0; i < 4; i++) svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.replace", Ref = "b" + i, Value = Block("b" + i, 10) });
|
||||
int baseVersion = s.Version;
|
||||
|
||||
var writers = Enumerable.Range(0, patches).Select<int, Action>(i => () =>
|
||||
{
|
||||
var delta = target == "block.replace"
|
||||
? new InvoiceDraftDelta { Target = target, Ref = "n" + i, Value = Block("n" + i, 1) }
|
||||
: new InvoiceDraftDelta { Target = target, Value = new JArray(i % 2 == 0 ? new[] { "b3", "b2", "1" } : new[] { "1", "b0", "b1" }) };
|
||||
Assert.NotNull(svc.ApplyPatch(s.Token, delta));
|
||||
});
|
||||
var readers = Enumerable.Range(0, 50).Select<int, Action>(_ => () =>
|
||||
{
|
||||
for (int k = 0; k < 5; k++)
|
||||
{
|
||||
JsonConvert.SerializeObject(svc.BuildState(s)); // what JSONAsync does after BuildState returns
|
||||
_ = svc.GetHistory(s.Token).Count;
|
||||
}
|
||||
});
|
||||
|
||||
await RunConcurrently(writers, readers);
|
||||
|
||||
// Every applied patch bumps the version exactly once and leaves one history entry (no lost
|
||||
// updates). A reorder to the order that is already current is a no-op by design, so for
|
||||
// block.order fewer than `patches` may apply.
|
||||
Assert.Equal(s.Version, svc.GetHistory(s.Token).Count);
|
||||
if (target == "block.order")
|
||||
{
|
||||
Assert.InRange(s.Version, baseVersion + 1, baseVersion + patches);
|
||||
Assert.Equal(5, s.Req.Count); // reorders never lose or duplicate a block
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Equal(baseVersion + patches, s.Version);
|
||||
Assert.Equal(1 + patches, s.Req.Count);
|
||||
Assert.Equal(100m + patches, s.Sums.TotalNet);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderPreviewAndFlush_ConcurrentWithPatches_NeverThrowAndCreateExactlyOnce()
|
||||
{
|
||||
var (svc, inv) = NewInvoiceService();
|
||||
var s = svc.OpenFromPayload(InvoicePayload(), "user1");
|
||||
var results = new ConcurrentBag<DraftFlushResult<FdsInvoiceData>>();
|
||||
|
||||
var writers = Enumerable.Range(0, 100).Select<int, Action>(i => () =>
|
||||
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.replace", Ref = "n" + i, Value = Block("n" + i, 1) }));
|
||||
var readers = Enumerable.Range(0, 20).Select<int, Action>(i => () =>
|
||||
{
|
||||
Assert.NotNull(svc.RenderPreviewAsync(s.Token).GetAwaiter().GetResult());
|
||||
results.Add(svc.FlushToDbAsync(s.Token, "user1", null!).GetAwaiter().GetResult()!);
|
||||
});
|
||||
|
||||
await RunConcurrently(writers, readers);
|
||||
|
||||
Assert.Equal(101, s.Req.Count);
|
||||
// 20 overlapping saves of a brand-new draft: exactly one fds__createInvoice, every other save
|
||||
// an update of that row — never a second orphaned invoice.
|
||||
Assert.Equal(20, inv.Calls.Count);
|
||||
Assert.Single(inv.Calls, c => !c.change);
|
||||
Assert.Equal("INV1", s.InvId);
|
||||
Assert.All(inv.Calls.Where(c => c.change), c => Assert.Equal("INV1", c.invId));
|
||||
Assert.All(results, r => Assert.Equal("INV1", r.Data.Id));
|
||||
Assert.Single(results, r => !r.Updated);
|
||||
}
|
||||
|
||||
// ── flush gate ─────────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public async Task FlushToDbAsync_SecondSaveOverlapsFirstRegistration_WaitsThenUpdatesInsteadOfCreating()
|
||||
{
|
||||
var (svc, inv) = NewInvoiceService();
|
||||
var s = svc.OpenFromPayload(InvoicePayload(), "user1");
|
||||
var block = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
inv.Block = block; // the fake clears Block once it has parked on it
|
||||
|
||||
var first = svc.FlushToDbAsync(s.Token, "user1", null!);
|
||||
await WithTimeout(inv.Entered.Task); // first save is inside the DB registration, InvId still empty
|
||||
var second = svc.FlushToDbAsync(s.Token, "user1", null!);
|
||||
|
||||
// While the first registration is in flight the second save must not reach the registration at
|
||||
// all (it would see the empty InvId and create a second row). A false pass here is possible
|
||||
// only under pathological scheduling; a false failure is not.
|
||||
await Task.Delay(150);
|
||||
Assert.False(second.IsCompleted);
|
||||
Assert.Single(inv.Calls);
|
||||
|
||||
block.SetResult();
|
||||
var r1 = (await WithTimeout(first))!;
|
||||
var r2 = (await WithTimeout(second))!;
|
||||
|
||||
Assert.Equal(new[] { (false, ""), (true, "INV1") }, inv.Calls.ToArray());
|
||||
Assert.False(r1.Updated); Assert.Equal("INV1", r1.Data.Id);
|
||||
Assert.True(r2.Updated); Assert.Equal("INV1", r2.Data.Id);
|
||||
Assert.Equal("INV1", s.InvId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FlushToDbAsync_SecondSaveCarriesPatchesMadeDuringFirst()
|
||||
{
|
||||
var (svc, inv) = NewInvoiceService();
|
||||
var s = svc.OpenFromPayload(InvoicePayload(), "user1");
|
||||
var block = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
inv.Block = block; // the fake clears Block once it has parked on it
|
||||
|
||||
var first = svc.FlushToDbAsync(s.Token, "user1", null!);
|
||||
await WithTimeout(inv.Entered.Task);
|
||||
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "email", Value = "neu@x.de" }); // edits are not blocked by a running save
|
||||
var second = svc.FlushToDbAsync(s.Token, "user1", null!);
|
||||
block.SetResult();
|
||||
|
||||
var r1 = (await WithTimeout(first))!;
|
||||
var r2 = (await WithTimeout(second))!;
|
||||
|
||||
// The waiting save snapshots after it acquires the gate, so it persists the newer state —
|
||||
// serialising never makes a save write stale data.
|
||||
Assert.Equal("a@b.de", r1.Data.RawInvoiceEmail);
|
||||
Assert.Equal("neu@x.de", r2.Data.RawInvoiceEmail);
|
||||
Assert.True(r2.Updated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FlushToDbAsync_RegistrationThrows_ReleasesGateAndNextSaveCreates()
|
||||
{
|
||||
var (svc, inv) = NewInvoiceService();
|
||||
var s = svc.OpenFromPayload(InvoicePayload(), "user1");
|
||||
inv.Throw = new InvalidOperationException("db down");
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => svc.FlushToDbAsync(s.Token, "user1", null!));
|
||||
|
||||
// The gate must be released by the failed save; the next one gets through and, since no id
|
||||
// was committed, still runs as the create.
|
||||
var r = (await WithTimeout(svc.FlushToDbAsync(s.Token, "user1", null!)))!;
|
||||
Assert.False(r.Updated);
|
||||
Assert.Equal("INV1", r.Data.Id);
|
||||
Assert.Equal("INV1", s.InvId);
|
||||
Assert.Equal(2, inv.Calls.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FlushToDbAsync_UnknownToken_ReturnsNullWithoutRegistering()
|
||||
{
|
||||
var (svc, inv) = NewInvoiceService();
|
||||
Assert.Null(await svc.FlushToDbAsync("ghost", "user1", null!));
|
||||
Assert.Empty(inv.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReminderFlushToDbAsync_SecondSaveOverlapsFirstRegistration_WaitsThenUpdatesInsteadOfCreating()
|
||||
{
|
||||
var (svc, rem) = NewReminderService();
|
||||
var s = svc.OpenFromPayload(ReminderPayload(), "user1");
|
||||
var block = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
rem.Block = block; // the fake clears Block once it has parked on it
|
||||
|
||||
var first = svc.FlushToDbAsync(s.Token, "user1", null!);
|
||||
await WithTimeout(rem.Entered.Task);
|
||||
var second = svc.FlushToDbAsync(s.Token, "user1", null!);
|
||||
await Task.Delay(150);
|
||||
Assert.False(second.IsCompleted);
|
||||
Assert.Single(rem.Calls);
|
||||
|
||||
block.SetResult();
|
||||
var r1 = (await WithTimeout(first))!;
|
||||
var r2 = (await WithTimeout(second))!;
|
||||
|
||||
Assert.Equal(new[] { (false, ""), (true, "REM1") }, rem.Calls.ToArray());
|
||||
Assert.False(r1.Updated); Assert.Equal("REM1", r1.Data.Id);
|
||||
Assert.True(r2.Updated); Assert.Equal("REM1", r2.Data.Id);
|
||||
Assert.Equal("REM1", s.RemId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReminderFlushToDbAsync_ManyConcurrentSaves_CreateExactlyOnce()
|
||||
{
|
||||
var (svc, rem) = NewReminderService();
|
||||
var s = svc.OpenFromPayload(ReminderPayload(), "user1");
|
||||
|
||||
await RunConcurrently(
|
||||
Enumerable.Range(0, 20).Select<int, Action>(_ => () => svc.FlushToDbAsync(s.Token, "user1", null!).GetAwaiter().GetResult()),
|
||||
Enumerable.Empty<Action>());
|
||||
|
||||
Assert.Equal(20, rem.Calls.Count);
|
||||
Assert.Single(rem.Calls, c => !c.change);
|
||||
Assert.Equal("REM1", s.RemId);
|
||||
}
|
||||
|
||||
// ── snapshots ──────────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public void BuildState_IsSnapshot_LaterPatchDoesNotChangeReturnedPayload()
|
||||
{
|
||||
var (svc, _) = NewInvoiceService();
|
||||
var s = svc.OpenFromPayload(InvoicePayload(), "user1");
|
||||
string before = JsonConvert.SerializeObject(svc.BuildState(s));
|
||||
var state = svc.BuildState(s);
|
||||
|
||||
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.replace", Ref = "x", Value = Block("x", 5) });
|
||||
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "email", Value = "neu@x.de" });
|
||||
|
||||
Assert.Equal(before, JsonConvert.SerializeObject(state));
|
||||
Assert.Contains("neu@x.de", JsonConvert.SerializeObject(svc.BuildState(s)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHistory_IsSnapshot_LaterPatchDoesNotGrowReturnedList()
|
||||
{
|
||||
var (svc, _) = NewInvoiceService();
|
||||
var s = svc.OpenFromPayload(InvoicePayload(), "user1");
|
||||
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "email", Value = "x@y.de" });
|
||||
var history = svc.GetHistory(s.Token);
|
||||
|
||||
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "email", Value = "z@y.de" });
|
||||
|
||||
Assert.Single(history);
|
||||
Assert.Equal(2, svc.GetHistory(s.Token).Count);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("unknown-token")]
|
||||
public void GetHistory_UnknownToken_ReturnsEmpty(string token)
|
||||
{
|
||||
Assert.Empty(NewInvoiceService().svc.GetHistory(token));
|
||||
Assert.Empty(NewReminderService().svc.GetHistory(token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReminderApplyPatch_ConcurrentPatchesAndStateReads_NeverThrowAndCountEveryPatch()
|
||||
{
|
||||
const int patches = 200;
|
||||
var (svc, _) = NewReminderService();
|
||||
var s = svc.OpenFromPayload(ReminderPayload(), "user1");
|
||||
|
||||
var writers = Enumerable.Range(0, patches).Select<int, Action>(i => () =>
|
||||
Assert.NotNull(svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = i % 2 == 0 ? "subject" : "amount_payed", Value = i % 2 == 0 ? "S" + i : "1" })));
|
||||
var readers = Enumerable.Range(0, 50).Select<int, Action>(_ => () =>
|
||||
{
|
||||
for (int k = 0; k < 5; k++)
|
||||
{
|
||||
JsonConvert.SerializeObject(svc.BuildState(s));
|
||||
_ = svc.GetHistory(s.Token).Count;
|
||||
}
|
||||
});
|
||||
|
||||
await RunConcurrently(writers, readers);
|
||||
|
||||
Assert.Equal(patches, s.Version);
|
||||
Assert.Equal(patches, svc.GetHistory(s.Token).Count);
|
||||
Assert.Equal(118m, s.Sums.AmountOpen);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Linq;
|
||||
using Fuchs.Controllers;
|
||||
using Fuchs.intranet;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Xunit;
|
||||
using static fds.FdsMfrClient;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the error-attribution fixes from the 2026-09-16 production log review and the
|
||||
/// subsequent migration onto the generic OCORE MultiLogger SQL log sink (ADR 0024):
|
||||
/// <see cref="FIS_SQLOptions"/> now logs every SQL error through <see cref="ILogger"/> alone
|
||||
/// (which the SQL sink additionally persists to <c>fds__debuglog</c> at Warning+, so the old,
|
||||
/// separate <c>Fuchs_intranet.debug_log</c> DB write was retired to avoid double-logging), with
|
||||
/// an <see cref="EventId"/> carrying the exact CodeReference text the retired DB write used to.
|
||||
/// The DB write itself, and <c>IntranetController.Do</c>'s full catch-all, are SQL/DI-bound and
|
||||
/// not unit-tested per repo convention; these tests pin the pure logic that feeds them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Shares <see cref="StaticOcoreLoggerCollection"/> with <c>FdsSqlOptionsTests</c>: both save/
|
||||
/// mutate/restore the process-wide static <see cref="OCORE.Logging.Logger"/> field, which would
|
||||
/// otherwise race across the two classes since xUnit parallelizes different test classes by
|
||||
/// default.
|
||||
/// </remarks>
|
||||
[Collection(StaticOcoreLoggerCollection.Name)]
|
||||
public class ErrorLoggingTests
|
||||
{
|
||||
/// <summary>Minimal <see cref="ILogger"/> double that records every call for assertion.</summary>
|
||||
private sealed class RecordingLogger : ILogger
|
||||
{
|
||||
public sealed record Entry(LogLevel Level, EventId EventId, Exception? Exception, string Message,
|
||||
IReadOnlyDictionary<string, object?> State);
|
||||
|
||||
public readonly List<Entry> Entries = new();
|
||||
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
var pairs = (state as IEnumerable<KeyValuePair<string, object>>)?
|
||||
.ToDictionary(kv => kv.Key, kv => (object?)kv.Value) ?? new Dictionary<string, object?>();
|
||||
Entries.Add(new Entry(logLevel, eventId, exception, formatter(state, exception), pairs));
|
||||
}
|
||||
}
|
||||
|
||||
// ── FIS_SQLOptions ─────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void FisSqlOptions_OnError_LogsWithEventIdAndStructuredPlaceholders()
|
||||
{
|
||||
var logger = new RecordingLogger();
|
||||
var context = new Dictionary<string, object> { ["fn"] = "inv", ["id"] = "invl", ["code"] = "" };
|
||||
var opt = new FIS_SQLOptions(context, logger);
|
||||
var ex = new TimeoutException("Das Ausführungstimeout ist abgelaufen.");
|
||||
var sqlPayload = new { sql = "EXECUTE [dbo].[fds__getInvoiceList] @authuser;" };
|
||||
|
||||
opt.OnError!("getSQLDataSet", ex, sqlPayload);
|
||||
|
||||
var entry = Assert.Single(logger.Entries);
|
||||
Assert.Equal(LogLevel.Error, entry.Level);
|
||||
// EventId.Name preserves the exact CodeReference text the retired DB write used, so the SQL
|
||||
// sink's fallback COALESCE(procedure, category, 'MultiLogger') keeps existing dashboards working.
|
||||
Assert.Equal("SQL Error in getSQLDataSet", entry.EventId.Name);
|
||||
Assert.Same(ex, entry.Exception);
|
||||
// The failing statement and caller context are structured placeholders, not string-concatenated
|
||||
// — this is what was missing before (every row's Data column was NULL).
|
||||
Assert.Equal("getSQLDataSet", entry.State["Procedure"]);
|
||||
Assert.Same(sqlPayload, entry.State["Sql"]);
|
||||
Assert.Same(context, entry.State["Context"]);
|
||||
// Plain Microsoft.Extensions.Logging (not Serilog) does not treat '@' as a destructuring
|
||||
// operator; it would be kept as a literal leading character in the key. This asserts the
|
||||
// placeholder names in FIS_SQLOptions are plain ("Sql"/"Context"), not "@Sql"/"@Context".
|
||||
Assert.False(entry.State.ContainsKey("@Sql"));
|
||||
Assert.Contains("getSQLDataSet", entry.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FisSqlOptions_OnError_NoLoggerGiven_FallsBackToStaticOcoreLogger()
|
||||
{
|
||||
// Several call sites construct `new FIS_SQLOptions()` with neither logger nor context
|
||||
// (Services/*.cs) — those must still reach a logger, not silently drop the error.
|
||||
var fallback = new RecordingLogger();
|
||||
var saved = OCORE.Logging.Logger;
|
||||
OCORE.Logging.Logger = fallback;
|
||||
try
|
||||
{
|
||||
var opt = new FIS_SQLOptions();
|
||||
opt.OnError!("setSQLValue_async", new Exception("boom"), new { sql = "SELECT 1;" });
|
||||
|
||||
var entry = Assert.Single(fallback.Entries);
|
||||
Assert.Equal("SQL Error in setSQLValue_async", entry.EventId.Name);
|
||||
}
|
||||
finally { OCORE.Logging.Logger = saved; }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FisSqlOptions_OnError_NoLoggerAndNoStaticFallback_DoesNotThrow()
|
||||
{
|
||||
var saved = OCORE.Logging.Logger;
|
||||
OCORE.Logging.Logger = null;
|
||||
try
|
||||
{
|
||||
var opt = new FIS_SQLOptions();
|
||||
var recorded = Record.Exception(() =>
|
||||
opt.OnError!("getSQLDataSet", new Exception("x"), new { sql = "SELECT 1;" }));
|
||||
|
||||
Assert.Null(recorded);
|
||||
}
|
||||
finally { OCORE.Logging.Logger = saved; }
|
||||
}
|
||||
|
||||
// ── Response description header (500 → x-ocms-desc) ────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("Die PDF-Vorschau konnte nicht erstellt werden.", "Die%20PDF-Vorschau%20konnte%20nicht%20erstellt%20werden.")]
|
||||
[InlineData("a+b", "a%2Bb")] // '+' must not survive: the client maps '+' to a space
|
||||
[InlineData("Zeile1\r\nZeile2", "Zeile1%20%20Zeile2")]
|
||||
[InlineData(" trimmed ", "trimmed")]
|
||||
public void EncodeResponseDescription_ProducesAsciiHeaderValue_ClientDecodesToOriginal(string msg, string expected)
|
||||
{
|
||||
string encoded = IntranetController.EncodeResponseDescription(msg);
|
||||
|
||||
Assert.Equal(expected, encoded);
|
||||
Assert.All(encoded, c => Assert.True(c < 128, "header must be ASCII"));
|
||||
// Mirror of the client: decodeURIComponent(v.replace(/\+/g, '%20'))
|
||||
Assert.Equal(msg.Replace("\r", " ").Replace("\n", " ").Trim(), Uri.UnescapeDataString(encoded.Replace("+", "%20")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncodeResponseDescription_UmlautsSurviveRoundTrip()
|
||||
{
|
||||
const string msg = "Zeitüberschreitung – bitte erneut versuchen";
|
||||
string encoded = IntranetController.EncodeResponseDescription(msg);
|
||||
|
||||
Assert.All(encoded, c => Assert.True(c < 128));
|
||||
Assert.Equal(msg, Uri.UnescapeDataString(encoded));
|
||||
}
|
||||
|
||||
// ── PDF preview failure message ─────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void PdfPreviewFailureMessage_UsesInnermostExceptionText()
|
||||
{
|
||||
var ex = new InvalidOperationException("outer",
|
||||
new AggregateException("agg", new ApplicationException("Font 'Courier New' cannot be resolved")));
|
||||
|
||||
string msg = IntranetController.PdfPreviewFailureMessage(ex, includeDetail: true);
|
||||
|
||||
Assert.StartsWith("Die PDF-Vorschau konnte nicht erstellt werden: ", msg);
|
||||
Assert.EndsWith("Font 'Courier New' cannot be resolved", msg);
|
||||
Assert.DoesNotContain("outer", msg);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void PdfPreviewFailureMessage_EmptyExceptionText_FallsBackToGenericText(string text)
|
||||
{
|
||||
string msg = IntranetController.PdfPreviewFailureMessage(new Exception(text), includeDetail: true);
|
||||
|
||||
Assert.Equal("Die PDF-Vorschau konnte nicht erstellt werden.", msg);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PdfPreviewFailureMessage_UnauthenticatedCaller_OmitsExceptionDetail()
|
||||
{
|
||||
var ex = new ApplicationException("Font 'Courier New' cannot be resolved");
|
||||
|
||||
string msg = IntranetController.PdfPreviewFailureMessage(ex, includeDetail: false);
|
||||
|
||||
Assert.Equal("Die PDF-Vorschau konnte nicht erstellt werden.", msg);
|
||||
Assert.DoesNotContain("Courier", msg);
|
||||
}
|
||||
|
||||
// ── Data service: schema failure must not cascade into "FROM [dbo].[]" ────
|
||||
|
||||
private static DataRowCollection NavTables(params string[] names)
|
||||
{
|
||||
var t = new DataTable();
|
||||
t.Columns.Add("tablename", typeof(string));
|
||||
foreach (var n in names) t.Rows.Add(n);
|
||||
return t.Rows;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void ShapeTableNames_NoEntityTable_ReturnsEmpty_EvenWithNavTables(string entityTable)
|
||||
{
|
||||
var names = DatabaseSchema.ShapeTableNames(entityTable, NavTables("mfr_invoice_items"));
|
||||
|
||||
Assert.Empty(names);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShapeTableNames_EntityTableFirst_ThenNavTables_SkippingBlanksAndDuplicates()
|
||||
{
|
||||
var names = DatabaseSchema.ShapeTableNames("mfr_invoice", NavTables("mfr_invoice_items", "", "mfr_invoice_items", "mfr_invoice_files"));
|
||||
|
||||
Assert.Equal(new[] { "mfr_invoice", "mfr_invoice_items", "mfr_invoice_files" }, names);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShapeTableNames_NoNavTables_ReturnsEntityOnly()
|
||||
{
|
||||
var names = DatabaseSchema.ShapeTableNames("mfr_invoice", NavTables());
|
||||
|
||||
Assert.Equal(new[] { "mfr_invoice" }, names);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using fds;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// mfr merge truncation (2026-09-30): mfr's free-text Note (ServiceObject up to 753 chars,
|
||||
/// Company up to 723) overflowed NVARCHAR(255) in the mfr__ tables / mfr__tt__ types and every
|
||||
/// batch holding such a row was rejected with the column-less message 8152. Covers the
|
||||
/// failure-path diagnosis in <see cref="FdsColumnWidths"/> and guards the widened SSDT schema.
|
||||
/// </summary>
|
||||
public class FdsColumnWidthsTests
|
||||
{
|
||||
private const long LongNoteServiceObjectId = 38722404355;
|
||||
|
||||
// Staged batch shaped like "SELECT TOP(0) [setid] = …, * FROM mfr__serviceobjects".
|
||||
private static DataTable ServiceObjectBatch(params (long Id, string? Note)[] rows)
|
||||
{
|
||||
var t = new DataTable("mfr__serviceobjects");
|
||||
t.Columns.Add("setid", typeof(string));
|
||||
t.Columns.Add("Id", typeof(long));
|
||||
t.Columns.Add("Name", typeof(string));
|
||||
t.Columns.Add("Note", typeof(string));
|
||||
t.Columns.Add("CustomValues", typeof(string));
|
||||
t.Columns.Add("Version", typeof(int));
|
||||
foreach (var (id, note) in rows)
|
||||
t.Rows.Add("abcde", id, "Heizung Keller", (object?)note ?? DBNull.Value, "[]", 1);
|
||||
return t;
|
||||
}
|
||||
|
||||
private static Dictionary<string, int> Widths(params (string Column, int Max)[] w) =>
|
||||
w.ToDictionary(x => x.Column, x => x.Max, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// ── IsTruncationError ────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(8152, true)] // classic "String or binary data would be truncated."
|
||||
[InlineData(2628, true)] // verbose variant (compat level 150+) that names the column
|
||||
[InlineData(2627, false)] // PK violation
|
||||
[InlineData(515, false)] // NULL into NOT NULL
|
||||
[InlineData(8672, false)] // MERGE duplicate source row
|
||||
[InlineData(0, false)]
|
||||
[InlineData(-2, false)] // client timeout
|
||||
public void IsTruncationError_ErrorNumber_OnlyTruncationNumbersMatch(int number, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, FdsColumnWidths.IsTruncationError(number));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsTruncationError_NonSqlOrNullException_ReturnsFalse()
|
||||
{
|
||||
Assert.False(FdsColumnWidths.IsTruncationError((Exception?)null));
|
||||
Assert.False(FdsColumnWidths.IsTruncationError(new InvalidOperationException("String or binary data would be truncated.")));
|
||||
}
|
||||
|
||||
// ── WidthsSql ────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void WidthsSql_IsParameterised_AndCoversTargetTableAndItsTableType()
|
||||
{
|
||||
Assert.Contains("@tablename", FdsColumnWidths.WidthsSql);
|
||||
Assert.Contains("REPLACE(@tablename, N'__', N'__tt__')", FdsColumnWidths.WidthsSql); // same naming as mfr__updt__ merge proc
|
||||
Assert.Contains("sys.table_types", FdsColumnWidths.WidthsSql);
|
||||
Assert.Contains("MIN(", FdsColumnWidths.WidthsSql); // narrower of table vs. type wins
|
||||
}
|
||||
|
||||
// ── ToWidthMap ───────────────────────────────────────────────────────────
|
||||
|
||||
private static DataTable WidthRows(params (object Name, object Max)[] rows)
|
||||
{
|
||||
var t = new DataTable();
|
||||
t.Columns.Add("columnname", typeof(object));
|
||||
t.Columns.Add("maxchars", typeof(object));
|
||||
foreach (var (n, m) in rows) t.Rows.Add(n, m);
|
||||
return t;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToWidthMap_ValidRows_MapsCaseInsensitively()
|
||||
{
|
||||
var map = FdsColumnWidths.ToWidthMap(WidthRows(("Note", 255), ("setid", (short)5)));
|
||||
|
||||
Assert.Equal(255, map["note"]);
|
||||
Assert.Equal(5, map["SETID"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToWidthMap_SameColumnTwice_KeepsNarrower()
|
||||
{
|
||||
var map = FdsColumnWidths.ToWidthMap(WidthRows(("Note", 500), ("note", 255)));
|
||||
|
||||
Assert.Equal(255, Assert.Single(map).Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToWidthMap_InvalidRows_AreSkipped()
|
||||
{
|
||||
var map = FdsColumnWidths.ToWidthMap(WidthRows(
|
||||
("", 10), (DBNull.Value, 10), ("A", DBNull.Value), ("B", 0), ("C", -1), ("Ok", 7)));
|
||||
|
||||
Assert.Equal(new[] { "Ok" }, map.Keys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToWidthMap_NullOrWrongShape_ReturnsEmpty()
|
||||
{
|
||||
Assert.Empty(FdsColumnWidths.ToWidthMap(null));
|
||||
var wrong = new DataTable();
|
||||
wrong.Columns.Add("name");
|
||||
wrong.Rows.Add("Note");
|
||||
Assert.Empty(FdsColumnWidths.ToWidthMap(wrong));
|
||||
}
|
||||
|
||||
// ── FindOverflows ────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, false)]
|
||||
[InlineData(254, false)]
|
||||
[InlineData(255, false)] // exactly the limit fits
|
||||
[InlineData(256, true)] // one over is rejected by SQL Server
|
||||
[InlineData(753, true)] // the production ServiceObject note
|
||||
public void FindOverflows_NoteLengthAroundLimit_ReportsOnlyWhenOver(int length, bool overflows)
|
||||
{
|
||||
var batch = ServiceObjectBatch((LongNoteServiceObjectId, new string('x', length)));
|
||||
|
||||
var result = FdsColumnWidths.FindOverflows(batch, Widths(("Note", 255), ("Name", 255), ("setid", 5)));
|
||||
|
||||
if (!overflows) { Assert.Empty(result); return; }
|
||||
var o = Assert.Single(result);
|
||||
Assert.Equal("Note", o.Column);
|
||||
Assert.Equal(255, o.MaxAllowed);
|
||||
Assert.Equal(length, o.LongestLength);
|
||||
Assert.Equal(1, o.RowsOver);
|
||||
Assert.Equal(LongNoteServiceObjectId, o.LongestRowId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindOverflows_SeveralRowsOver_CountsThemAndPointsAtTheLongest()
|
||||
{
|
||||
var batch = ServiceObjectBatch((1, new string('a', 300)), (2, "short"), (3, new string('b', 539)), (4, null));
|
||||
|
||||
var o = Assert.Single(FdsColumnWidths.FindOverflows(batch, Widths(("Note", 255))));
|
||||
|
||||
Assert.Equal(2, o.RowsOver);
|
||||
Assert.Equal(539, o.LongestLength);
|
||||
Assert.Equal(3L, o.LongestRowId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindOverflows_SeveralColumnsOver_ReportsEachColumn()
|
||||
{
|
||||
var batch = ServiceObjectBatch((7, new string('n', 300)));
|
||||
batch.Rows[0]["setid"] = "toolong";
|
||||
|
||||
var cols = FdsColumnWidths.FindOverflows(batch, Widths(("Note", 255), ("setid", 5))).Select(o => o.Column);
|
||||
|
||||
Assert.Equal(new[] { "setid", "Note" }, cols); // DataTable column order
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindOverflows_MaxOrUnknownColumns_AreNeverReported()
|
||||
{
|
||||
// CustomValues is NVARCHAR(MAX) → WidthsSql omits it → no entry in the map.
|
||||
var batch = ServiceObjectBatch((1, new string('x', 10_000)));
|
||||
batch.Rows[0]["CustomValues"] = new string('c', 100_000);
|
||||
|
||||
Assert.Empty(FdsColumnWidths.FindOverflows(batch, Widths(("Name", 255))));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindOverflows_NonStringColumnWithWidth_IsIgnored()
|
||||
{
|
||||
var batch = ServiceObjectBatch((123456789012, "x"));
|
||||
|
||||
Assert.Empty(FdsColumnWidths.FindOverflows(batch, Widths(("Id", 2), ("Version", 0))));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindOverflows_EmptyBatchOrNoWidths_ReturnsEmpty()
|
||||
{
|
||||
Assert.Empty(FdsColumnWidths.FindOverflows(ServiceObjectBatch(), Widths(("Note", 1))));
|
||||
Assert.Empty(FdsColumnWidths.FindOverflows(ServiceObjectBatch((1, new string('x', 999))), Widths()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindOverflows_TableWithoutIdColumn_ReportsWithoutRowId()
|
||||
{
|
||||
var t = new DataTable("mfr__*PartnerSet");
|
||||
t.Columns.Add("Property", typeof(string));
|
||||
t.Rows.Add(new string('p', 60));
|
||||
|
||||
var o = Assert.Single(FdsColumnWidths.FindOverflows(t, Widths(("property", 50))));
|
||||
|
||||
Assert.Null(o.LongestRowId);
|
||||
Assert.Equal("Property: 60 > 50 chars (1 row)", o.ToString());
|
||||
}
|
||||
|
||||
// ── Describe ─────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Describe_NoOverflow_SaysNoneDetected()
|
||||
{
|
||||
Assert.Equal("none detected", FdsColumnWidths.Describe(Array.Empty<FdsColumnWidths.Overflow>()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Describe_Overflows_NameColumnLengthLimitAndId()
|
||||
{
|
||||
var text = FdsColumnWidths.Describe(new[]
|
||||
{
|
||||
new FdsColumnWidths.Overflow("Note", 255, 753, 1, LongNoteServiceObjectId),
|
||||
new FdsColumnWidths.Overflow("Name", 255, 300, 3, 42L),
|
||||
});
|
||||
|
||||
Assert.Equal("Note: 753 > 255 chars (1 row, e.g. Id=38722404355); Name: 300 > 255 chars (3 rows, e.g. Id=42)", text);
|
||||
}
|
||||
|
||||
// ── Real SSDT schema: the widened Note must stay unbounded end to end ────
|
||||
|
||||
private static readonly Regex ColumnDecl = new(
|
||||
@"\[(?<col>[^\]]+)\]\s+\[?n?varchar\]?\s*\(\s*(?<len>\d+|max)\s*\)",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
private static string? FindDatabaseProject()
|
||||
{
|
||||
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir != null; dir = dir.Parent)
|
||||
{
|
||||
string candidate = Path.Combine(dir.FullName, "Fuchs_Database", "dbo");
|
||||
if (Directory.Exists(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Declared char widths of the bounded string columns in an SSDT object (MAX omitted, like WidthsSql).</summary>
|
||||
private static Dictionary<string, int> DeclaredWidths(string relativePath, string? onlyWithin = null)
|
||||
{
|
||||
string? root = FindDatabaseProject();
|
||||
if (root == null) Assert.Skip("Fuchs_Database SSDT project not present in this checkout.");
|
||||
string sql = File.ReadAllText(Path.Combine(root!, relativePath));
|
||||
if (onlyWithin != null)
|
||||
{
|
||||
int start = sql.IndexOf(onlyWithin, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.True(start >= 0, $"'{onlyWithin}' not found in {relativePath}");
|
||||
sql = sql[start..sql.IndexOf(");", start, StringComparison.Ordinal)];
|
||||
}
|
||||
var map = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (Match m in ColumnDecl.Matches(sql))
|
||||
if (!m.Groups["len"].Value.Equals("max", StringComparison.OrdinalIgnoreCase))
|
||||
map[m.Groups["col"].Value] = int.Parse(m.Groups["len"].Value);
|
||||
Assert.NotEmpty(map); // the regex still understands the file
|
||||
return map;
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> NoteCarriers() => new[]
|
||||
{
|
||||
new object[] { @"Tables\mfr__serviceobjects.sql", null! },
|
||||
new object[] { @"User Defined Types\mfr__tt__serviceobjects.sql", null! },
|
||||
new object[] { @"Tables\mfr__companies.sql", null! },
|
||||
new object[] { @"User Defined Types\mfr__tt__companies.sql", null! },
|
||||
// INSERT INTO @company SELECT cy.* FROM mfr__companies — must be as wide as the table.
|
||||
new object[] { @"Stored Procedures\fds__getRequest_details.sql", "DECLARE @company TABLE" },
|
||||
new object[] { @"Stored Procedures\fds__prepStorno_recreate.sql", "DECLARE @company TABLE" },
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(NoteCarriers))]
|
||||
public void SsdtSchema_NoteColumn_IsUnbounded(string relativePath, string? onlyWithin)
|
||||
{
|
||||
var widths = DeclaredWidths(relativePath.Replace('\\', Path.DirectorySeparatorChar), onlyWithin);
|
||||
|
||||
Assert.False(widths.ContainsKey("Note"), $"{relativePath}: [Note] is bounded ({(widths.TryGetValue("Note", out int w) ? w : 0)}) — mfr notes exceed 255 chars");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"Tables\mfr__serviceobjects.sql", @"User Defined Types\mfr__tt__serviceobjects.sql")]
|
||||
[InlineData(@"Tables\mfr__companies.sql", @"User Defined Types\mfr__tt__companies.sql")]
|
||||
public void SsdtSchema_ProductionLongNote_FitsTableAndTableType(string table, string type)
|
||||
{
|
||||
var widths = DeclaredWidths(table.Replace('\\', Path.DirectorySeparatorChar));
|
||||
foreach (var kv in DeclaredWidths(type.Replace('\\', Path.DirectorySeparatorChar)))
|
||||
widths[kv.Key] = widths.TryGetValue(kv.Key, out int w) ? Math.Min(w, kv.Value) : kv.Value;
|
||||
var batch = ServiceObjectBatch((LongNoteServiceObjectId, new string('x', 753)));
|
||||
|
||||
Assert.Empty(FdsColumnWidths.FindOverflows(batch, widths));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SsdtSchema_OldNarrowNote_WouldHaveBeenReported()
|
||||
{
|
||||
// Failing-path counterpart: with the pre-fix NVARCHAR(255) the same batch is diagnosed.
|
||||
var widths = DeclaredWidths(Path.Combine("User Defined Types", "mfr__tt__serviceobjects.sql"));
|
||||
widths["Note"] = 255;
|
||||
var batch = ServiceObjectBatch((LongNoteServiceObjectId, new string('x', 753)));
|
||||
|
||||
Assert.Equal("Note: 753 > 255 chars (1 row, e.g. Id=38722404355)",
|
||||
FdsColumnWidths.Describe(FdsColumnWidths.FindOverflows(batch, widths)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using fds;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers <see cref="FdsSqlOptions"/> (the MFR-sync worker's equivalent of
|
||||
/// <c>Fuchs.intranet.FIS_SQLOptions</c>): after ADR 0024, it must log a SQL error through
|
||||
/// <see cref="ILogger"/> alone — the original 2026-09-16 log review's deadlock/timeout rows came
|
||||
/// from this exact class calling both <see cref="ILogger"/> and a direct
|
||||
/// <c>EXECUTE [dbo].[fds__admin_logdebug]</c>, which would double-write every row once the
|
||||
/// worker's ILogger calls also reach the SQL log sink (hosted in the same process as the web app,
|
||||
/// ADR 0010).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Shares <see cref="StaticOcoreLoggerCollection"/> with <c>ErrorLoggingTests</c>: both save/
|
||||
/// mutate/restore the process-wide static <see cref="OCORE.Logging.Logger"/> field, which would
|
||||
/// otherwise race across the two classes since xUnit parallelizes different test classes by
|
||||
/// default.
|
||||
/// </remarks>
|
||||
[Collection(StaticOcoreLoggerCollection.Name)]
|
||||
public class FdsSqlOptionsTests
|
||||
{
|
||||
private sealed class RecordingLogger : ILogger
|
||||
{
|
||||
public sealed record Entry(LogLevel Level, EventId EventId, Exception? Exception, string Message,
|
||||
IReadOnlyDictionary<string, object?> State);
|
||||
|
||||
public readonly List<Entry> Entries = new();
|
||||
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
var pairs = (state as IEnumerable<KeyValuePair<string, object>>)?
|
||||
.ToDictionary(kv => kv.Key, kv => (object?)kv.Value) ?? new Dictionary<string, object?>();
|
||||
Entries.Add(new Entry(logLevel, eventId, exception, formatter(state, exception), pairs));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnError_LogsOnce_WithProcedureAsEventIdName_NotPrefixed()
|
||||
{
|
||||
var logger = new RecordingLogger();
|
||||
var dic = new Dictionary<string, object> { ["uid"] = "abc" };
|
||||
var opt = new FdsSqlOptions(dic, logger);
|
||||
var ex = new TimeoutException("deadlock victim");
|
||||
|
||||
opt.OnError!("mfr__getSchema", ex, new { sql = "EXECUTE [dbo].[mfr__getSchema] 'table', @tgttype;" });
|
||||
|
||||
var entry = Assert.Single(logger.Entries);
|
||||
Assert.Equal(LogLevel.Error, entry.Level);
|
||||
// Unprefixed: the direct DebugLog() call this replaced passed `procedure` as-is.
|
||||
Assert.Equal("mfr__getSchema", entry.EventId.Name);
|
||||
Assert.Same(ex, entry.Exception);
|
||||
Assert.Equal("mfr__getSchema", entry.State["Procedure"]);
|
||||
Assert.Same(dic, entry.State["CtrlInfo"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnError_NoLoggerGiven_FallsBackToStaticOcoreLogger()
|
||||
{
|
||||
var fallback = new RecordingLogger();
|
||||
var saved = OCORE.Logging.Logger;
|
||||
OCORE.Logging.Logger = fallback;
|
||||
try
|
||||
{
|
||||
var opt = new FdsSqlOptions();
|
||||
opt.OnError!("fds__getUpdateableRequests", new Exception("boom"), new { sql = "SELECT 1;" });
|
||||
|
||||
var entry = Assert.Single(fallback.Entries);
|
||||
Assert.Equal("fds__getUpdateableRequests", entry.EventId.Name);
|
||||
}
|
||||
finally { OCORE.Logging.Logger = saved; }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnError_NoLoggerAndNoStaticFallback_DoesNotThrow()
|
||||
{
|
||||
var saved = OCORE.Logging.Logger;
|
||||
OCORE.Logging.Logger = null;
|
||||
try
|
||||
{
|
||||
var opt = new FdsSqlOptions();
|
||||
var recorded = Record.Exception(() =>
|
||||
opt.OnError!("mfr__getSchema", new Exception("x"), new { sql = "SELECT 1;" }));
|
||||
|
||||
Assert.Null(recorded);
|
||||
}
|
||||
finally { OCORE.Logging.Logger = saved; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Installs PdfSharp's global font resolver once, before any test runs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="PdfSharp.Fonts.GlobalFontSettings.FontResolver"/> is process-wide and, once used,
|
||||
/// cannot be swapped — PdfSharp throws "You must not change font resolver after it was once used".
|
||||
/// Two independent installers exist in this solution: <c>FuchsPdf.EnsureFontResolver</c> (installs
|
||||
/// <c>OCOREFontResolver</c>, used by <see cref="PdfPipelineTests"/>) and eRechnungLib's
|
||||
/// <c>SystemFontResolver.EnsureInstalled</c> (installs itself only when none is set yet, used
|
||||
/// internally by the parameterless <c>ToZugferd(profile)</c> overload some tests call, e.g.
|
||||
/// <see cref="ERechnungMapperTests"/>). Production code never hits that second path — it always
|
||||
/// supplies its own rendered PDF (ADR 0012) — so this conflict is test-process-only, but whichever
|
||||
/// installer's first render happened to run first (an xUnit test-execution-order accident, not a
|
||||
/// property of the code under test) would win for every other test in the process. Installing
|
||||
/// OCOREFontResolver here, before test discovery hands control to any test method, makes the
|
||||
/// outcome deterministic instead of order-dependent.
|
||||
/// </remarks>
|
||||
internal static class FontResolverTestBootstrap
|
||||
{
|
||||
[ModuleInitializer]
|
||||
internal static void Install() =>
|
||||
PdfSharp.Fonts.GlobalFontSettings.FontResolver = new OCORE_web_pdf.pdf.OCOREFontResolver();
|
||||
}
|
||||
@@ -15,8 +15,8 @@
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="coverlet.collector" Version="10.0.1">
|
||||
<PackageReference Include="Moq" Version="4.21.0" />
|
||||
<PackageReference Include="coverlet.collector" Version="10.1.0">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Fuchs.Services;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OCORE.web.pwmailer;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the Fuchs override of <see cref="PWMailerClientService.ValidateMessage"/>: the Mailer
|
||||
/// contract's attachment limits (count, per-file and per-message size, forbidden extensions) are
|
||||
/// enforced before any HTTP request, on top of the base rules (required fields, empty file,
|
||||
/// missing extension), and a compliant message still goes out. The transport is a scripted
|
||||
/// handler that fails the test if it is reached when it must not be.
|
||||
/// </summary>
|
||||
public class FuchsMailerClientTests
|
||||
{
|
||||
private sealed class ScriptedHandler : HttpMessageHandler
|
||||
{
|
||||
public int Calls;
|
||||
public string Body = "{\"comStackID\":\"11111111-2222-3333-4444-555555555555\",\"attachmentsReceived\":1,\"attachmentsProcessed\":1}";
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
Calls++;
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(Body) });
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Factory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpMessageHandler _handler;
|
||||
public Factory(HttpMessageHandler handler) => _handler = handler;
|
||||
public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false);
|
||||
}
|
||||
|
||||
private static (FuchsMailerClient client, ScriptedHandler handler) Make(MailerAttachmentLimits? limits = null)
|
||||
{
|
||||
var handler = new ScriptedHandler();
|
||||
var options = Options.Create(new PWMailerClientOptions
|
||||
{
|
||||
BaseUrl = "https://mailer.test/api/mailer", Account = "acct", Token = "tok",
|
||||
ServerId = "11111111-2222-3333-4444-555555555555", RetryDelayMilliseconds = 0
|
||||
});
|
||||
var settings = Options.Create(new ProcessWebComSettings { Attachments = limits ?? new MailerAttachmentLimits() });
|
||||
return (new FuchsMailerClient(options, settings, new Factory(handler), NullLogger<PWMailerClientService>.Instance), handler);
|
||||
}
|
||||
|
||||
private static PWMailerMessage Message(params PWMailerAttachment[] attachments)
|
||||
{
|
||||
var m = new PWMailerMessage { To = "a@example.com", Subject = "Hi", Body = "<p>x</p>", Reference = "R1" };
|
||||
m.Attachments.AddRange(attachments);
|
||||
return m;
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> LimitViolations()
|
||||
{
|
||||
yield return [new PWMailerAttachment[] { new("run.EXE", [1]) }, "forbidden extension (.exe)"];
|
||||
yield return [new PWMailerAttachment[] { new("script.js", [1]) }, "forbidden extension (.js)"];
|
||||
yield return [Enumerable.Range(1, 11).Select(i => new PWMailerAttachment($"f{i}.pdf", [1])).ToArray(), "Too many attachments: 11 (max 10)"];
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(LimitViolations))]
|
||||
public async Task PushAsync_AttachmentViolatesContractLimits_RefusedBeforeSending(PWMailerAttachment[] attachments, string expected)
|
||||
{
|
||||
var (client, handler) = Make();
|
||||
|
||||
var result = await client.PushAsync(Message(attachments));
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(0, result.StatusCode);
|
||||
Assert.Equal(0, result.Attempts);
|
||||
Assert.Contains(expected, result.Error);
|
||||
Assert.Equal(0, handler.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PushAsync_PerFileAndPerMessageSizeLimits_AreEnforcedFromSettings()
|
||||
{
|
||||
var (client, handler) = Make(new MailerAttachmentLimits { MaxFileBytes = 100, MaxMessageBytes = 150, MaxCount = 10 });
|
||||
|
||||
var tooBig = await client.PushAsync(Message(new PWMailerAttachment("a.pdf", new byte[101])));
|
||||
Assert.Contains("'a.pdf' is 101 bytes (max 100)", tooBig.Error);
|
||||
|
||||
var total = await client.PushAsync(Message(new PWMailerAttachment("a.pdf", new byte[100]), new PWMailerAttachment("b.pdf", new byte[51])));
|
||||
Assert.Contains("total 151 bytes (max 150", total.Error);
|
||||
|
||||
Assert.Equal(0, handler.Calls);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("leer.pdf", new byte[0], "is empty")]
|
||||
[InlineData("noext", new byte[] { 1 }, "no file extension")]
|
||||
public async Task PushAsync_BaseContractRules_StillAppliedThroughOverride(string name, byte[] bytes, string expected)
|
||||
{
|
||||
var (client, handler) = Make();
|
||||
|
||||
var result = await client.PushAsync(Message(new PWMailerAttachment(name, bytes)));
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains(expected, result.Error);
|
||||
Assert.Equal(0, handler.Calls);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Rechnung.pdf")]
|
||||
[InlineData("export.CSV")]
|
||||
[InlineData("archive.7z")]
|
||||
public async Task PushAsync_AllowedAttachment_IsSent(string name)
|
||||
{
|
||||
var (client, handler) = Make();
|
||||
|
||||
var result = await client.PushAsync(Message(new PWMailerAttachment(name, [1, 2])));
|
||||
|
||||
Assert.True(result.Success, result.Error);
|
||||
Assert.Equal(1, handler.Calls);
|
||||
Assert.Equal(1, result.Value!.AttachmentsProcessed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MailerAttachmentPolicy_NoAttachments_NoViolation()
|
||||
{
|
||||
Assert.Empty(MailerAttachmentPolicy.Validate(Array.Empty<PWMailerAttachment>(), new MailerAttachmentLimits()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Fuchs.intranet;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Invoice amounts reach the procs as exact, cent-rounded decimals, independent of the server
|
||||
/// culture. Regression for the Stornorechnung save failure: the VAT amount was formatted
|
||||
/// invariantly ("-615.3757999999999", unrounded double from a reloaded Storno line) and re-parsed
|
||||
/// under de-DE as -6153757999999999 → "Error converting data type float to numeric".
|
||||
/// </summary>
|
||||
public class InvoiceAmountParamTests : IDisposable
|
||||
{
|
||||
private readonly CultureInfo _culture = CultureInfo.CurrentCulture;
|
||||
public void Dispose() => CultureInfo.CurrentCulture = _culture;
|
||||
|
||||
private static readonly string[] AmountParams =
|
||||
{ "@InvoiceBalance", "@InvoiceBalance_net", "@InvoiceVAT_net1", "@InvoiceService_net", "@InvoiceService_VAT" };
|
||||
|
||||
/// <summary>The exact shape InvoiceDraftEditService.BuildFdsData produces: decimal sums, VAT map by rate.</summary>
|
||||
private static FdsInvoiceData StornoDraft(object vatAmount) => new(new JObject
|
||||
{
|
||||
["admin"] = new JObject { ["type"] = "c", ["customerid"] = "7" },
|
||||
["new"] = new JObject { ["title"] = "Stornorechnung", ["total_net"] = -3238.82m, ["total_gross"] = -3854.20m },
|
||||
["sms"] = new JObject
|
||||
{
|
||||
["tscn"] = -1234.56m, ["tscvat"] = -234.57m,
|
||||
["vat"] = new JObject { ["19"] = JToken.FromObject(vatAmount) }
|
||||
}
|
||||
});
|
||||
|
||||
private static decimal Val(List<SqlParameter> pl, string name)
|
||||
{
|
||||
var p = pl.Single(x => x.ParameterName == name);
|
||||
Assert.Equal(SqlDbType.Decimal, p.SqlDbType);
|
||||
return Assert.IsType<decimal>(p.Value);
|
||||
}
|
||||
|
||||
public static TheoryData<string, object> CulturesAndVat => new()
|
||||
{
|
||||
{ "de-DE", -615.38m },
|
||||
{ "en-US", -615.38m },
|
||||
{ "de-DE", -615.3757999999999 }, // unrounded double from a reloaded Storno line (net * vat * 0.01)
|
||||
{ "en-US", -615.3757999999999 },
|
||||
{ "de-DE", -615.3800000000001 },
|
||||
{ "", -615.38m }, // invariant
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(CulturesAndVat))]
|
||||
public void BuildInvoiceParams_StornoDraft_AmountsExactInEveryCulture(string culture, object vatAmount)
|
||||
{
|
||||
CultureInfo.CurrentCulture = new CultureInfo(culture);
|
||||
|
||||
var pl = StornoDraft(vatAmount).BuildInvoiceParams(change: true, invId: "abc");
|
||||
|
||||
Assert.Equal(-3854.20m, Val(pl, "@InvoiceBalance"));
|
||||
Assert.Equal(-3238.82m, Val(pl, "@InvoiceBalance_net"));
|
||||
Assert.Equal(-615.38m, Val(pl, "@InvoiceVAT_net1"));
|
||||
Assert.Equal(-1234.56m, Val(pl, "@InvoiceService_net"));
|
||||
Assert.Equal(-234.57m, Val(pl, "@InvoiceService_VAT"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildInvoiceParams_AmountParams_MatchProcPrecisionAndScale()
|
||||
{
|
||||
var pl = StornoDraft(-615.38m).BuildInvoiceParams(change: false, invId: "");
|
||||
|
||||
foreach (var name in AmountParams)
|
||||
{
|
||||
var p = pl.Single(x => x.ParameterName == name);
|
||||
Assert.Equal(10, p.Precision);
|
||||
// fds__setInvoice/fds__createInvoice: numeric(10,3); fds__createInvoice_Details: numeric(10,2).
|
||||
Assert.Equal(name.StartsWith("@InvoiceService_") ? 2 : 3, p.Scale);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-10_000_000.00)]
|
||||
[InlineData(10_000_000.00)]
|
||||
[InlineData(-6153757999999999.0)]
|
||||
public void BuildInvoiceParams_AmountOutOfProcRange_Throws(double vat)
|
||||
{
|
||||
var ex = Assert.Throws<InvoiceAmountOutOfRangeException>(() =>
|
||||
StornoDraft(vat).BuildInvoiceParams(change: true, invId: "abc"));
|
||||
|
||||
Assert.Equal("@InvoiceVAT_net1", ex.Parameter);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(9_999_999.99)]
|
||||
[InlineData(-9_999_999.99)]
|
||||
[InlineData(0.0)]
|
||||
public void BuildInvoiceParams_AmountAtProcRangeBoundary_Accepted(double vat)
|
||||
{
|
||||
var pl = StornoDraft(vat).BuildInvoiceParams(change: true, invId: "abc");
|
||||
|
||||
Assert.Equal((decimal)vat, Val(pl, "@InvoiceVAT_net1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildInvoiceParams_MissingAmounts_KeepPreviousNullAndZeroSemantics()
|
||||
{
|
||||
var noSms = new FdsInvoiceData(new JObject { ["admin"] = new JObject(), ["new"] = new JObject() })
|
||||
.BuildInvoiceParams(change: false, invId: "");
|
||||
Assert.Equal(DBNull.Value, noSms.Single(p => p.ParameterName == "@InvoiceBalance").Value);
|
||||
Assert.Equal(0m, noSms.Single(p => p.ParameterName == "@InvoiceService_net").Value); // no sms block → 0
|
||||
Assert.Equal(0m, noSms.Single(p => p.ParameterName == "@InvoiceVAT_net1").Value); // HighestVat → "0"
|
||||
|
||||
var emptySms = new FdsInvoiceData(new JObject { ["new"] = new JObject(), ["sms"] = new JObject() })
|
||||
.BuildInvoiceParams(change: false, invId: "");
|
||||
Assert.Equal(DBNull.Value, emptySms.Single(p => p.ParameterName == "@InvoiceService_net").Value); // key missing → NULL
|
||||
|
||||
// No "new" block at all: the legacy SQL_Float(nz(...) ?? "0") sent 0, not NULL.
|
||||
var noNew = new FdsInvoiceData(new JObject { ["admin"] = new JObject(), ["sms"] = new JObject() })
|
||||
.BuildInvoiceParams(change: false, invId: "");
|
||||
Assert.Equal(0m, noNew.Single(p => p.ParameterName == "@InvoiceBalance").Value);
|
||||
Assert.Equal(0m, noNew.Single(p => p.ParameterName == "@InvoiceBalance_net").Value);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("-19.95", "-19.95")]
|
||||
[InlineData("-19,95", "-19.95")]
|
||||
[InlineData("-1234.5600000000002", "-1234.56")]
|
||||
[InlineData("-615.3757999999999", "-615.38")]
|
||||
[InlineData("1234,565", "1234.57")] // rounded half away from zero
|
||||
[InlineData("-1234,565", "-1234.57")]
|
||||
[InlineData("-1.5E-05", "0")]
|
||||
[InlineData(" 42 ", "42")]
|
||||
public void ParseAmount_Strings_ParsedCultureIndependentlyAndRoundedToCents(string raw, string expected)
|
||||
{
|
||||
foreach (var c in new[] { "de-DE", "en-US" })
|
||||
{
|
||||
CultureInfo.CurrentCulture = new CultureInfo(c);
|
||||
Assert.Equal(decimal.Parse(expected, CultureInfo.InvariantCulture), FdsInvoiceData.ParseAmount(raw));
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("NaN")]
|
||||
[InlineData("abc")]
|
||||
[InlineData(double.NaN)]
|
||||
[InlineData(double.PositiveInfinity)]
|
||||
public void ParseAmount_EmptyOrInvalid_IsNull(object? raw) =>
|
||||
Assert.Null(FdsInvoiceData.ParseAmount(raw));
|
||||
|
||||
[Fact]
|
||||
public void ParseAmount_NumbersAndJValues_TakenAsIs()
|
||||
{
|
||||
Assert.Equal(-3238.82m, FdsInvoiceData.ParseAmount(-3238.82m));
|
||||
Assert.Equal(-3238.82m, FdsInvoiceData.ParseAmount(-3238.82));
|
||||
Assert.Equal(-12m, FdsInvoiceData.ParseAmount(-12));
|
||||
Assert.Equal(-3238.82m, FdsInvoiceData.ParseAmount(new JValue(-3238.82m)));
|
||||
Assert.Null(FdsInvoiceData.ParseAmount(JValue.CreateNull()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Fuchs.intranet;
|
||||
using Xunit;
|
||||
using static OCORE.OCORE_dictionaries;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// ADR 0032: one invoice date for the PDF ("Rechnungsdatum") and the e-invoice issue date —
|
||||
/// DateFinalized (incl. backdated), else DateCreated — converted from the stored UTC to the German
|
||||
/// calendar day; plus the backdate request/response parsing used by inv/dpreview and req/sconf.
|
||||
/// </summary>
|
||||
public class InvoiceDatesTests
|
||||
{
|
||||
private static GenericObjectDictionary Reg(object? finalized, object? created) => new(new Dictionary<string, object?>
|
||||
{
|
||||
["DateFinalized"] = finalized,
|
||||
["DateCreated"] = created
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public void IssueDate_FinalizedAndCreated_PrefersDateFinalized()
|
||||
=> Assert.Equal(new DateOnly(2026, 9, 30),
|
||||
InvoiceDates.IssueDate(Reg(new DateTime(2026, 9, 30, 12, 0, 0), new DateTime(2026, 9, 2, 8, 0, 0))));
|
||||
|
||||
[Fact]
|
||||
public void IssueDate_DraftWithoutFinalized_FallsBackToDateCreated()
|
||||
=> Assert.Equal(new DateOnly(2026, 9, 2),
|
||||
InvoiceDates.IssueDate(Reg(DBNull.Value, new DateTime(2026, 9, 2, 8, 0, 0))));
|
||||
|
||||
[Fact]
|
||||
public void IssueDate_NoRegistration_UsesProvidedUtcNow()
|
||||
=> Assert.Equal(new DateOnly(2026, 3, 1),
|
||||
InvoiceDates.IssueDate(null, new DateTime(2026, 3, 1, 10, 0, 0, DateTimeKind.Utc)));
|
||||
|
||||
[Theory]
|
||||
// UTC late evening is already the next German day (summer +2h, winter +1h)
|
||||
[InlineData("2026-09-30T22:30:00", "2026-10-01")]
|
||||
[InlineData("2026-09-30T21:59:00", "2026-09-30")]
|
||||
[InlineData("2026-01-31T23:30:00", "2026-02-01")]
|
||||
[InlineData("2026-01-31T22:59:00", "2026-01-31")]
|
||||
// backdated invoices are stored at 12:00 UTC and stay on their day
|
||||
[InlineData("2026-08-31T12:00:00", "2026-08-31")]
|
||||
[InlineData("2025-12-31T12:00:00", "2025-12-31")]
|
||||
public void IssueDate_StoredUtc_ReturnsGermanCalendarDay(string storedUtc, string expected)
|
||||
=> Assert.Equal(DateOnly.Parse(expected),
|
||||
InvoiceDates.IssueDate(Reg(DateTime.Parse(storedUtc), null)));
|
||||
|
||||
[Theory]
|
||||
[InlineData("2026-09-30", 2026, 9, 30)]
|
||||
[InlineData(" 2025-12-31 ", 2025, 12, 31)]
|
||||
public void ParseBackdate_IsoDate_ReturnsDate(string iso, int y, int m, int d)
|
||||
=> Assert.Equal(new DateOnly(y, m, d), InvoiceDates.ParseBackdate(iso));
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("NULL")]
|
||||
[InlineData("30.09.2026")]
|
||||
[InlineData("2026-02-30")]
|
||||
public void ParseBackdate_EmptyOrInvalid_ReturnsNull(string? iso)
|
||||
=> Assert.Null(InvoiceDates.ParseBackdate(iso));
|
||||
|
||||
[Theory]
|
||||
[InlineData("1", true)]
|
||||
[InlineData("true", true)]
|
||||
[InlineData("TRUE", true)]
|
||||
[InlineData("on", true)]
|
||||
[InlineData("0", false)]
|
||||
[InlineData("false", false)]
|
||||
[InlineData("", false)]
|
||||
[InlineData(null, false)]
|
||||
[InlineData("yes please", false)]
|
||||
public void IsBackdateRequested_FormValue_ParsesStrictly(string? value, bool expected)
|
||||
=> Assert.Equal(expected, InvoiceDates.IsBackdateRequested(value));
|
||||
|
||||
// fds__setInvoiceFinal result → what req/sconf tells the user. Only 60001 is a backdate rejection;
|
||||
// a 0-row result is the already-final/sent guard (double submit) and must not invite a duplicate.
|
||||
[Theory]
|
||||
[InlineData("", null, 1, InvoiceFinalizeOutcome.Finalized)]
|
||||
[InlineData(null, null, 1, InvoiceFinalizeOutcome.Finalized)]
|
||||
[InlineData("SQL_exception: backdating not allowed", 60001, 0, InvoiceFinalizeOutcome.BackdateNotAllowed)]
|
||||
[InlineData("SQL_exception: not authorized", 60000, 0, InvoiceFinalizeOutcome.Error)]
|
||||
[InlineData("SQL_exception: lock not acquired", 60002, 0, InvoiceFinalizeOutcome.Error)]
|
||||
[InlineData("SQL_exception: Cannot insert NULL", 515, 0, InvoiceFinalizeOutcome.Error)]
|
||||
[InlineData("SQL_exception: timeout", null, 0, InvoiceFinalizeOutcome.Error)]
|
||||
[InlineData("", null, 0, InvoiceFinalizeOutcome.AlreadyFinal)]
|
||||
[InlineData(null, null, 0, InvoiceFinalizeOutcome.AlreadyFinal)]
|
||||
public void ClassifyFinalizeResult_SqlOutcome_MapsToOutcome(string? exception, int? errorNumber, int rows, InvoiceFinalizeOutcome expected)
|
||||
=> Assert.Equal(expected, InvoiceDates.ClassifyFinalizeResult(exception, errorNumber, rows));
|
||||
|
||||
[Fact]
|
||||
public void ClassifyFinalizeResult_ErrorNumber60001WithoutExceptionText_IsNotARejection()
|
||||
// the number alone never arrives without a message; a row with no exception is a success
|
||||
=> Assert.Equal(InvoiceFinalizeOutcome.Finalized, InvoiceDates.ClassifyFinalizeResult("", 60001, 1));
|
||||
|
||||
[Theory]
|
||||
[InlineData(InvoiceFinalizeOutcome.Finalized, "ok")]
|
||||
[InlineData(InvoiceFinalizeOutcome.BackdateNotAllowed, "rejected")]
|
||||
[InlineData(InvoiceFinalizeOutcome.AlreadyFinal, "already_final")]
|
||||
[InlineData(InvoiceFinalizeOutcome.Error, "error")]
|
||||
public void OutcomeTag_EveryOutcome_HasDistinctTelemetryTag(InvoiceFinalizeOutcome outcome, string expected)
|
||||
=> Assert.Equal(expected, InvoiceDates.OutcomeTag(outcome));
|
||||
}
|
||||
@@ -169,7 +169,8 @@ public class InvoiceDraftServiceTests
|
||||
var result = await svc.FlushToDbAsync(s.Token, "user1", null!);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("INV42", result!.Id);
|
||||
Assert.Equal("INV42", result!.Data.Id);
|
||||
Assert.False(result.Updated);
|
||||
Assert.False(inv.LastChange); // new draft (no prior InvId) → create, not update
|
||||
Assert.Equal("INV42", svc.Get(s.Token)!.InvId);
|
||||
|
||||
@@ -680,9 +681,10 @@ public class InvoiceDraftServiceTests
|
||||
payload["invid"] = "INV7";
|
||||
var s = svc.OpenFromPayload(payload, "user1");
|
||||
|
||||
await svc.FlushToDbAsync(s.Token, "user1", null!);
|
||||
var result = await svc.FlushToDbAsync(s.Token, "user1", null!);
|
||||
|
||||
Assert.True(inv.LastChange); // prior InvId → update path
|
||||
Assert.True(result!.Updated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Services;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using OCORE.security;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers <see cref="InvoiceFileBackupService.BackupAndClearAsync"/>: the invoice-not-found
|
||||
/// short-circuit, that a blob backup-rename failure never blocks the DB-side backup/clear (best
|
||||
/// effort per Docs/Decisions/0027), and that the DB call failing is reported without throwing.
|
||||
/// No SQL Server: the DB-clear path uses an unconfigured connection so the write fails fast inside
|
||||
/// the OCORE helper, mirroring InvoiceServiceStoreFileTests.
|
||||
/// </summary>
|
||||
public class InvoiceFileBackupServiceTests
|
||||
{
|
||||
private sealed class RecordingLogger : ILogger<InvoiceFileBackupService>
|
||||
{
|
||||
public sealed record Entry(LogLevel Level, Exception? Exception, string Message);
|
||||
public readonly List<Entry> Entries = new();
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||
Func<TState, Exception?, string> formatter) =>
|
||||
Entries.Add(new Entry(logLevel, exception, formatter(state, exception)));
|
||||
}
|
||||
|
||||
// A refused local port: the write fails inside the OCORE helper's own try/catch (returning an
|
||||
// Exception string) instead of hanging on a connect timeout.
|
||||
private const string UnreachableConnectionString =
|
||||
"Server=127.0.0.1,1;Database=fuchs;User Id=u;Password=p;Connect Timeout=1;TrustServerCertificate=True";
|
||||
|
||||
private static FdsInvoiceData Invoice(string id = "W7VAL", bool isFinal = true, bool isExternal = false, bool hasFile = true) => new()
|
||||
{
|
||||
InvoiceRegistration = new OCORE.OCORE_dictionaries.GenericObjectDictionary(new Dictionary<string, object?>
|
||||
{
|
||||
["Id"] = id,
|
||||
["InvoiceId"] = "R2026-0258",
|
||||
["DocumentName"] = $"Rechnung_{id}.pdf",
|
||||
["IsFinal"] = isFinal,
|
||||
["IsExternal"] = isExternal,
|
||||
["hasFile"] = hasFile,
|
||||
}),
|
||||
IsDraft = !isFinal,
|
||||
};
|
||||
|
||||
private static (InvoiceFileBackupService svc, RecordingLogger log, Mock<IInvoiceService> invoices, Mock<IBlobStorageService> blob)
|
||||
MakeService(FdsInvoiceData? invoice, string? connectionString = null)
|
||||
{
|
||||
var log = new RecordingLogger();
|
||||
var invoices = new Mock<IInvoiceService>();
|
||||
invoices.Setup(i => i.LoadInvoiceAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DatabaseSecurity>()))
|
||||
.ReturnsAsync(invoice ?? new FdsInvoiceData());
|
||||
var blob = new Mock<IBlobStorageService>();
|
||||
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ConnectionStrings:fuchs_fds_ConnectionString"] = connectionString
|
||||
}).Build();
|
||||
var svc = new InvoiceFileBackupService(new Fuchs_intranet(config), invoices.Object, blob.Object, log);
|
||||
return (svc, log, invoices, blob);
|
||||
}
|
||||
|
||||
// LoadInvoiceAsync never yields a null registration for an unknown id — it yields an empty
|
||||
// dictionary (FirstRow.toObjectDictionary()), i.e. an invoice whose Id is "". Both shapes must
|
||||
// be recognised as "not found" and never fall through to the IsDraft check as "not_final".
|
||||
public static IEnumerable<object[]> NotFoundShapes() =>
|
||||
[
|
||||
[ new FdsInvoiceData() ],
|
||||
[ new FdsInvoiceData { InvoiceRegistration = new OCORE.OCORE_dictionaries.GenericObjectDictionary(new Dictionary<string, object?>()) } ],
|
||||
];
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(NotFoundShapes))]
|
||||
public async Task BackupAndClearAsync_InvoiceNotFound_ReturnsFailureWithoutTouchingBlobOrDb(FdsInvoiceData loaded)
|
||||
{
|
||||
var (svc, log, invoices, blob) = MakeService(loaded);
|
||||
|
||||
var result = await svc.BackupAndClearAsync("MISSING", "user1", new DatabaseSecurity("user1", null));
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("invoice_not_found", result.Reason);
|
||||
Assert.Null(result.BlobBackupUri);
|
||||
blob.Verify(b => b.CopyInvoicePdfToBackupAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
var entry = Assert.Single(log.Entries);
|
||||
Assert.Equal(LogLevel.Warning, entry.Level);
|
||||
Assert.Contains("not found", entry.Message);
|
||||
}
|
||||
|
||||
// ADR 0025's regeneration precondition is load-bearing: clearing a draft's file would let the
|
||||
// next read persist a draft render that then blocks the real final PDF forever; an external
|
||||
// (MFR) document can't be regenerated at all. Both must be refused before anything is touched.
|
||||
[Theory]
|
||||
[InlineData(false, false, "not_final")]
|
||||
[InlineData(true, true, "external")]
|
||||
[InlineData(false, true, "not_final")]
|
||||
public async Task BackupAndClearAsync_DraftOrExternalInvoice_RefusesWithoutTouchingBlobOrDb(
|
||||
bool isFinal, bool isExternal, string expectedReason)
|
||||
{
|
||||
var (svc, log, invoices, blob) = MakeService(Invoice(isFinal: isFinal, isExternal: isExternal), UnreachableConnectionString);
|
||||
|
||||
var result = await svc.BackupAndClearAsync("W7VAL", "user1", new DatabaseSecurity("user1", null));
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(expectedReason, result.Reason);
|
||||
Assert.Null(result.BlobBackupUri);
|
||||
blob.Verify(b => b.CopyInvoicePdfToBackupAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
var entry = Assert.Single(log.Entries);
|
||||
Assert.Equal(LogLevel.Warning, entry.Level);
|
||||
Assert.Contains("refused", entry.Message);
|
||||
Assert.DoesNotContain(log.Entries, e => e.Level == LogLevel.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BackupAndClearAsync_NoStoredFile_ReturnsNoFileWithoutCopyingBlobOrTouchingDb()
|
||||
{
|
||||
// A repeated click on an already-cleared invoice must not leave another backup_* blob
|
||||
// behind: fds__getInvoice's hasFile settles it before the copy and before the proc.
|
||||
var (svc, log, invoices, blob) = MakeService(Invoice(hasFile: false), UnreachableConnectionString);
|
||||
|
||||
var result = await svc.BackupAndClearAsync("W7VAL", "user1", new DatabaseSecurity("user1", null));
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("no_file", result.Reason);
|
||||
Assert.Null(result.BlobBackupUri);
|
||||
blob.Verify(b => b.CopyInvoicePdfToBackupAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
Assert.DoesNotContain(log.Entries, e => e.Level == LogLevel.Error); // the proc was never called
|
||||
Assert.Contains(log.Entries, e => e.Level == LogLevel.Warning && e.Message.Contains("no file stored"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BackupAndClearAsync_BlobCopySucceeds_DbFails_ReportsDbErrorWithBackupUri()
|
||||
{
|
||||
// The backup copy is taken BEFORE the DB clear (so no regeneration can overwrite the
|
||||
// original first) and the original blob is kept; a DB failure (here: no real SQL Server)
|
||||
// therefore leaves the archive intact plus a spare copy, which the result still reports.
|
||||
var backupUri = new Uri("https://test.blob.core.windows.net/invoices/W7VAL/backup_x.pdf");
|
||||
var (svc, log, invoices, blob) = MakeService(Invoice(), UnreachableConnectionString);
|
||||
blob.Setup(b => b.CopyInvoicePdfToBackupAsync("W7VAL", "Rechnung_W7VAL.pdf", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(backupUri);
|
||||
|
||||
var result = await svc.BackupAndClearAsync("W7VAL", "user1", new DatabaseSecurity("user1", null));
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("db_error", result.Reason);
|
||||
Assert.Equal(backupUri, result.BlobBackupUri);
|
||||
blob.Verify(b => b.CopyInvoicePdfToBackupAsync("W7VAL", "Rechnung_W7VAL.pdf", It.IsAny<CancellationToken>()), Times.Once);
|
||||
Assert.Contains(log.Entries, e => e.Level == LogLevel.Error && e.Message.Contains("fds__admin_backupAndClearInvoiceFile failed"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BackupAndClearAsync_BlobCopyThrows_StillAttemptsDbClearAndReturnsDbOutcome()
|
||||
{
|
||||
var (svc, log, invoices, blob) = MakeService(Invoice(), UnreachableConnectionString);
|
||||
blob.Setup(b => b.CopyInvoicePdfToBackupAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("blob boom"));
|
||||
|
||||
var result = await svc.BackupAndClearAsync("W7VAL", "user1", new DatabaseSecurity("user1", null));
|
||||
|
||||
// The blob exception is swallowed and logged; the DB step still runs (and fails here for
|
||||
// lack of a SQL Server) rather than the exception escaping BackupAndClearAsync.
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("db_error", result.Reason);
|
||||
Assert.Null(result.BlobBackupUri);
|
||||
Assert.Contains(log.Entries, e => e.Level == LogLevel.Error && e.Message.Contains("blob backup-copy threw"));
|
||||
Assert.Contains(log.Entries, e => e.Level == LogLevel.Error && e.Message.Contains("fds__admin_backupAndClearInvoiceFile failed"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Fuchs.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <c>fds__invoice_items.reference_InvId</c> links a CanceledInvoice / IntInvoiceAllocation line to the
|
||||
/// invoice it cancels / credits. It was never persisted from the editor, so fds__setInvoiceFinal
|
||||
/// could not record allocations (and a NULL reference aborted its MERGE). The posted <c>ref</c> is
|
||||
/// normalised by <see cref="InvoiceService.ItemReferenceInvId"/>.
|
||||
/// </summary>
|
||||
public class InvoiceItemReferenceTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("XH6EH", "XH6EH")]
|
||||
[InlineData(" 6BOY0 ", "6BOY0")]
|
||||
[InlineData("12345678901234567890", "12345678901234567890")] // exactly varchar(20)
|
||||
public void ItemReferenceInvId_ValidId_ReturnsTrimmedId(string raw, string expected)
|
||||
=> Assert.Equal(expected, InvoiceService.ItemReferenceInvId(raw));
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("null")]
|
||||
[InlineData("NULL")]
|
||||
[InlineData("123456789012345678901")] // longer than the column
|
||||
[InlineData("AB'; DROP")]
|
||||
[InlineData("R2025-0244")] // an invoice *number*, not an invoice id
|
||||
public void ItemReferenceInvId_InvalidOrMissing_ReturnsNull(string? raw)
|
||||
=> Assert.Null(InvoiceService.ItemReferenceInvId(raw));
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Notifications;
|
||||
using Fuchs.Services;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using MigraDoc.DocumentObjectModel;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The stored Leistungsdatum / -zeitraum must reach the invoice PDF's admin block (it was never
|
||||
/// passed to the text blocks, so every invoice printed "Leistungszeitraum: -").
|
||||
/// </summary>
|
||||
public class InvoicePdfProvisionPeriodTests
|
||||
{
|
||||
private sealed class NoHttp : IHttpClientFactory
|
||||
{
|
||||
public HttpClient CreateClient(string name) => throw new InvalidOperationException("no HTTP expected");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("2026-06-18", "18.06.2026")]
|
||||
[InlineData("2026-06-01 - 2026-06-30", "01.06.2026 - 30.06.2026")]
|
||||
[InlineData("18.06.2026", "18.06.2026")]
|
||||
[InlineData("01.06.2026 - 30.06.2026", "01.06.2026 - 30.06.2026")]
|
||||
[InlineData("R2026-0258", "R2026-0258")]
|
||||
[InlineData("", "")]
|
||||
[InlineData(null, "")]
|
||||
[InlineData("nach Absprache", "nach Absprache")]
|
||||
public void ProvisionPeriodForDisplay_MixedFormats_ShowsGermanDates(string? input, string expected)
|
||||
{
|
||||
Assert.Equal(expected, InvoiceService.ProvisionPeriodForDisplay(input));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("18.06.2026", "18.06.2026", "Leistungsdatum")]
|
||||
[InlineData("01.06.2026 - 30.06.2026", "01.06.2026 - 30.06.2026", "Leistungszeitraum")]
|
||||
[InlineData("2026-06-18", "18.06.2026", "Leistungsdatum")]
|
||||
[InlineData("2026-06-01 - 2026-06-30", "01.06.2026 - 30.06.2026", "Leistungszeitraum")]
|
||||
[InlineData(" 18.06.2026 ", "18.06.2026", "Leistungsdatum")]
|
||||
[InlineData("", "", "Leistungszeitraum")]
|
||||
[InlineData(null, "", "Leistungszeitraum")]
|
||||
public void ApplyProvisionPeriod_StoredValue_SetsGermanValueAndMatchingLabel(string? stored, string expectedValue, string expectedLabel)
|
||||
{
|
||||
var tb = new FuchsPdf.FdsTextBlocks();
|
||||
|
||||
InvoiceService.ApplyProvisionPeriod(tb, stored);
|
||||
|
||||
Assert.Equal(expectedValue, tb.ProvisionPeriod);
|
||||
Assert.Equal(expectedLabel, tb.AdminProvLabel);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("18.06.2026", "18.06.2026", "Leistungsdatum")]
|
||||
[InlineData("2026-06-01 - 2026-06-30", "01.06.2026 - 30.06.2026", "Leistungszeitraum")]
|
||||
[InlineData(null, "", "Leistungszeitraum")]
|
||||
public async Task GenerateInvoicePdfAsync_StoredProvisionPeriod_ReachesAdminBlock(string? stored, string expectedValue, string expectedLabel)
|
||||
{
|
||||
FuchsPdf.FdsTextBlocks? captured = null;
|
||||
var pdf = new Mock<IPdfService>();
|
||||
pdf.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), It.IsAny<bool>()))
|
||||
.Callback<FuchsPdf.FdsTextBlocks, bool>((tb, _) => captured = tb)
|
||||
.ReturnsAsync(new Document());
|
||||
var erechnung = new Mock<IERechnungService>();
|
||||
erechnung.SetupGet(e => e.Enabled).Returns(false);
|
||||
var config = new ConfigurationBuilder().Build();
|
||||
var svc = new InvoiceService(new Fuchs_intranet(config), pdf.Object, new Mock<IBlobStorageService>().Object,
|
||||
new Mock<IEventService>().Object, erechnung.Object, new NoHttp(), config, NullLogger<InvoiceService>.Instance);
|
||||
var reg = new Dictionary<string, object?> { ["Id"] = "W7VAL", ["InvoiceId"] = "R2026-0258" };
|
||||
if (stored != null) reg["ProvisionPeriod"] = stored;
|
||||
var invoice = new FdsInvoiceData { InvoiceRegistration = new OCORE.OCORE_dictionaries.GenericObjectDictionary(reg) };
|
||||
|
||||
await svc.GenerateInvoicePdfAsync(invoice, draft: true);
|
||||
|
||||
Assert.NotNull(captured);
|
||||
Assert.Equal(expectedValue, captured!.ProvisionPeriod);
|
||||
Assert.Equal(expectedLabel, captured.AdminProvLabel);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using Fuchs.Controllers;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <c>inv/pget</c> is the first call of both storno paths. Intranet-created invoices carry an
|
||||
/// alphanumeric Id (<c>fds__fn_invoice_id</c>, e.g. "XH6EH"); rejecting those with 400 left the
|
||||
/// storno window empty. Only mfr invoice ids (positive integers) may trigger the mfr reset.
|
||||
/// </summary>
|
||||
public class InvoicePgetIdTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("12345", 12345L)]
|
||||
[InlineData(" 987654321 ", 987654321L)]
|
||||
[InlineData("9223372036854775807", long.MaxValue)]
|
||||
public void TryGetMfrInvoiceId_NumericMfrId_ReturnsTrueWithValue(string raw, long expected)
|
||||
{
|
||||
Assert.True(IntranetController.TryGetMfrInvoiceId(raw, out long id));
|
||||
Assert.Equal(expected, id);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("XH6EH")] // Intranet invoice id
|
||||
[InlineData("6BOY0")] // alphanumeric, starts with a digit
|
||||
[InlineData("00000")] // numeric but not a valid (positive) mfr id
|
||||
[InlineData("0")]
|
||||
[InlineData("-5")]
|
||||
[InlineData("1.5")]
|
||||
[InlineData("1e3")]
|
||||
[InlineData("9223372036854775808")] // overflow
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData(null)]
|
||||
public void TryGetMfrInvoiceId_NonMfrId_ReturnsFalse(string? raw)
|
||||
{
|
||||
Assert.False(IntranetController.TryGetMfrInvoiceId(raw, out _));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System;
|
||||
using Fuchs.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The invoice header save batch must stop as soon as fds__createInvoice / fds__setInvoice fails:
|
||||
/// previously a parameter conversion error left @Id NULL and the batch went on to
|
||||
/// fds__createInvoice_Details, whose "Cannot insert NULL into InvId" buried the real error.
|
||||
/// </summary>
|
||||
public class InvoiceRegisterSqlTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(false, "[dbo].[fds__createInvoice] ", "fds__createInvoice did not return")]
|
||||
[InlineData(true, "[dbo].[fds__setInvoice] ", "fds__setInvoice did not return")]
|
||||
public void BuildRegisterInvoiceSql_GuardSitsBetweenHeaderProcAndDetails(bool change, string headerProc, string guardText)
|
||||
{
|
||||
string sql = InvoiceService.BuildRegisterInvoiceSql(change, hasProvisionLocation: true);
|
||||
|
||||
int header = sql.IndexOf(headerProc, StringComparison.Ordinal);
|
||||
int guard = sql.IndexOf("IF @Id IS NULL THROW", StringComparison.Ordinal);
|
||||
int details = sql.IndexOf("[dbo].[fds__createInvoice_Details]", StringComparison.Ordinal);
|
||||
int update = sql.IndexOf("UPDATE [dbo].[fds__invoices]", StringComparison.Ordinal);
|
||||
|
||||
Assert.True(header >= 0, "header proc missing");
|
||||
Assert.True(header < guard && guard < details && details < update, sql);
|
||||
Assert.Contains(guardText, sql);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void BuildRegisterInvoiceSql_ExactlyOneHeaderProcPerMode(bool change)
|
||||
{
|
||||
string sql = InvoiceService.BuildRegisterInvoiceSql(change, hasProvisionLocation: false);
|
||||
|
||||
Assert.Equal(change, sql.Contains("[dbo].[fds__setInvoice] @InvId,"));
|
||||
Assert.Equal(!change, sql.Contains("[dbo].[fds__createInvoice] @InvoiceType,"));
|
||||
Assert.Equal(change, sql.Contains("@InvId"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, false)]
|
||||
[InlineData(true, true)]
|
||||
public void BuildRegisterInvoiceSql_ProvisionLocationUpdateOnlyWhenPresent(bool hasProvisionLocation, bool expected)
|
||||
{
|
||||
string sql = InvoiceService.BuildRegisterInvoiceSql(change: true, hasProvisionLocation);
|
||||
|
||||
Assert.Equal(expected, sql.Contains("@ProvisionLocation"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRegisterInvoiceSql_EveryStatementIsSemicolonTerminated()
|
||||
{
|
||||
// THROW requires the preceding statement to be terminated with ';'.
|
||||
string sql = InvoiceService.BuildRegisterInvoiceSql(change: true, hasProvisionLocation: true);
|
||||
|
||||
foreach (var line in sql.Split('\n'))
|
||||
Assert.EndsWith(";", line.TrimEnd());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Notifications;
|
||||
using Fuchs.Services;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MigraDoc.DocumentObjectModel;
|
||||
using Moq;
|
||||
using OCORE.security;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Exercises the failure branches of <see cref="InvoiceService.StoreInvoiceDocumentFileAsync"/>.
|
||||
/// The method signals every failure with an empty array, so the three distinct causes — render
|
||||
/// threw, render produced no bytes, DB store failed — are only distinguishable by what gets
|
||||
/// logged. Each must be logged individually, and none may reach the blob upload or the
|
||||
/// InvoiceFileCreated event. No SQL Server: the store-failure case uses an unconfigured
|
||||
/// connection so the write fails fast inside the OCORE helper.
|
||||
/// </summary>
|
||||
public class InvoiceServiceStoreFileTests
|
||||
{
|
||||
private sealed class RecordingLogger : ILogger<InvoiceService>
|
||||
{
|
||||
public sealed record Entry(LogLevel Level, Exception? Exception, string Message);
|
||||
public readonly List<Entry> Entries = new();
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||
Func<TState, Exception?, string> formatter) =>
|
||||
Entries.Add(new Entry(logLevel, exception, formatter(state, exception)));
|
||||
}
|
||||
|
||||
private sealed class NoHttp : IHttpClientFactory
|
||||
{
|
||||
public HttpClient CreateClient(string name) => throw new InvalidOperationException("no HTTP expected");
|
||||
}
|
||||
|
||||
// A refused local port: the fds__setInvoiceFile write fails inside the OCORE helper's own
|
||||
// try/catch (returning false) instead of hanging on a connect timeout.
|
||||
private const string UnreachableConnectionString =
|
||||
"Server=127.0.0.1,1;Database=fuchs;User Id=u;Password=p;Connect Timeout=1;TrustServerCertificate=True";
|
||||
|
||||
private static (InvoiceService svc, RecordingLogger log, Mock<IBlobStorageService> blob, Mock<IEventService> events)
|
||||
MakeService(Mock<IPdfService> pdf, string? connectionString = null)
|
||||
{
|
||||
var log = new RecordingLogger();
|
||||
var blob = new Mock<IBlobStorageService>();
|
||||
var events = new Mock<IEventService>();
|
||||
var erechnung = new Mock<IERechnungService>();
|
||||
erechnung.SetupGet(e => e.Enabled).Returns(false);
|
||||
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ConnectionStrings:fuchs_fds_ConnectionString"] = connectionString
|
||||
}).Build();
|
||||
var svc = new InvoiceService(
|
||||
new Fuchs_intranet(config),
|
||||
pdf.Object, blob.Object, events.Object, erechnung.Object,
|
||||
new NoHttp(), config, log);
|
||||
return (svc, log, blob, events);
|
||||
}
|
||||
|
||||
private static FdsInvoiceData Invoice(bool isFinal) => new()
|
||||
{
|
||||
InvoiceRegistration = new OCORE.OCORE_dictionaries.GenericObjectDictionary(new Dictionary<string, object?>
|
||||
{
|
||||
["Id"] = "W7VAL",
|
||||
["InvoiceId"] = "R2026-0258",
|
||||
["IsFinal"] = isFinal
|
||||
}),
|
||||
IsDraft = !isFinal
|
||||
};
|
||||
|
||||
private static Mock<IPdfService> PdfReturning(byte[] bytes)
|
||||
{
|
||||
var pdf = new Mock<IPdfService>();
|
||||
pdf.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), It.IsAny<bool>()))
|
||||
.ReturnsAsync(new Document());
|
||||
pdf.Setup(p => p.DocToPdfBytes(It.IsAny<Document>())).Returns(bytes);
|
||||
return pdf;
|
||||
}
|
||||
|
||||
private static void AssertNothingPersisted(Mock<IBlobStorageService> blob, Mock<IEventService> events)
|
||||
{
|
||||
blob.Verify(b => b.UploadInvoicePdfAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<byte[]>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, object?>>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
events.Verify(e => e.InvoiceFileCreatedAsync(It.IsAny<FdsInvoiceData>(), It.IsAny<string>(), It.IsAny<string>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task StoreInvoiceDocumentFileAsync_RenderProducesZeroBytes_ReturnsEmptyAndLogsError(bool draft)
|
||||
{
|
||||
var (svc, log, blob, events) = MakeService(PdfReturning(Array.Empty<byte>()));
|
||||
|
||||
var result = await svc.StoreInvoiceDocumentFileAsync(Invoice(isFinal: !draft), draft, "user1", new DatabaseSecurity("user1", null));
|
||||
|
||||
Assert.Empty(result);
|
||||
var entry = Assert.Single(log.Entries, e => e.Level == LogLevel.Error);
|
||||
Assert.Null(entry.Exception);
|
||||
Assert.Contains("produced 0 bytes", entry.Message);
|
||||
Assert.Contains("W7VAL", entry.Message);
|
||||
Assert.Contains($"draft={draft}", entry.Message);
|
||||
AssertNothingPersisted(blob, events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreInvoiceDocumentFileAsync_RenderThrows_ReturnsEmptyAndLogsExceptionNotZeroBytes()
|
||||
{
|
||||
var pdf = new Mock<IPdfService>();
|
||||
var boom = new InvalidOperationException("font resolver exploded");
|
||||
pdf.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), It.IsAny<bool>())).ThrowsAsync(boom);
|
||||
var (svc, log, blob, events) = MakeService(pdf);
|
||||
|
||||
var result = await svc.StoreInvoiceDocumentFileAsync(Invoice(isFinal: true), false, "user1", new DatabaseSecurity("user1", null));
|
||||
|
||||
Assert.Empty(result);
|
||||
var entry = Assert.Single(log.Entries, e => e.Level == LogLevel.Error);
|
||||
Assert.Same(boom, entry.Exception);
|
||||
Assert.Contains("render failed", entry.Message);
|
||||
Assert.DoesNotContain(log.Entries, e => e.Message.Contains("produced 0 bytes"));
|
||||
AssertNothingPersisted(blob, events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreInvoiceDocumentFileAsync_RenderOkButDbStoreFails_ReturnsEmptyWithoutRenderErrorAndSkipsBlob()
|
||||
{
|
||||
// Render succeeds; the fds__setInvoiceFile write fails (no connection configured). The
|
||||
// SQL error is reported through FIS_SQLOptions.OnError, not through the render branches —
|
||||
// so neither render message may appear, and the PDF must not be archived to blob storage
|
||||
// as if the primary DB write had succeeded.
|
||||
var (svc, log, blob, events) = MakeService(PdfReturning(new byte[] { 0x25, 0x50, 0x44, 0x46 }), UnreachableConnectionString);
|
||||
|
||||
var result = await svc.StoreInvoiceDocumentFileAsync(Invoice(isFinal: true), false, "user1", new DatabaseSecurity("user1", null));
|
||||
|
||||
Assert.Empty(result);
|
||||
Assert.DoesNotContain(log.Entries, e => e.Message.Contains("produced 0 bytes"));
|
||||
Assert.DoesNotContain(log.Entries, e => e.Message.Contains("render failed"));
|
||||
AssertNothingPersisted(blob, events);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ using eRechnungLib.Profiles;
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Services;
|
||||
using MigraDoc.DocumentObjectModel;
|
||||
using MigraDoc.Rendering;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using PdfSharp.Pdf.IO;
|
||||
using Xunit;
|
||||
@@ -31,14 +30,6 @@ public class PdfPipelineTests
|
||||
// ── Stage 1 helper: a "dummy" visual invoice PDF built purely with PdfSharp/MigraDoc ──
|
||||
private static byte[] BuildDummyPdfWithPdfSharp()
|
||||
{
|
||||
// Same font resolver the production render path installs (PdfSharp 6 no longer
|
||||
// resolves system fonts on its own).
|
||||
if (PdfSharp.Fonts.GlobalFontSettings.FontResolver is null ||
|
||||
PdfSharp.Fonts.GlobalFontSettings.FontResolver.GetType() != typeof(OCORE_web_pdf.pdf.OCOREFontResolver))
|
||||
{
|
||||
PdfSharp.Fonts.GlobalFontSettings.FontResolver = new OCORE_web_pdf.pdf.OCOREFontResolver();
|
||||
}
|
||||
|
||||
var doc = new Document();
|
||||
doc.Info.Title = "Dummy Rechnung";
|
||||
var normal = doc.Styles["Normal"]!;
|
||||
@@ -50,11 +41,11 @@ public class PdfPipelineTests
|
||||
sec.AddParagraph("Position 1: Beratungsleistung — 200,00 EUR netto");
|
||||
sec.AddParagraph("Position 2: Entwicklung — 500,00 EUR netto");
|
||||
|
||||
var renderer = new PdfDocumentRenderer { Document = doc };
|
||||
renderer.RenderDocument();
|
||||
using var ms = new MemoryStream();
|
||||
renderer.PdfDocument.Save(ms, closeStream: false);
|
||||
return ms.ToArray();
|
||||
// Goes through FuchsPdf.DocToPdfBytesRaw so the font resolver is installed exactly the way
|
||||
// (and exactly once, thread-safely) the production render path installs it — a second,
|
||||
// separate check-then-set here previously raced PdfSharp's "must not change font resolver
|
||||
// after it was once used" guard under parallel test execution.
|
||||
return FuchsPdf.DocToPdfBytesRaw(doc);
|
||||
}
|
||||
|
||||
// ── Stage 3 helper: a minimal but EN 16931-complete domestic invoice model ──
|
||||
|
||||
@@ -3,65 +3,71 @@ using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Fuchs.Services;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using OCORE.web.pwmailer;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the outbound communication service. Cover both intentionally
|
||||
/// succeeding (API 200) and intentionally failing (invalid email, disabled,
|
||||
/// API 500) paths, plus the attachment payload contract and metric emission.
|
||||
/// Tests for the outbound communication service as an adapter over the OCORE_web
|
||||
/// <see cref="IPWMailerClientService"/> (ADR 0029). Cover both intentionally succeeding and
|
||||
/// intentionally failing paths (invalid email, disabled, API error, duplicate, refused by
|
||||
/// pre-validation, attachment count not confirmed), the <see cref="PWMailerMessage"/> handed to
|
||||
/// the client, the audit log written on every path, and metric emission. The HTTP contract itself
|
||||
/// (JSON vs multipart, unnamed envelope field, attachmentCount) is the client's responsibility and
|
||||
/// is tested in OCORE_webTests/comm/PWMailerClientServiceTests.cs.
|
||||
///
|
||||
/// Fuchs_intranet is passed as null: it is only touched by the audit-log write,
|
||||
/// which is wrapped in try/catch and therefore harmless in a unit test.
|
||||
/// </summary>
|
||||
public class ProcessWebComServiceTests
|
||||
{
|
||||
private const string FuchsMeterName = "Fuchs.Intranet";
|
||||
|
||||
// ── Test doubles ────────────────────────────────────────────────────────
|
||||
private sealed class StubHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly HttpStatusCode _status;
|
||||
private readonly string _body;
|
||||
public string? LastRequestBody { get; private set; }
|
||||
public int CallCount { get; private set; }
|
||||
private static PWMailerResult<PWMailerPushReceipt> Accepted(int received = 0, int processed = 0, string comStackId = "cs-1") =>
|
||||
new() { StatusCode = 200, Value = new PWMailerPushReceipt(comStackId, received, processed), Attempts = 1, Operation = "push_com" };
|
||||
|
||||
public StubHandler(HttpStatusCode status, string body = "OK")
|
||||
private static PWMailerResult<PWMailerPushReceipt> Failed(int status, string error) =>
|
||||
new() { StatusCode = status, Error = error, Attempts = 1, Operation = "push_com" };
|
||||
|
||||
/// <summary>The client refused the call before sending (validation, options, token): status 0, no attempt.</summary>
|
||||
private static PWMailerResult<PWMailerPushReceipt> Refused(string error) =>
|
||||
new() { StatusCode = 0, Error = error, Attempts = 0, Operation = "push_com", Exception = new ArgumentException(error) };
|
||||
|
||||
private sealed class MailerStub
|
||||
{
|
||||
public readonly Mock<IPWMailerClientService> Mock = new(MockBehavior.Loose);
|
||||
public readonly List<PWMailerMessage> Pushed = new();
|
||||
public int CallCount => Pushed.Count;
|
||||
|
||||
public MailerStub(PWMailerResult<PWMailerPushReceipt>? push = null)
|
||||
{
|
||||
_status = status;
|
||||
_body = body;
|
||||
var result = push ?? Accepted();
|
||||
Mock.Setup(m => m.PushAsync(It.IsAny<PWMailerMessage>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<PWMailerMessage, CancellationToken>((m, _) => Pushed.Add(m))
|
||||
.ReturnsAsync(result);
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
/// <summary>Answers like a Mailer that processed every file it received.</summary>
|
||||
public static MailerStub Echoing()
|
||||
{
|
||||
CallCount++;
|
||||
LastRequestBody = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken);
|
||||
return new HttpResponseMessage(_status) { Content = new StringContent(_body) };
|
||||
var stub = new MailerStub();
|
||||
stub.Mock.Setup(m => m.PushAsync(It.IsAny<PWMailerMessage>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<PWMailerMessage, CancellationToken>((m, _) => stub.Pushed.Add(m))
|
||||
.ReturnsAsync((PWMailerMessage m, CancellationToken _) => Accepted(m.Attachments.Count, m.Attachments.Count));
|
||||
return stub;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ThrowingHandler : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
=> throw new HttpRequestException("simulated transport failure");
|
||||
}
|
||||
|
||||
private sealed class StubHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpMessageHandler _handler;
|
||||
public StubHttpClientFactory(HttpMessageHandler handler) => _handler = handler;
|
||||
public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures what would be written to <c>fds__logEmail</c> without hitting a database, by
|
||||
/// overriding the audit-log write. Lets tests assert that every send path is logged and that
|
||||
@@ -70,145 +76,129 @@ public class ProcessWebComServiceTests
|
||||
private sealed class CapturingComService : ProcessWebComService
|
||||
{
|
||||
public readonly List<(bool success, List<string> log)> Entries = new();
|
||||
public readonly List<string> Configs = new();
|
||||
public readonly List<string> Guids = new();
|
||||
|
||||
public CapturingComService(
|
||||
IOptions<ProcessWebComSettings> settings,
|
||||
IOptions<FuchsEmailSettings> emailSettings,
|
||||
IHttpClientFactory httpClientFactory)
|
||||
: base(NullLogger<ProcessWebComService>.Instance, intranet: null!, settings, emailSettings, httpClientFactory)
|
||||
{
|
||||
}
|
||||
public CapturingComService(IOptions<ProcessWebComSettings> settings, IOptions<FuchsEmailSettings> emailSettings,
|
||||
IPWMailerClientService mailer)
|
||||
: base(NullLogger<ProcessWebComService>.Instance, intranet: null!, settings, emailSettings, mailer) { }
|
||||
|
||||
protected override Task WriteAuditLogAsync(string reference, string guid, string config,
|
||||
DateTime sent, bool success, IEnumerable<string> errors)
|
||||
{
|
||||
Entries.Add((success, errors.ToList()));
|
||||
Configs.Add(config);
|
||||
Guids.Add(guid);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private static CapturingComService CreateCapturing(HttpMessageHandler handler, bool enabled = true, string? overrideRecipient = null)
|
||||
{
|
||||
var settings = Options.Create(new ProcessWebComSettings
|
||||
private static IOptions<ProcessWebComSettings> Settings(bool enabled = true, string accountId = "acct", string token = "tok", string serverId = "") =>
|
||||
Options.Create(new ProcessWebComSettings
|
||||
{
|
||||
Enabled = enabled, BaseUrl = "https://mailer.test", AccountId = "acct", Token = "tok"
|
||||
Enabled = enabled, BaseUrl = "https://mailer.test", AccountId = accountId, Token = token, ServerId = serverId
|
||||
});
|
||||
var emailSettings = Options.Create(new FuchsEmailSettings { OverrideRecipient = overrideRecipient });
|
||||
return new CapturingComService(settings, emailSettings, new StubHttpClientFactory(handler));
|
||||
}
|
||||
|
||||
private static ProcessWebComService CreateService(StubHandler handler, bool enabled = true, string? overrideRecipient = null)
|
||||
{
|
||||
var settings = Options.Create(new ProcessWebComSettings
|
||||
{
|
||||
Enabled = enabled,
|
||||
BaseUrl = "https://mailer.test",
|
||||
AccountId = "acct",
|
||||
Token = "tok"
|
||||
});
|
||||
var emailSettings = Options.Create(new FuchsEmailSettings
|
||||
{
|
||||
OverrideRecipient = overrideRecipient
|
||||
});
|
||||
return new ProcessWebComService(
|
||||
NullLogger<ProcessWebComService>.Instance,
|
||||
intranet: null!,
|
||||
settings,
|
||||
emailSettings,
|
||||
new StubHttpClientFactory(handler));
|
||||
}
|
||||
private static CapturingComService CreateCapturing(MailerStub mailer, bool enabled = true, string? overrideRecipient = null) =>
|
||||
new(Settings(enabled), Options.Create(new FuchsEmailSettings { OverrideRecipient = overrideRecipient }), mailer.Mock.Object);
|
||||
|
||||
private static ProcessWebComService CreateService(MailerStub mailer, bool enabled = true, string? overrideRecipient = null) =>
|
||||
new(NullLogger<ProcessWebComService>.Instance, intranet: null!, Settings(enabled),
|
||||
Options.Create(new FuchsEmailSettings { OverrideRecipient = overrideRecipient }), mailer.Mock.Object);
|
||||
|
||||
// ── Success path ─────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_ValidAndApiOk_ReturnsTrueAndPostsOnce()
|
||||
public async Task SendEmailAsync_ValidAndApiOk_ReturnsTrueAndPushesOnce()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler);
|
||||
var mailer = new MailerStub();
|
||||
var svc = CreateService(mailer);
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_1", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde");
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
Assert.Equal(1, mailer.CallCount);
|
||||
}
|
||||
|
||||
// ── Failure paths ──────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_ApiError_ReturnsFalse()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.InternalServerError, "boom");
|
||||
var svc = CreateService(handler);
|
||||
var mailer = new MailerStub(Failed(500, "boom"));
|
||||
var svc = CreateService(mailer);
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_2", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde");
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
Assert.Equal(1, mailer.CallCount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("not-an-email")]
|
||||
[InlineData("")]
|
||||
[InlineData("missing@")]
|
||||
public async Task SendEmailAsync_InvalidEmail_ReturnsFalseWithoutCallingApi(string badEmail)
|
||||
public async Task SendEmailAsync_InvalidEmail_ReturnsFalseWithoutCallingMailer(string badEmail)
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler);
|
||||
var mailer = new MailerStub();
|
||||
var svc = CreateService(mailer);
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_3", "Subject", "<p>hi</p>", badEmail, "Kunde");
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Equal(0, handler.CallCount); // never reached the API
|
||||
Assert.Equal(0, mailer.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_Disabled_ReturnsFalseWithoutCallingApi()
|
||||
public async Task SendEmailAsync_Disabled_ReturnsFalseWithoutCallingMailer()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler, enabled: false);
|
||||
var mailer = new MailerStub();
|
||||
var svc = CreateService(mailer, enabled: false);
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_4", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde");
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Equal(0, handler.CallCount);
|
||||
Assert.Equal(0, mailer.CallCount);
|
||||
}
|
||||
|
||||
// ── Audit logging: every attempt is logged; failures carry the service response ─
|
||||
// ── Audit logging: every attempt is logged; failures carry the client's error ─
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_Success_LogsSuccessfulAttempt()
|
||||
public async Task SendEmailAsync_Success_LogsSuccessfulAttemptWithComStackId()
|
||||
{
|
||||
var svc = CreateCapturing(new StubHandler(HttpStatusCode.OK));
|
||||
var svc = CreateCapturing(new MailerStub(Accepted(comStackId: "cs-42")));
|
||||
|
||||
await svc.SendEmailAsync("inv_log_ok", "S", "<p>x</p>", "kunde@example.de", "Kunde");
|
||||
|
||||
var entry = Assert.Single(svc.Entries);
|
||||
Assert.True(entry.success);
|
||||
Assert.Equal("cs-42", Assert.Single(svc.Guids)); // what getComStatus/getCom accept
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_ApiError_LogsFailureIncludingServiceResponse()
|
||||
public async Task SendEmailAsync_ApiError_LogsFailureIncludingStatusAndError()
|
||||
{
|
||||
var svc = CreateCapturing(new StubHandler(HttpStatusCode.InternalServerError, "mailer rejected: quota exceeded"));
|
||||
var svc = CreateCapturing(new MailerStub(Failed(500, "mailer rejected: quota exceeded")));
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_log_api", "S", "<p>x</p>", "kunde@example.de", "Kunde");
|
||||
|
||||
Assert.False(result);
|
||||
var entry = Assert.Single(svc.Entries);
|
||||
Assert.False(entry.success);
|
||||
Assert.Contains(entry.log, l => l.Contains("mailer rejected: quota exceeded")); // raw service response
|
||||
Assert.Contains(entry.log, l => l.Contains("500")); // HTTP status
|
||||
Assert.Contains(entry.log, l => l.Contains("mailer rejected: quota exceeded") && l.Contains("500"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_TransportException_LogsFailureIncludingExceptionDetail()
|
||||
public async Task SendEmailAsync_ClientThrows_LogsFailureIncludingExceptionDetail()
|
||||
{
|
||||
var svc = CreateCapturing(new ThrowingHandler());
|
||||
var mailer = new MailerStub();
|
||||
mailer.Mock.Setup(m => m.PushAsync(It.IsAny<PWMailerMessage>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("simulated client failure"));
|
||||
var svc = CreateCapturing(mailer);
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_log_ex", "S", "<p>x</p>", "kunde@example.de", "Kunde");
|
||||
|
||||
Assert.False(result);
|
||||
var entry = Assert.Single(svc.Entries);
|
||||
Assert.False(entry.success);
|
||||
Assert.Contains(entry.log, l => l.Contains("Exception while sending"));
|
||||
Assert.Contains(entry.log, l => l.Contains("simulated transport failure"));
|
||||
Assert.Contains(entry.log, l => l.Contains("Exception while sending") && l.Contains("simulated client failure"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -216,7 +206,7 @@ public class ProcessWebComServiceTests
|
||||
[InlineData("")]
|
||||
public async Task SendEmailAsync_InvalidEmail_StillLogsFailedAttempt(string badEmail)
|
||||
{
|
||||
var svc = CreateCapturing(new StubHandler(HttpStatusCode.OK));
|
||||
var svc = CreateCapturing(new MailerStub());
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_log_bad", "S", "<p>x</p>", badEmail, "Kunde");
|
||||
|
||||
@@ -229,7 +219,7 @@ public class ProcessWebComServiceTests
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_Disabled_LogsFailedAttempt()
|
||||
{
|
||||
var svc = CreateCapturing(new StubHandler(HttpStatusCode.OK), enabled: false);
|
||||
var svc = CreateCapturing(new MailerStub(), enabled: false);
|
||||
|
||||
await svc.SendEmailAsync("inv_log_dis", "S", "<p>x</p>", "kunde@example.de", "Kunde");
|
||||
|
||||
@@ -267,31 +257,113 @@ public class ProcessWebComServiceTests
|
||||
Assert.Equal(when, pl.Single(p => p.ParameterName == "@DateSent").Value);
|
||||
}
|
||||
|
||||
// ── Audit-log config: fds__emaillog.config must be the JSON fds_wgd_lastEmails parses ─
|
||||
// Regression: the service logged config = "" on every path; OPENJSON('') throws, which took
|
||||
// the "last emails" dashboard widget down and lost who/what/attachments from the audit trail.
|
||||
[Fact]
|
||||
public void BuildAuditConfig_FullInput_ProducesLegacyShapeWithoutBody()
|
||||
{
|
||||
var files = new Dictionary<string, byte[]> { ["Rechnung R2026-0119.pdf"] = [1, 2], ["x.xml"] = [3] };
|
||||
|
||||
string json = ProcessWebComService.BuildAuditConfig(
|
||||
"SanitärFuchs - Rechnung", "buchhaltung@example.de", "Buchhaltung Erne", "chef@example.de", files.Keys);
|
||||
|
||||
var o = JObject.Parse(json);
|
||||
Assert.Equal("SanitärFuchs - Rechnung", (string?)o["subject"]);
|
||||
Assert.Equal("", (string?)o["from"]);
|
||||
Assert.Equal(new[] { "\"Buchhaltung Erne\" <buchhaltung@example.de>" }, o["to"]!.Select(t => (string?)t));
|
||||
Assert.Equal(new[] { "chef@example.de" }, o["cc"]!.Select(t => (string?)t));
|
||||
Assert.Empty(o["bcc"]!);
|
||||
Assert.Equal(new[] { "Rechnung R2026-0119.pdf", "x.xml" }, o["attachments"]!.Select(t => (string?)t));
|
||||
Assert.Null(o["body"]); // never the HTML body
|
||||
Assert.DoesNotContain("AQI=", json); // never attachment bytes (base64 of 1,2)
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "a@b.de", "a@b.de")] // no name → bare address
|
||||
[InlineData("", "a@b.de", "a@b.de")]
|
||||
[InlineData(" ", "a@b.de", "a@b.de")]
|
||||
[InlineData("Ann", "a@b.de", "\"Ann\" <a@b.de>")] // name → quoted display form the widget unwraps
|
||||
[InlineData(" Ann ", "a@b.de", "\"Ann\" <a@b.de>")]
|
||||
public void BuildAuditConfig_RecipientName_FormatsToArrayEntry(string? name, string email, string expected)
|
||||
{
|
||||
var o = JObject.Parse(ProcessWebComService.BuildAuditConfig("S", email, name, null, null));
|
||||
|
||||
Assert.Equal(new[] { expected }, o["to"]!.Select(t => (string?)t));
|
||||
Assert.Empty(o["cc"]!);
|
||||
Assert.Empty(o["attachments"]!);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
public void BuildAuditConfig_NullOrEmptySubject_StillValidJson(string? subject)
|
||||
{
|
||||
var o = JObject.Parse(ProcessWebComService.BuildAuditConfig(subject!, "a@b.de", null, null, null));
|
||||
Assert.Equal("", (string?)o["subject"]);
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> AllAuditPaths() =>
|
||||
[
|
||||
[ "success", 200, true, "kunde@example.de" ],
|
||||
[ "api-error", 500, true, "kunde@example.de" ],
|
||||
[ "disabled", 200, false, "kunde@example.de" ],
|
||||
[ "invalid-email", 200, true, "not-an-email" ],
|
||||
];
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(AllAuditPaths))]
|
||||
public async Task SendEmailAsync_EveryAuditPath_LogsParsableConfigWithRecipientSubjectAndAttachments(
|
||||
string path, int status, bool enabled, string email)
|
||||
{
|
||||
var mailer = status == 200 ? MailerStub.Echoing() : new MailerStub(Failed(status, "err"));
|
||||
var svc = CreateCapturing(mailer, enabled: enabled);
|
||||
var att = new Dictionary<string, byte[]> { ["Mahnung.pdf"] = [9, 9] };
|
||||
|
||||
await svc.SendEmailAsync("inv_cfg_" + path, "Subj " + path, "<p>x</p>", email, "Kunde", att);
|
||||
|
||||
string config = Assert.Single(svc.Configs);
|
||||
var o = JObject.Parse(config); // was "" → OPENJSON error in fds_wgd_lastEmails
|
||||
Assert.Equal("Subj " + path, (string?)o["subject"]);
|
||||
Assert.Equal(new[] { $"\"Kunde\" <{email}>" }, o["to"]!.Select(t => (string?)t));
|
||||
Assert.Equal(new[] { "Mahnung.pdf" }, o["attachments"]!.Select(t => (string?)t));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_OverrideRecipientSet_ConfigRecordsActualRecipientAndPrefixedSubject()
|
||||
{
|
||||
var svc = CreateCapturing(new MailerStub(), overrideRecipient: "dev-inbox@example.test");
|
||||
|
||||
await svc.SendEmailAsync("inv_cfg_ovr", "Subject", "<p>x</p>", "kunde@example.de", "Kunde", null, cc: "cc@example.de");
|
||||
|
||||
var o = JObject.Parse(Assert.Single(svc.Configs));
|
||||
Assert.Equal(new[] { "\"Kunde\" <dev-inbox@example.test>" }, o["to"]!.Select(t => (string?)t)); // where it really went
|
||||
Assert.Empty(o["cc"]!); // cc is dropped by the override
|
||||
Assert.StartsWith("[DEV → kunde@example.de] ", (string?)o["subject"]); // original recipient stays traceable
|
||||
}
|
||||
|
||||
// ── Dev/test recipient override safety net ─────────────────────────────────
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_OverrideRecipientSet_RedirectsToOverrideAddress()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler, overrideRecipient: "dev-inbox@example.test");
|
||||
var mailer = new MailerStub();
|
||||
var svc = CreateService(mailer, overrideRecipient: "dev-inbox@example.test");
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_ov1", "Subject", "<p>hi</p>", "realcustomer@example.de", "Kunde");
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
var json = JObject.Parse(handler.LastRequestBody!);
|
||||
Assert.Equal("dev-inbox@example.test", json["communication"]!["to"]!.ToString());
|
||||
Assert.Equal("dev-inbox@example.test", Assert.Single(mailer.Pushed).To);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_OverrideRecipientSet_SubjectRetainsOriginalRecipientForTraceability()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler, overrideRecipient: "dev-inbox@example.test");
|
||||
var mailer = new MailerStub();
|
||||
var svc = CreateService(mailer, overrideRecipient: "dev-inbox@example.test");
|
||||
|
||||
await svc.SendEmailAsync("inv_ov2", "Rechnung 123", "<p>hi</p>", "realcustomer@example.de", "Kunde");
|
||||
|
||||
var json = JObject.Parse(handler.LastRequestBody!);
|
||||
string subject = json["communication"]!["subject"]!.ToString();
|
||||
string subject = Assert.Single(mailer.Pushed).Subject;
|
||||
Assert.Contains("realcustomer@example.de", subject);
|
||||
Assert.Contains("Rechnung 123", subject);
|
||||
}
|
||||
@@ -299,84 +371,69 @@ public class ProcessWebComServiceTests
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_OverrideRecipientSet_InvalidOriginalAddressIsStillRedirected()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler, overrideRecipient: "dev-inbox@example.test");
|
||||
var mailer = new MailerStub();
|
||||
var svc = CreateService(mailer, overrideRecipient: "dev-inbox@example.test");
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_ov3", "Subject", "<p>hi</p>", "not-an-email", "Kunde");
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
var json = JObject.Parse(handler.LastRequestBody!);
|
||||
Assert.Equal("dev-inbox@example.test", json["communication"]!["to"]!.ToString());
|
||||
Assert.Equal("dev-inbox@example.test", Assert.Single(mailer.Pushed).To);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_NoOverrideConfigured_SendsToOriginalRecipient()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler);
|
||||
var mailer = new MailerStub();
|
||||
var svc = CreateService(mailer);
|
||||
|
||||
await svc.SendEmailAsync("inv_ov4", "Subject", "<p>hi</p>", "realcustomer@example.de", "Kunde");
|
||||
|
||||
var json = JObject.Parse(handler.LastRequestBody!);
|
||||
Assert.Equal("realcustomer@example.de", json["communication"]!["to"]!.ToString());
|
||||
Assert.Equal("realcustomer@example.de", Assert.Single(mailer.Pushed).To);
|
||||
}
|
||||
|
||||
// ── Cc recipient ("Zusatzempfänger") ─────────────────────────────────────────
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_CcProvided_IsIncludedInPayload()
|
||||
public async Task SendEmailAsync_CcProvided_IsIncludedInMessage()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler);
|
||||
var mailer = new MailerStub();
|
||||
var svc = CreateService(mailer);
|
||||
|
||||
await svc.SendEmailAsync("inv_cc1", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde",
|
||||
cc: "zusatz@example.de");
|
||||
await svc.SendEmailAsync("inv_cc1", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde", cc: "zusatz@example.de");
|
||||
|
||||
var json = JObject.Parse(handler.LastRequestBody!);
|
||||
Assert.Equal("zusatz@example.de", json["communication"]!["cc"]!.ToString());
|
||||
Assert.Equal("zusatz@example.de", Assert.Single(mailer.Pushed).Cc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_CcOmitted_IsNullInPayload()
|
||||
public async Task SendEmailAsync_CcOmitted_IsNullInMessage()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler);
|
||||
var mailer = new MailerStub();
|
||||
var svc = CreateService(mailer);
|
||||
|
||||
await svc.SendEmailAsync("inv_cc2", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde");
|
||||
|
||||
var json = JObject.Parse(handler.LastRequestBody!);
|
||||
Assert.Equal(JTokenType.Null, json["communication"]!["cc"]!.Type);
|
||||
Assert.Null(Assert.Single(mailer.Pushed).Cc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_OverrideRecipientSet_DropsCcEntirely()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler, overrideRecipient: "dev-inbox@example.test");
|
||||
var mailer = new MailerStub();
|
||||
var svc = CreateService(mailer, overrideRecipient: "dev-inbox@example.test");
|
||||
|
||||
await svc.SendEmailAsync("inv_cc3", "Subject", "<p>hi</p>", "realcustomer@example.de", "Kunde",
|
||||
cc: "zusatz@example.de");
|
||||
await svc.SendEmailAsync("inv_cc3", "Subject", "<p>hi</p>", "realcustomer@example.de", "Kunde", cc: "zusatz@example.de");
|
||||
|
||||
var json = JObject.Parse(handler.LastRequestBody!);
|
||||
Assert.Equal("dev-inbox@example.test", json["communication"]!["to"]!.ToString());
|
||||
Assert.Equal(JTokenType.Null, json["communication"]!["cc"]!.Type);
|
||||
var m = Assert.Single(mailer.Pushed);
|
||||
Assert.Equal("dev-inbox@example.test", m.To);
|
||||
Assert.Null(m.Cc);
|
||||
}
|
||||
|
||||
// ── Override enforcement across every real appsettings*.json environment ───
|
||||
// Loads the ACTUAL Fuchs/appsettings*.json files the same way ASP.NET Core
|
||||
// layers them (base file + optional environment-specific overlay). These files
|
||||
// are copied into this test assembly's output directory via the Fuchs project
|
||||
// reference (Microsoft.NET.Sdk.Web auto-includes appsettings*.json as Content
|
||||
// with CopyToOutputDirectory). This proves the safety net holds for whichever
|
||||
// environment/appsettings file ends up "active" at runtime - not just a
|
||||
// hand-typed literal - so a future edit that silently breaks the override key
|
||||
// or its value in any appsettings*.json file would fail this test.
|
||||
//
|
||||
// Note: this API has a single "communication.to" field - there is no distinct
|
||||
// to/cc/bcc concept anywhere in the codebase (see ProcessWebComService.
|
||||
// SendEmailAsync / communication.to). "to/cc/bcc cleared" is therefore fully
|
||||
// satisfied by asserting that field no longer carries the original recipient
|
||||
// once an override is configured.
|
||||
// Loads the ACTUAL Fuchs/appsettings*.json files the same way ASP.NET Core layers them (base
|
||||
// file + optional environment-specific overlay). These files are copied into this test
|
||||
// assembly's output directory via the Fuchs project reference. This proves the safety net holds
|
||||
// for whichever environment/appsettings file ends up "active" at runtime — not just a hand-typed
|
||||
// literal — so a future edit that silently breaks the override key or its value in any
|
||||
// appsettings*.json file would fail this test.
|
||||
public static IEnumerable<object[]> AppSettingsEnvironments()
|
||||
{
|
||||
yield return new object[] { "" }; // base appsettings.json only ("Production"-like default)
|
||||
@@ -403,8 +460,8 @@ public class ProcessWebComServiceTests
|
||||
public async Task SendEmailAsync_OverrideRecipientFromRealAppsettings_ClearsRecipientWheneverConfigured(string environmentName)
|
||||
{
|
||||
string overrideRecipient = ResolveOverrideRecipientForEnvironment(environmentName);
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler, overrideRecipient: overrideRecipient);
|
||||
var mailer = new MailerStub();
|
||||
var svc = CreateService(mailer, overrideRecipient: overrideRecipient);
|
||||
|
||||
const string originalRecipient = "realcustomer@tenant-owner.example";
|
||||
bool result = await svc.SendEmailAsync(
|
||||
@@ -412,68 +469,281 @@ public class ProcessWebComServiceTests
|
||||
"Subject", "<p>hi</p>", originalRecipient, "Kunde");
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
var json = JObject.Parse(handler.LastRequestBody!);
|
||||
string sentRecipient = json["communication"]!["to"]!.ToString();
|
||||
|
||||
string sentRecipient = Assert.Single(mailer.Pushed).To;
|
||||
if (!string.IsNullOrWhiteSpace(overrideRecipient))
|
||||
{
|
||||
// Override configured for this environment: the real recipient (to/cc/bcc)
|
||||
// must be fully cleared and replaced by the override - never leaked.
|
||||
Assert.Equal(overrideRecipient, sentRecipient);
|
||||
Assert.NotEqual(originalRecipient, sentRecipient);
|
||||
}
|
||||
else
|
||||
{
|
||||
// No override configured for this environment: real recipient is used normally.
|
||||
Assert.Equal(originalRecipient, sentRecipient);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Attachment payload contract ────────────────────────────────────────────
|
||||
// ── The message handed to the PWMailer client ─────────────────────────────
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_WithAttachment_EmbedsBase64InPayload()
|
||||
public async Task SendEmailAsync_MapsEnvelopeFieldsAndAttachmentsOntoPWMailerMessage()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler);
|
||||
byte[] pdf = { 1, 2, 3, 4 };
|
||||
var attachments = new Dictionary<string, byte[]> { ["Rechnung.pdf"] = pdf };
|
||||
var mailer = MailerStub.Echoing();
|
||||
var svc = CreateService(mailer);
|
||||
byte[] pdf = { 0x25, 0x50, 0x44, 0x46 };
|
||||
byte[] xml = { 0x3C, 0x3F };
|
||||
var attachments = new Dictionary<string, byte[]> { ["Rechnung R2026-0119.pdf"] = pdf, ["zugferd.xml"] = xml };
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_5", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde", attachments);
|
||||
bool result = await svc.SendEmailAsync("inv_map", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde", attachments, cc: "cc@example.de");
|
||||
|
||||
Assert.True(result);
|
||||
Assert.NotNull(handler.LastRequestBody);
|
||||
var json = JObject.Parse(handler.LastRequestBody!);
|
||||
var att = (JArray)json["communication"]!["attachments"]!;
|
||||
Assert.Single(att);
|
||||
Assert.Equal("Rechnung.pdf", att[0]!["filename"]!.ToString());
|
||||
Assert.Equal("application/pdf", att[0]!["mimeType"]!.ToString());
|
||||
Assert.Equal(Convert.ToBase64String(pdf), att[0]!["contentBase64"]!.ToString());
|
||||
var m = Assert.Single(mailer.Pushed);
|
||||
Assert.Equal("e", m.ComType);
|
||||
Assert.Equal("inv_map", m.Reference);
|
||||
Assert.Equal("kunde@example.de", m.To);
|
||||
Assert.Equal("cc@example.de", m.Cc);
|
||||
Assert.Equal("Subject", m.Subject);
|
||||
Assert.StartsWith("<p>hi</p>", m.Body); // signature is appended after the body
|
||||
Assert.Null(m.AttachmentCount); // null ⇒ client sends the real count
|
||||
Assert.Equal(2, m.Attachments.Count);
|
||||
Assert.Equal(pdf, m.Attachments.Single(a => a.FileName == "Rechnung R2026-0119.pdf").Bytes);
|
||||
Assert.Equal(xml, m.Attachments.Single(a => a.FileName == "zugferd.xml").Bytes);
|
||||
}
|
||||
|
||||
// ── Response checks ────────────────────────────────────────────────────────
|
||||
[Theory]
|
||||
[InlineData(1, 0)]
|
||||
[InlineData(0, 0)]
|
||||
public async Task SendEmailAsync_AttachmentsNotConfirmedProcessed_ReturnsFalseAndAuditsCounts(int received, int processed)
|
||||
{
|
||||
// A 200 is not enough when files were sent: the Mailer must report every one as processed.
|
||||
var svc = CreateCapturing(new MailerStub(Accepted(received, processed, "cs-9")));
|
||||
var att = new Dictionary<string, byte[]> { ["Rechnung.pdf"] = [1, 2, 3] };
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_mismatch", "S", "<p>x</p>", "kunde@example.de", "Kunde", att);
|
||||
|
||||
Assert.False(result);
|
||||
var entry = Assert.Single(svc.Entries);
|
||||
Assert.False(entry.success);
|
||||
Assert.Contains(entry.log, l => l.Contains("not all 1 attachment(s)")
|
||||
&& l.Contains($"attachmentsReceived={received}") && l.Contains($"attachmentsProcessed={processed}"));
|
||||
Assert.Equal("cs-9", Assert.Single(svc.Guids)); // still traceable at the Mailer
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_AttachmentsMismatch_IncrementsFailedCounterWithReason()
|
||||
{
|
||||
string? reason = null;
|
||||
using var listener = new MeterListener
|
||||
{
|
||||
InstrumentPublished = (inst, l) =>
|
||||
{
|
||||
if (inst.Meter.Name == FuchsMeterName && inst.Name == "fuchs.emails.failed")
|
||||
l.EnableMeasurementEvents(inst);
|
||||
}
|
||||
};
|
||||
listener.SetMeasurementEventCallback<long>((_, _, tags, _) =>
|
||||
{
|
||||
foreach (var t in tags) if (t.Key == "reason") reason = t.Value?.ToString();
|
||||
});
|
||||
listener.Start();
|
||||
|
||||
var svc = CreateService(new MailerStub(Accepted(1, 0)));
|
||||
await svc.SendEmailAsync("inv_mm_metric", "S", "<p>x</p>", "kunde@example.de", "Kunde",
|
||||
new Dictionary<string, byte[]> { ["a.pdf"] = [1] });
|
||||
|
||||
Assert.Equal("attachments-mismatch", reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_Conflict409_IsSuccessBecauseThePendingDuplicateWillBeSent()
|
||||
{
|
||||
// A resend of an identical message while the first is still pending: the Mailer keeps the
|
||||
// first entry and delivers it — the caller (e.g. req/resend, or a double click) must see a
|
||||
// successful send, with the deduplication recorded in the audit log.
|
||||
var svc = CreateCapturing(new MailerStub(Failed(409, "duplicate of pending entry")));
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_dup", "S", "<p>x</p>", "kunde@example.de", "Kunde");
|
||||
|
||||
Assert.True(result);
|
||||
var entry = Assert.Single(svc.Entries);
|
||||
Assert.True(entry.success);
|
||||
Assert.Contains(entry.log, l => l.Contains("still-pending") && l.Contains("409") && l.Contains("duplicate of pending entry"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_RefusedByClientPreValidation_ReturnsFalseAndAuditsEachRule()
|
||||
{
|
||||
// The client refuses before sending (status 0, no attempt) with "; "-joined rule messages —
|
||||
// e.g. Fuchs' attachment limits (FuchsMailerClient) or the contract's required fields.
|
||||
var svc = CreateCapturing(new MailerStub(Refused("Attachment 'big.pdf' is 11,000,000 bytes (max 10,485,760); attachment 'x' has no file extension")));
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_refused", "S", "<p>x</p>", "kunde@example.de", "Kunde",
|
||||
new Dictionary<string, byte[]> { ["big.pdf"] = [1] });
|
||||
|
||||
Assert.False(result);
|
||||
var entry = Assert.Single(svc.Entries);
|
||||
Assert.False(entry.success);
|
||||
Assert.Equal(2, entry.log.Count);
|
||||
Assert.Contains(entry.log, l => l.StartsWith("Attachment 'big.pdf'"));
|
||||
Assert.Contains(entry.log, l => l.Contains("no file extension"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_RefusedBecauseClientNotUsable_ReturnsFalseWithClientConfigReason()
|
||||
{
|
||||
// Same shape as a validation refusal (status 0, no attempt) but no ArgumentException: the
|
||||
// client could not resolve its token / options. Must not be reported as caller validation.
|
||||
string? reason = null;
|
||||
using var listener = new MeterListener
|
||||
{
|
||||
InstrumentPublished = (inst, l) =>
|
||||
{
|
||||
if (inst.Meter.Name == FuchsMeterName && inst.Name == "fuchs.emails.failed")
|
||||
l.EnableMeasurementEvents(inst);
|
||||
}
|
||||
};
|
||||
listener.SetMeasurementEventCallback<long>((_, _, tags, _) =>
|
||||
{
|
||||
foreach (var t in tags) if (t.Key == "reason") reason = t.Value?.ToString();
|
||||
});
|
||||
listener.Start();
|
||||
|
||||
var svc = CreateCapturing(new MailerStub(new PWMailerResult<PWMailerPushReceipt>
|
||||
{
|
||||
StatusCode = 0, Attempts = 0, Operation = "push_com",
|
||||
Error = "PWMailer token could not be loaded from Key Vault secret 'Fuchs--Mailer--Token': timeout"
|
||||
}));
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_cfg", "S", "<p>x</p>", "kunde@example.de", "Kunde");
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Equal("client-config", reason);
|
||||
var entry = Assert.Single(svc.Entries);
|
||||
Assert.False(entry.success);
|
||||
Assert.Contains(entry.log, l => l.StartsWith("Mailer client not usable") && l.Contains("Key Vault"));
|
||||
}
|
||||
|
||||
// ── SMS ────────────────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public async Task SendSmsAsync_ValidAndApiOk_ReturnsTrue()
|
||||
public async Task SendSmsAsync_ValidAndApiOk_ReturnsTrueAndPushesComTypeS()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler);
|
||||
var mailer = new MailerStub();
|
||||
var svc = CreateService(mailer);
|
||||
|
||||
bool result = await svc.SendSmsAsync("01700000000", "Code 1234");
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
var m = Assert.Single(mailer.Pushed);
|
||||
Assert.Equal("s", m.ComType);
|
||||
Assert.Equal("01700000000", m.To);
|
||||
Assert.Equal("Code 1234", m.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendSmsAsync_EmptyMobile_ReturnsFalseWithoutCallingApi()
|
||||
public async Task SendSmsAsync_EmptyMobile_ReturnsFalseWithoutCallingMailer()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler);
|
||||
var mailer = new MailerStub();
|
||||
var svc = CreateService(mailer);
|
||||
|
||||
bool result = await svc.SendSmsAsync("", "Code 1234");
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Equal(0, handler.CallCount);
|
||||
Assert.Equal(0, mailer.CallCount);
|
||||
}
|
||||
|
||||
// ── Credential verification (client VerifyAsync) ────────────────────────────
|
||||
private static PWMailerResult<PWMailerVerifyResult> VerifyResult(bool ok, string account = "acc-123", string? serverId = null) =>
|
||||
new() { StatusCode = 200, Value = new PWMailerVerifyResult(ok, account, serverId), Attempts = 1, Operation = "verify" };
|
||||
|
||||
private static ProcessWebComService VerifyService(Mock<IPWMailerClientService> mailer, string accountId = "acct", string token = "tok", string serverId = "") =>
|
||||
new(NullLogger<ProcessWebComService>.Instance, intranet: null!, Settings(true, accountId, token, serverId),
|
||||
Options.Create(new FuchsEmailSettings()), mailer.Object);
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyCredentialsAsync_MailerConfirms_ReturnsOkWithAccount()
|
||||
{
|
||||
var mailer = new Mock<IPWMailerClientService>();
|
||||
mailer.Setup(m => m.VerifyAsync(null, It.IsAny<CancellationToken>())).ReturnsAsync(VerifyResult(true));
|
||||
|
||||
var result = await VerifyService(mailer).VerifyCredentialsAsync();
|
||||
|
||||
Assert.True(result.Ok);
|
||||
Assert.Equal("acc-123", result.Account);
|
||||
mailer.Verify(m => m.VerifyAsync(null, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyCredentialsAsync_ServerIdConfigured_IsSentAndEchoedOnSuccess()
|
||||
{
|
||||
var mailer = new Mock<IPWMailerClientService>();
|
||||
mailer.Setup(m => m.VerifyAsync("srv-1", It.IsAny<CancellationToken>())).ReturnsAsync(VerifyResult(true, serverId: "srv-1"));
|
||||
|
||||
var result = await VerifyService(mailer, serverId: "srv-1").VerifyCredentialsAsync();
|
||||
|
||||
Assert.True(result.Ok);
|
||||
Assert.Equal("srv-1", result.ServerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyCredentialsAsync_ServerIdNotOwnedByAccount_ReturnsNotOkWithServerIdDetail()
|
||||
{
|
||||
var mailer = new Mock<IPWMailerClientService>();
|
||||
mailer.Setup(m => m.VerifyAsync("srv-wrong", It.IsAny<CancellationToken>())).ReturnsAsync(VerifyResult(false, serverId: "srv-wrong"));
|
||||
|
||||
var result = await VerifyService(mailer, serverId: "srv-wrong").VerifyCredentialsAsync();
|
||||
|
||||
Assert.False(result.Ok);
|
||||
Assert.Contains("srv-wrong", result.Detail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyCredentialsAsync_MailerReturnsOkFalse_ReturnsNotOk()
|
||||
{
|
||||
var mailer = new Mock<IPWMailerClientService>();
|
||||
mailer.Setup(m => m.VerifyAsync(null, It.IsAny<CancellationToken>())).ReturnsAsync(VerifyResult(false));
|
||||
|
||||
var result = await VerifyService(mailer).VerifyCredentialsAsync();
|
||||
|
||||
Assert.False(result.Ok);
|
||||
Assert.Contains("ungültige Zugangsdaten", result.Detail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyCredentialsAsync_Http401_ReturnsNotOkWithDetail()
|
||||
{
|
||||
var mailer = new Mock<IPWMailerClientService>();
|
||||
mailer.Setup(m => m.VerifyAsync(null, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new PWMailerResult<PWMailerVerifyResult> { StatusCode = 401, Error = "not authenticated", Attempts = 1 });
|
||||
|
||||
var result = await VerifyService(mailer, token: "wrong").VerifyCredentialsAsync();
|
||||
|
||||
Assert.False(result.Ok);
|
||||
Assert.Contains("401", result.Detail);
|
||||
Assert.Contains("not authenticated", result.Detail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyCredentialsAsync_TransportError_ReturnsNotOkWithClientError()
|
||||
{
|
||||
var mailer = new Mock<IPWMailerClientService>();
|
||||
mailer.Setup(m => m.VerifyAsync(null, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new PWMailerResult<PWMailerVerifyResult> { StatusCode = 0, Error = "simulated transport failure", Attempts = 3, TimedOut = false });
|
||||
|
||||
var result = await VerifyService(mailer).VerifyCredentialsAsync();
|
||||
|
||||
Assert.False(result.Ok);
|
||||
Assert.Contains("simulated transport failure", result.Detail);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("", "tok")]
|
||||
[InlineData("acct", "")]
|
||||
public async Task VerifyCredentialsAsync_CredentialsNotConfigured_ReturnsNotOkWithoutCallingClient(string accountId, string token)
|
||||
{
|
||||
var mailer = new Mock<IPWMailerClientService>();
|
||||
|
||||
var result = await VerifyService(mailer, accountId, token).VerifyCredentialsAsync();
|
||||
|
||||
Assert.False(result.Ok);
|
||||
mailer.Verify(m => m.VerifyAsync(It.IsAny<string?>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
// ── Metric emission (performance indicator) ────────────────────────────────
|
||||
@@ -492,11 +762,9 @@ public class ProcessWebComServiceTests
|
||||
listener.SetMeasurementEventCallback<long>((_, value, _, _) => Interlocked.Add(ref delta, value));
|
||||
listener.Start();
|
||||
|
||||
var svc = CreateService(new StubHandler(HttpStatusCode.OK));
|
||||
var svc = CreateService(new MailerStub());
|
||||
await svc.SendEmailAsync("inv_metric", "S", "<p>x</p>", "kunde@example.de", "Kunde");
|
||||
|
||||
Assert.True(delta >= 1, "fuchs.emails.sent counter should have been incremented on a successful send.");
|
||||
}
|
||||
|
||||
private const string FuchsMeterName = "Fuchs.Intranet";
|
||||
}
|
||||
|
||||
@@ -238,7 +238,8 @@ public class ReminderDraftServiceTests
|
||||
var result = await svc.FlushToDbAsync(s.Token, "user1", null!);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("REM42", result!.Id);
|
||||
Assert.Equal("REM42", result!.Data.Id);
|
||||
Assert.False(result.Updated);
|
||||
Assert.False(rem.LastChange); // new draft (no prior RemId) → create
|
||||
Assert.Equal("REM42", svc.Get(s.Token)!.RemId);
|
||||
// the email/subject the editor set must reach registration
|
||||
@@ -255,9 +256,10 @@ public class ReminderDraftServiceTests
|
||||
payload["remid"] = "REM7";
|
||||
var s = svc.OpenFromPayload(payload, "user1");
|
||||
|
||||
await svc.FlushToDbAsync(s.Token, "user1", null!);
|
||||
var result = await svc.FlushToDbAsync(s.Token, "user1", null!);
|
||||
|
||||
Assert.True(rem.LastChange); // prior RemId → update path
|
||||
Assert.True(result!.Updated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
using System;
|
||||
using Fuchs.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The reminder save batch (<see cref="ReminderService.BuildRegisterReminderSql"/>): a new draft is
|
||||
/// created, a re-saved one is updated in place via <c>fds__setReminder</c> — previously the update
|
||||
/// path ran an empty batch, so "Zwischenspeichern" of an already-registered reminder draft silently
|
||||
/// saved nothing. Either proc leaving <c>@Id</c> NULL must surface as an error, not as an empty row.
|
||||
/// </summary>
|
||||
public class ReminderRegisterSqlTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(false, "[dbo].[fds__createReminder] @InvId,", "[dbo].[fds__setReminder]")]
|
||||
[InlineData(true, "[dbo].[fds__setReminder] @RemId,", "[dbo].[fds__createReminder]")]
|
||||
public void BuildRegisterReminderSql_ExactlyOneProcPerMode(bool change, string expectedProc, string otherProc)
|
||||
{
|
||||
string sql = ReminderService.BuildRegisterReminderSql(change);
|
||||
|
||||
Assert.Contains(expectedProc, sql);
|
||||
Assert.DoesNotContain(otherProc, sql);
|
||||
Assert.Equal(change, sql.Contains("@RemId"));
|
||||
Assert.Equal(!change, sql.Contains("@InvId"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void BuildRegisterReminderSql_GuardFollowsTheProc(bool change)
|
||||
{
|
||||
string sql = ReminderService.BuildRegisterReminderSql(change);
|
||||
|
||||
int proc = sql.IndexOf("EXECUTE [dbo].[fds__", StringComparison.Ordinal);
|
||||
int guard = sql.IndexOf("IF @Id IS NULL THROW 50001", StringComparison.Ordinal);
|
||||
Assert.True(proc >= 0 && guard > proc, sql);
|
||||
Assert.StartsWith("DECLARE @Id varchar(10);", sql);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void BuildRegisterReminderSql_EveryStatementIsSemicolonTerminated(bool change)
|
||||
{
|
||||
// THROW requires the preceding statement to be terminated with ';'.
|
||||
foreach (var line in ReminderService.BuildRegisterReminderSql(change).Split('\n'))
|
||||
Assert.EndsWith(";", line.TrimEnd());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Notifications;
|
||||
using Fuchs.Services;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MigraDoc.DocumentObjectModel;
|
||||
using Moq;
|
||||
using OCORE.security;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Reminder counterpart of <see cref="InvoiceServiceStoreFileTests"/>: every failure branch of
|
||||
/// <see cref="ReminderService.StoreReminderDocumentFileAsync"/> must be logged individually and
|
||||
/// must not reach the blob upload or the ReminderFileCreated event (ADR 0025).
|
||||
/// </summary>
|
||||
public class ReminderServiceStoreFileTests
|
||||
{
|
||||
private sealed class RecordingLogger : ILogger<ReminderService>
|
||||
{
|
||||
public sealed record Entry(LogLevel Level, Exception? Exception, string Message);
|
||||
public readonly List<Entry> Entries = new();
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||
Func<TState, Exception?, string> formatter) =>
|
||||
Entries.Add(new Entry(logLevel, exception, formatter(state, exception)));
|
||||
}
|
||||
|
||||
private const string UnreachableConnectionString =
|
||||
"Server=127.0.0.1,1;Database=fuchs;User Id=u;Password=p;Connect Timeout=1;TrustServerCertificate=True";
|
||||
|
||||
private static (ReminderService svc, RecordingLogger log, Mock<IBlobStorageService> blob, Mock<IEventService> events)
|
||||
MakeService(Mock<IPdfService> pdf, string? connectionString = null)
|
||||
{
|
||||
var log = new RecordingLogger();
|
||||
var blob = new Mock<IBlobStorageService>();
|
||||
var events = new Mock<IEventService>();
|
||||
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ConnectionStrings:fuchs_fds_ConnectionString"] = connectionString
|
||||
}).Build();
|
||||
var svc = new ReminderService(new Fuchs_intranet(config), pdf.Object, blob.Object, events.Object, log);
|
||||
return (svc, log, blob, events);
|
||||
}
|
||||
|
||||
private static FdsReminderData Reminder() => new()
|
||||
{
|
||||
ReminderRegistration = new OCORE.OCORE_dictionaries.GenericObjectDictionary(new Dictionary<string, object?>
|
||||
{
|
||||
["Id"] = "M1ABC",
|
||||
["subject"] = "Zahlungserinnerung",
|
||||
["IsFinal"] = true
|
||||
}),
|
||||
IsDraft = false
|
||||
};
|
||||
|
||||
private static Mock<IPdfService> PdfReturning(byte[] bytes)
|
||||
{
|
||||
var pdf = new Mock<IPdfService>();
|
||||
pdf.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), It.IsAny<bool>()))
|
||||
.ReturnsAsync(new Document());
|
||||
pdf.Setup(p => p.DocToPdfBytes(It.IsAny<Document>())).Returns(bytes);
|
||||
return pdf;
|
||||
}
|
||||
|
||||
private static void AssertNothingPersisted(Mock<IBlobStorageService> blob, Mock<IEventService> events)
|
||||
{
|
||||
blob.Verify(b => b.UploadReminderPdfAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<byte[]>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, object?>>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
events.Verify(e => e.ReminderFileCreatedAsync(It.IsAny<FdsReminderData>(), It.IsAny<string>(), It.IsAny<string>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreReminderDocumentFileAsync_RenderProducesZeroBytes_ReturnsEmptyAndLogsError()
|
||||
{
|
||||
var (svc, log, blob, events) = MakeService(PdfReturning(Array.Empty<byte>()));
|
||||
|
||||
var result = await svc.StoreReminderDocumentFileAsync(Reminder(), false, "user1", new DatabaseSecurity("user1", null));
|
||||
|
||||
Assert.Empty(result);
|
||||
var entry = Assert.Single(log.Entries, e => e.Level == LogLevel.Error);
|
||||
Assert.Null(entry.Exception);
|
||||
Assert.Contains("produced 0 bytes", entry.Message);
|
||||
Assert.Contains("M1ABC", entry.Message);
|
||||
AssertNothingPersisted(blob, events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreReminderDocumentFileAsync_RenderThrows_ReturnsEmptyAndLogsExceptionOnce()
|
||||
{
|
||||
var pdf = new Mock<IPdfService>();
|
||||
var boom = new InvalidOperationException("render exploded");
|
||||
pdf.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), It.IsAny<bool>())).ThrowsAsync(boom);
|
||||
var (svc, log, blob, events) = MakeService(pdf);
|
||||
|
||||
var result = await svc.StoreReminderDocumentFileAsync(Reminder(), false, "user1", new DatabaseSecurity("user1", null));
|
||||
|
||||
Assert.Empty(result);
|
||||
var entry = Assert.Single(log.Entries, e => e.Level == LogLevel.Error);
|
||||
Assert.Same(boom, entry.Exception);
|
||||
Assert.Contains("render failed", entry.Message);
|
||||
AssertNothingPersisted(blob, events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreReminderDocumentFileAsync_RenderOkButDbStoreFails_ReturnsEmptyAndSkipsBlob()
|
||||
{
|
||||
var (svc, log, blob, events) = MakeService(PdfReturning(new byte[] { 0x25, 0x50, 0x44, 0x46 }), UnreachableConnectionString);
|
||||
|
||||
var result = await svc.StoreReminderDocumentFileAsync(Reminder(), false, "user1", new DatabaseSecurity("user1", null));
|
||||
|
||||
Assert.Empty(result);
|
||||
Assert.DoesNotContain(log.Entries, e => e.Message.Contains("produced 0 bytes"));
|
||||
Assert.DoesNotContain(log.Entries, e => e.Message.Contains("render failed"));
|
||||
AssertNothingPersisted(blob, events);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using Fuchs.Controllers;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IntranetController.SafeDocumentFileName"/> names a regenerated invoice document
|
||||
/// (inv/rdoc, ADR 0025). DocumentName comes from the user-typed invoice title, so it must survive
|
||||
/// path separators and editor markup without truncating or breaking the download filename.
|
||||
/// </summary>
|
||||
public class ReportDocFileNameTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("Rechnung R2026-0258.pdf", "Rechnung R2026-0258.pdf")]
|
||||
[InlineData("Wartung 2025/26 R2026-0001.pdf", "Wartung 2025_26 R2026-0001.pdf")]
|
||||
[InlineData("Angebot A\\B: \"x\" <y>|z?*.pdf", "Angebot A_B_ _x_ _y__z__.pdf")]
|
||||
[InlineData("<p>Rechnung R2026-0002.pdf</p>", "Rechnung R2026-0002.pdf")]
|
||||
[InlineData(" Rechnung R2026-0003.pdf ", "Rechnung R2026-0003.pdf")]
|
||||
public void SafeDocumentFileName_ReplacesInvalidCharsAndStripsMarkup(string documentName, string expected)
|
||||
{
|
||||
Assert.Equal(expected, IntranetController.SafeDocumentFileName(documentName, "Rechnung_X.pdf"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("<p></p>")]
|
||||
[InlineData("<p> </p>")]
|
||||
public void SafeDocumentFileName_EmptyDocumentName_UsesFallback(string? documentName)
|
||||
{
|
||||
Assert.Equal("Rechnung_W7VAL.pdf", IntranetController.SafeDocumentFileName(documentName!, "Rechnung_W7VAL.pdf"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The stored-document path (<c>FdsMfr.GetReportDoc</c>) replaced exactly these characters
|
||||
/// before the sanitiser was shared: <c>= > | ! < /</c>. The shared helper must still
|
||||
/// produce the identical result for them — unifying the two paths may not loosen either.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("a>b|c!d<e/f=g.pdf", "a_b_c_d_e_f_g.pdf")]
|
||||
[InlineData("<p>Summe=100!.pdf</p>", "Summe_100_.pdf")]
|
||||
public void SafeFileName_KeepsEveryReplacementTheStoredDocumentPathMade(string documentName, string expected)
|
||||
{
|
||||
Assert.Equal(expected, fds.FdsDocumentNames.SafeFileName(documentName, "X.pdf"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Characters the old stored-document path did NOT catch but Windows rejects — the reason
|
||||
/// the sanitiser was unified (code review on ADR 0025): a stored and a regenerated document
|
||||
/// must never diverge in filename safety.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("Angebot A\\B.pdf", "Angebot A_B.pdf")]
|
||||
[InlineData("Re: 2026.pdf", "Re_ 2026.pdf")]
|
||||
[InlineData("x*y?z\".pdf", "x_y_z_.pdf")]
|
||||
[InlineData("tab\there.pdf", "tab_here.pdf")]
|
||||
public void SafeFileName_ReplacesInvalidCharsTheStoredDocumentPathMissed(string documentName, string expected)
|
||||
{
|
||||
Assert.Equal(expected, fds.FdsDocumentNames.SafeFileName(documentName, "X.pdf"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Rechnung R2026-0258.pdf")]
|
||||
[InlineData("Wartung 2025/26 R2026-0001.pdf")]
|
||||
[InlineData("a>b|c!d<e/f=g.pdf")]
|
||||
[InlineData("Angebot A\\B: \"x\" <y>|z?*.pdf")]
|
||||
[InlineData("<p></p>")]
|
||||
[InlineData(null)]
|
||||
public void SafeDocumentFileName_MatchesSharedSanitiserForEveryInput(string? documentName)
|
||||
{
|
||||
Assert.Equal(
|
||||
fds.FdsDocumentNames.SafeFileName(documentName, "Rechnung_X.pdf"),
|
||||
IntranetController.SafeDocumentFileName(documentName!, "Rechnung_X.pdf"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SafeFileName_ReturnsFallbackVerbatim_WhenNothingUsableRemains()
|
||||
{
|
||||
// The fallback is the caller's responsibility and is not sanitised or second-guessed.
|
||||
Assert.Equal("W7VAL", fds.FdsDocumentNames.SafeFileName("<p></p>", "W7VAL"));
|
||||
Assert.Equal("", fds.FdsDocumentNames.SafeFileName("", ""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Security.Claims;
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Logging;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers <see cref="RequestLogScopeMiddleware.BuildScope"/>: the per-request scope that attributes
|
||||
/// every log line (including ones written deep inside OCORE) to a request id, path, and — once
|
||||
/// authenticated — the <c>fuchs:useraccount_id</c> claim, feeding both the generic serialized
|
||||
/// context and (via the "authuser" key specifically) <c>LogEntry.AuthUser</c>. See ADR 0024.
|
||||
/// </summary>
|
||||
public class RequestLogScopeMiddlewareTests
|
||||
{
|
||||
private static DefaultHttpContext ContextWithPath(string path)
|
||||
{
|
||||
var ctx = new DefaultHttpContext();
|
||||
ctx.Request.Path = path;
|
||||
return ctx;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildScope_UnauthenticatedRequest_CarriesRequestIdAndPath_NoAuthUserKey()
|
||||
{
|
||||
var ctx = ContextWithPath("/do/inv/dpreview");
|
||||
ctx.TraceIdentifier = "trace-123";
|
||||
|
||||
var scope = RequestLogScopeMiddleware.BuildScope(ctx);
|
||||
|
||||
Assert.Equal("trace-123", scope["requestId"]);
|
||||
Assert.Equal("/do/inv/dpreview", scope["path"]);
|
||||
Assert.False(scope.ContainsKey("authuser"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildScope_AuthenticatedRequest_IncludesAuthUserFromClaim()
|
||||
{
|
||||
var ctx = ContextWithPath("/do/inv/dpreview");
|
||||
var identity = new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim(FuchsUserIdentity.ClaimUserAccountId, "acct-42"),
|
||||
}, "fuchs_intranet");
|
||||
ctx.User = new ClaimsPrincipal(identity);
|
||||
|
||||
var scope = RequestLogScopeMiddleware.BuildScope(ctx);
|
||||
|
||||
Assert.Equal("acct-42", scope["authuser"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildScope_AuthenticatedPrincipalWithEmptyClaim_OmitsAuthUserKey()
|
||||
{
|
||||
var ctx = ContextWithPath("/do/inv/dpreview");
|
||||
var identity = new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim(FuchsUserIdentity.ClaimUserAccountId, ""),
|
||||
}, "fuchs_intranet");
|
||||
ctx.User = new ClaimsPrincipal(identity);
|
||||
|
||||
var scope = RequestLogScopeMiddleware.BuildScope(ctx);
|
||||
|
||||
Assert.False(scope.ContainsKey("authuser"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildScope_RootPath_UsesEmptyStringNotNull()
|
||||
{
|
||||
var ctx = new DefaultHttpContext();
|
||||
|
||||
var scope = RequestLogScopeMiddleware.BuildScope(ctx);
|
||||
|
||||
Assert.Equal("", scope["path"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The C# ↔ T-SQL contract, checked against the <c>Fuchs_Database</c> SSDT project (ADR 0036).
|
||||
/// Every <c>[dbo].[…]</c> object named in a C# string must exist in the project, every
|
||||
/// <c>EXECUTE</c> must bind against the procedure's declared parameters, and every scalar/table
|
||||
/// function call must pass the declared number of arguments. None of this is visible to the
|
||||
/// compiler or to a unit test that stubs the database — it used to fail only at runtime
|
||||
/// (e.g. <c>inv/datev</c> passing three positional arguments to a four-parameter proc).
|
||||
/// The SSDT build covers the SQL side of the same contract (unresolved/ambiguous columns).
|
||||
/// </summary>
|
||||
public class SqlContractTests
|
||||
{
|
||||
internal sealed record SqlObject(string Name, string Kind, IReadOnlyList<(string Name, bool HasDefault)> Parameters);
|
||||
|
||||
internal sealed record SqlReference(string File, int Line, string Name, bool IsExecute, bool IsCall, string Args);
|
||||
|
||||
private const char JoinedBreak = '\u0001';
|
||||
|
||||
private static readonly string[] ScannedProjects = ["Fuchs", "Fuchs_DataService"];
|
||||
|
||||
// Signature FdsMfrClient.Update__entitytable uses for its dynamically named [mfr__updt__<table>] call.
|
||||
private static readonly string[] MfrUpdtSignature = ["@tblname", "@referencetable", "@tgtid"];
|
||||
|
||||
private static readonly Regex Declaration = new(
|
||||
@"CREATE\s+(?:OR\s+ALTER\s+)?(?<kind>PROCEDURE|PROC|FUNCTION|TYPE|TABLE|VIEW)\s+(?:\[?dbo\]?\.)?\[?(?<name>[^\]\s(]+)\]?",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex ParameterDeclaration = new(
|
||||
@"@(?<name>\w+)\s+(?:AS\s+)?[\w\[\]\.]+(?:\s*\([^)]*\))?(?:\s+READONLY)?\s*(?<def>=)?",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex Reference = new(
|
||||
@"(?<exec>EXEC(?:UTE)?\s+)?(?:@\w+\s*=\s*)?\[?dbo\]?\.\[?(?<name>[A-Za-z][A-Za-z0-9_\-]*)\]?(?<call>\s*\()?",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
// ── Locating and parsing ──────────────────────────────────────────────────────────────────
|
||||
|
||||
private static string RepoRoot()
|
||||
{
|
||||
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir != null; dir = dir.Parent)
|
||||
if (Directory.Exists(Path.Combine(dir.FullName, "Fuchs_Database", "dbo"))
|
||||
&& Directory.Exists(Path.Combine(dir.FullName, "Fuchs")))
|
||||
return dir.FullName;
|
||||
Assert.Skip("Fuchs_Database SSDT project not present in this checkout.");
|
||||
return "";
|
||||
}
|
||||
|
||||
internal static string StripSqlComments(string sql) =>
|
||||
Regex.Replace(Regex.Replace(sql, @"/\*.*?\*/", " ", RegexOptions.Singleline), @"--[^\n]*", " ");
|
||||
|
||||
/// <summary>Parses one SSDT file into the object it declares, or null for non-object scripts.</summary>
|
||||
internal static SqlObject? ParseObject(string sql)
|
||||
{
|
||||
sql = StripSqlComments(sql);
|
||||
var m = Declaration.Match(sql);
|
||||
if (!m.Success) return null;
|
||||
string kind = m.Groups["kind"].Value.ToUpperInvariant() is "PROC" ? "PROCEDURE" : m.Groups["kind"].Value.ToUpperInvariant();
|
||||
string header = "";
|
||||
string rest = sql[(m.Index + m.Length)..];
|
||||
if (kind == "FUNCTION")
|
||||
{
|
||||
int open = rest.IndexOf('('), depth = 0;
|
||||
for (int i = open; open >= 0 && i < rest.Length; i++)
|
||||
{
|
||||
depth += rest[i] == '(' ? 1 : rest[i] == ')' ? -1 : 0;
|
||||
if (depth == 0) { header = rest[(open + 1)..i]; break; }
|
||||
}
|
||||
}
|
||||
else if (kind == "PROCEDURE")
|
||||
{
|
||||
header = Regex.Split(rest, @"^\s*AS\b", RegexOptions.Multiline | RegexOptions.IgnoreCase)[0];
|
||||
header = Regex.Split(header, @"\bWITH\s+(?:EXECUTE|RECOMPILE|ENCRYPTION)", RegexOptions.IgnoreCase)[0];
|
||||
}
|
||||
var ps = ParameterDeclaration.Matches(header)
|
||||
.Select(p => ("@" + p.Groups["name"].Value.ToLowerInvariant(), p.Groups["def"].Success))
|
||||
.ToList();
|
||||
return new SqlObject(m.Groups["name"].Value, kind, ps);
|
||||
}
|
||||
|
||||
private static Dictionary<string, SqlObject> LoadProject()
|
||||
{
|
||||
var objects = new Dictionary<string, SqlObject>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var file in Directory.EnumerateFiles(Path.Combine(RepoRoot(), "Fuchs_Database", "dbo"), "*.sql", SearchOption.AllDirectories))
|
||||
if (ParseObject(File.ReadAllText(file)) is { } o)
|
||||
objects[o.Name] = o;
|
||||
return objects;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SQL object references in C# source. Comment lines are dropped (commented-out code and doc
|
||||
/// comments name objects too) and adjacent concatenated literals are joined, so an
|
||||
/// EXECUTE split over <c>"…" +</c> lines is seen as one statement.
|
||||
/// </summary>
|
||||
internal static IEnumerable<SqlReference> ScanSource(string file, string source)
|
||||
{
|
||||
var lines = source.Split('\n');
|
||||
string code = string.Join('\n', lines.Select(l => l.TrimStart().StartsWith("//", StringComparison.Ordinal) ? "" : l));
|
||||
code = Regex.Replace(code, @"/\*.*?\*/", m => new string('\n', m.Value.Count(c => c == '\n')), RegexOptions.Singleline);
|
||||
// Join "a " + "b". The swallowed line breaks become JoinedBreak: it counts as a line for the
|
||||
// reported line number but does not end the statement's argument list.
|
||||
code = Regex.Replace(code, "\"(\\s*\\+\\s*)\\$?@?\"", m => new string(JoinedBreak, m.Groups[1].Value.Count(c => c == '\n')));
|
||||
|
||||
foreach (Match m in Reference.Matches(code))
|
||||
{
|
||||
string name = m.Groups["name"].Value;
|
||||
if (name.StartsWith("sp_", StringComparison.OrdinalIgnoreCase) || name.StartsWith("sys", StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
int line = code.Take(m.Index).Count(c => c is '\n' or JoinedBreak) + 1;
|
||||
string tail = code[(m.Index + m.Length)..];
|
||||
string args;
|
||||
if (m.Groups["call"].Success)
|
||||
{
|
||||
int depth = 1, i = 0;
|
||||
while (i < tail.Length && depth > 0) depth += tail[i++] switch { '(' => 1, ')' => -1, _ => 0 };
|
||||
args = tail[..Math.Max(0, i - 1)];
|
||||
}
|
||||
else
|
||||
{
|
||||
int end = tail.IndexOfAny([';', '"', '\n']);
|
||||
args = end < 0 ? tail : tail[..end];
|
||||
}
|
||||
args = args.Replace(JoinedBreak.ToString(), "", StringComparison.Ordinal).Trim();
|
||||
yield return new SqlReference(file, line, name, m.Groups["exec"].Success, m.Groups["call"].Success, args);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<SqlReference> ScanRepository()
|
||||
{
|
||||
string root = RepoRoot();
|
||||
return ScannedProjects
|
||||
.SelectMany(p => Directory.EnumerateFiles(Path.Combine(root, p), "*.cs", SearchOption.AllDirectories))
|
||||
.Where(f => !Regex.IsMatch(f, @"[\\/](bin|obj)[\\/]"))
|
||||
.SelectMany(f => ScanSource(Path.GetRelativePath(root, f), File.ReadAllText(f)))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// ── Binding rules ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Binding problems of an EXECUTE argument list against a procedure's parameters.</summary>
|
||||
internal static List<string> ExecuteProblems(string args, IReadOnlyList<(string Name, bool HasDefault)> ps)
|
||||
{
|
||||
var problems = new List<string>();
|
||||
var supplied = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
bool sawNamed = false;
|
||||
int position = 0;
|
||||
foreach (var arg in args.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var named = Regex.Match(arg, @"^(@\w+)\s*=");
|
||||
if (named.Success)
|
||||
{
|
||||
sawNamed = true;
|
||||
string n = named.Groups[1].Value.ToLowerInvariant();
|
||||
if (!ps.Any(p => p.Name == n)) problems.Add($"unknown parameter {n}");
|
||||
supplied.Add(n);
|
||||
continue;
|
||||
}
|
||||
if (sawNamed) problems.Add($"positional argument '{arg}' after a named one");
|
||||
if (position >= ps.Count) { problems.Add($"argument '{arg}' beyond the {ps.Count} declared parameter(s)"); continue; }
|
||||
supplied.Add(ps[position++].Name);
|
||||
}
|
||||
problems.AddRange(ps.Where(p => !p.HasDefault && !supplied.Contains(p.Name))
|
||||
.Select(p => $"required parameter {p.Name} not supplied"));
|
||||
return problems;
|
||||
}
|
||||
|
||||
/// <summary>T-SQL functions take every declared argument (DEFAULT is passed explicitly).</summary>
|
||||
internal static List<string> CallProblems(string args, IReadOnlyList<(string Name, bool HasDefault)> ps)
|
||||
{
|
||||
int count = string.IsNullOrWhiteSpace(args) ? 0 : Regex.Split(args, @",(?![^(]*\))").Length;
|
||||
return count == ps.Count ? [] : [$"{count} argument(s), function declares {ps.Count}"];
|
||||
}
|
||||
|
||||
internal static List<string> Problems(IEnumerable<SqlReference> refs, IReadOnlyDictionary<string, SqlObject> objects)
|
||||
{
|
||||
var problems = new List<string>();
|
||||
foreach (var r in refs)
|
||||
{
|
||||
string at = $"{r.File}:{r.Line} [dbo].[{r.Name}]";
|
||||
if (!objects.TryGetValue(r.Name, out var o)) { problems.Add($"{at}: not in Fuchs_Database"); continue; }
|
||||
var found = r.IsExecute && o.Kind == "PROCEDURE" ? ExecuteProblems(r.Args, o.Parameters)
|
||||
: r.IsCall && o.Kind == "FUNCTION" ? CallProblems(r.Args, o.Parameters)
|
||||
: [];
|
||||
problems.AddRange(found.Select(p => $"{at}: {p}"));
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
// ── The contract, against the real repository ─────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ScanRepository_RealSources_FindsTheKnownCallSites()
|
||||
{
|
||||
// Guards the scanner itself: a regex regression that matched nothing would pass every check below.
|
||||
var refs = ScanRepository();
|
||||
Assert.True(refs.Count(r => r.IsExecute) >= 50, $"only {refs.Count(r => r.IsExecute)} EXECUTE call sites found");
|
||||
Assert.Contains(refs, r => r.IsExecute && r.Name.Equals("fds__getDatevExports", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(refs, r => r.IsCall && r.Name.Equals("fis_getModuleAuth", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CSharpSqlReferences_AgainstSsdtProject_ExistAndBind()
|
||||
{
|
||||
var problems = Problems(ScanRepository(), LoadProject());
|
||||
Assert.True(problems.Count == 0, "C# ↔ SQL contract violations:\n" + string.Join('\n', problems));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MfrUpdtProcedures_AllDeclareTheSignatureFdsMfrClientCallsDynamically()
|
||||
{
|
||||
var updt = LoadProject().Values.Where(o => o.Name.StartsWith("mfr__updt__", StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
Assert.NotEmpty(updt);
|
||||
Assert.All(updt, o => Assert.Equal(MfrUpdtSignature, o.Parameters.Select(p => p.Name)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoggerSqlSinkProcedure_ExistsInSsdtProject()
|
||||
// Program.cs configures the OCORE MultiLogger sink by name (ADR 0024); a missing proc silently loses every DB log row.
|
||||
=> Assert.True(LoadProject().TryGetValue("fds__admin_logdebug_ext", out var o) && o.Kind == "PROCEDURE");
|
||||
|
||||
// ── The rules themselves: succeeding and failing inputs ───────────────────────────────────
|
||||
|
||||
private static readonly (string, bool)[] Datev = [("@tgtdate", false), ("@mode", true), ("@files", false), ("@authuser", false)];
|
||||
|
||||
[Theory]
|
||||
[InlineData("@tgtdate = @tgtdate, @files = 0, @authuser = @authuser")]
|
||||
[InlineData("@tgtdate, @mode, @files, @authuser")]
|
||||
[InlineData("@tgtdate, @mode, @files = 1, @authuser = @u")]
|
||||
public void ExecuteProblems_CorrectlyBoundArguments_None(string args)
|
||||
=> Assert.Empty(ExecuteProblems(args, Datev));
|
||||
|
||||
[Theory]
|
||||
[InlineData("@tgtdate, @mode, @authuser", "required parameter @authuser")] // the former inv/datev call
|
||||
[InlineData("@tgtdate = @t, @files = 0, @user = @u", "unknown parameter @user")]
|
||||
[InlineData("@tgtdate = @t, @mode, @files = 0, @authuser = @u", "after a named one")]
|
||||
[InlineData("@a, @b, @c, @d, @e", "beyond the 4 declared")]
|
||||
[InlineData("", "required parameter @tgtdate")]
|
||||
public void ExecuteProblems_MisboundArguments_Reported(string args, string expected)
|
||||
=> Assert.Contains(ExecuteProblems(args, Datev), p => p.Contains(expected, StringComparison.Ordinal));
|
||||
|
||||
[Theory]
|
||||
[InlineData("'fds_reports', @authuser", 2, true)]
|
||||
[InlineData("LEFT(@a, 3), @b", 2, true)]
|
||||
[InlineData("", 0, true)]
|
||||
[InlineData("@authuser", 2, false)]
|
||||
[InlineData("@a, @b, @c", 2, false)]
|
||||
public void CallProblems_ArgumentCount_ReportedOnlyOnMismatch(string args, int declared, bool ok)
|
||||
{
|
||||
var ps = Enumerable.Range(0, declared).Select(i => ($"@p{i}", false)).ToList();
|
||||
Assert.Equal(ok, CallProblems(args, ps).Count == 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanSource_ConcatenatedCommentedAndDocText_OnlyLiveStatementsWithFullArguments()
|
||||
{
|
||||
const string src = """
|
||||
var a = "EXECUTE [dbo].[proc_a] @x, " +
|
||||
"@y;";
|
||||
// "EXECUTE [dbo].[proc_commented] @z;"
|
||||
/// <c>[dbo].[proc_doc]</c>
|
||||
var b = "SELECT [dbo].[fn_b](@p, LEFT(@q, 2)) AS v";
|
||||
""";
|
||||
var refs = ScanSource("x.cs", src).ToList();
|
||||
Assert.Equal(["proc_a", "fn_b"], refs.Select(r => r.Name));
|
||||
Assert.Equal("@x, @y", refs[0].Args);
|
||||
Assert.Equal(1, refs[0].Line);
|
||||
Assert.Equal("@p, LEFT(@q, 2)", refs[1].Args);
|
||||
Assert.Equal(5, refs[1].Line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Problems_ObjectMissingFromProject_Reported()
|
||||
{
|
||||
var objects = new Dictionary<string, SqlObject>(StringComparer.OrdinalIgnoreCase);
|
||||
var refs = new[] { new SqlReference("x.cs", 3, "fds__gone", true, false, "@a") };
|
||||
Assert.Equal(["x.cs:3 [dbo].[fds__gone]: not in Fuchs_Database"], Problems(refs, objects));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("CREATE PROCEDURE [dbo].[p]\n\t@a as nvarchar(50)\n\t, @b int = 1 -- @c comment\nAS\nBEGIN SELECT 1 END", "PROCEDURE", "@a,@b", "False,True")]
|
||||
[InlineData("create function [f](@count int = 10)\nRETURNS TABLE AS RETURN SELECT 1 as x", "FUNCTION", "@count", "True")]
|
||||
[InlineData("CREATE FUNCTION [dbo].[g] (\n)\nRETURNS varchar(10) AS BEGIN RETURN '' END", "FUNCTION", "", "")]
|
||||
[InlineData("CREATE TYPE [dbo].[tt] AS TABLE ([Id] int)", "TYPE", "", "")]
|
||||
public void ParseObject_SsdtDeclarations_NameKindAndParameters(string sql, string kind, string names, string defaults)
|
||||
{
|
||||
var o = ParseObject(sql)!;
|
||||
Assert.Equal(kind, o.Kind);
|
||||
Assert.Equal(names, string.Join(",", o.Parameters.Select(p => p.Name)));
|
||||
Assert.Equal(defaults, string.Join(",", o.Parameters.Select(p => p.HasDefault)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Fuchs.intranet;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
using static OCORE.SQL.sql;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for the OCORE string → number SQL parameter helpers. Saving a Stornorechnung
|
||||
/// failed in production with "Error converting data type float to numeric": the negative totals
|
||||
/// did not parse in the first (sign-less) attempt and fell back to the server's de-DE culture,
|
||||
/// where "." is a group separator — "-19.95" became -1995 and JS float noise
|
||||
/// ("-1234.5600000000002") overflowed numeric(10,3), so fds__setInvoice never ran.
|
||||
/// Every test runs under de-DE, the production server culture.
|
||||
/// </summary>
|
||||
public class SqlNumberParameterTests : IDisposable
|
||||
{
|
||||
private readonly CultureInfo _culture = CultureInfo.CurrentCulture;
|
||||
|
||||
public SqlNumberParameterTests() => CultureInfo.CurrentCulture = new CultureInfo("de-DE");
|
||||
|
||||
public void Dispose() => CultureInfo.CurrentCulture = _culture;
|
||||
|
||||
[Theory]
|
||||
[InlineData("-19.95", -19.95)]
|
||||
[InlineData("-1234.56", -1234.56)]
|
||||
[InlineData("-1234.5600000000002", -1234.56)]
|
||||
[InlineData("-0.5", -0.5)]
|
||||
[InlineData("1234.56", 1234.56)]
|
||||
[InlineData("1234.5600000000002", 1234.56)]
|
||||
[InlineData("-1,5", -1.5)]
|
||||
[InlineData("-1.234,56", -1234.56)]
|
||||
[InlineData("1.234,56", 1234.56)]
|
||||
[InlineData("1,234.56", 1234.56)]
|
||||
[InlineData(" -42 ", -42)]
|
||||
[InlineData("-1.5E-05", -0.000015)]
|
||||
[InlineData("0", 0)]
|
||||
public void SQL_Float_StringValue_ParsesSignedAndCultureFormattedNumbers(string input, double expected)
|
||||
{
|
||||
var p = SQL_Float("@v", stringvalue: input);
|
||||
|
||||
var value = Assert.IsType<float>(p.Value);
|
||||
Assert.Equal(expected, value, 0.01);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("-19.95", -19.95)]
|
||||
[InlineData("-1234.56", -1234.56)]
|
||||
[InlineData("-1.234,56", -1234.56)]
|
||||
[InlineData("1.234,56", 1234.56)]
|
||||
[InlineData("-1,5", -1.5)]
|
||||
public void SQL_Decimal_StringValue_ParsesSignedAndCultureFormattedNumbers(string input, double expected)
|
||||
{
|
||||
var p = SQL_Decimal("@v", input, precision: 10, scale: 3);
|
||||
|
||||
var value = Assert.IsType<decimal>(p.Value);
|
||||
Assert.Equal((decimal)expected, value);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("NaN")]
|
||||
[InlineData("abc")]
|
||||
[InlineData("--5")]
|
||||
public void SQL_Float_StringValue_InvalidOrEmpty_IsDbNull(string input)
|
||||
{
|
||||
var p = SQL_Float("@v", stringvalue: input);
|
||||
|
||||
Assert.Equal(DBNull.Value, p.Value);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("NaN")]
|
||||
[InlineData("abc")]
|
||||
public void SQL_Decimal_StringValue_InvalidOrEmpty_IsDbNull(string input)
|
||||
{
|
||||
var p = SQL_Decimal("@v", input);
|
||||
|
||||
Assert.Equal(DBNull.Value, p.Value);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Groups every test class that saves/mutates/restores the process-wide static
|
||||
/// <see cref="OCORE.Logging.Logger"/> field (e.g. to exercise the <c>FIS_SQLOptions</c>/
|
||||
/// <c>FdsSqlOptions</c> "no logger given" fallback). xUnit runs different test *classes* in
|
||||
/// parallel by default (each is its own collection unless grouped); without this, two such tests
|
||||
/// racing on the same static field could read/restore each other's value and fail intermittently
|
||||
/// for reasons unrelated to the code under test. Putting them in one named collection makes xUnit
|
||||
/// run them sequentially relative to each other while leaving the rest of the suite parallel.
|
||||
/// </summary>
|
||||
[CollectionDefinition(Name)]
|
||||
public class StaticOcoreLoggerCollection
|
||||
{
|
||||
public const string Name = "OCORE.Logging.Logger (static)";
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Services;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -24,6 +25,7 @@ namespace Fuchs.Tests;
|
||||
public class SystemStatusServiceTests
|
||||
{
|
||||
private const string FuchsMeterName = "Fuchs.Intranet";
|
||||
private static readonly byte[] FakePdfBytes = { 0x25, 0x50, 0x44, 0x46, 0x2D }; // "%PDF-"
|
||||
|
||||
private static IConfiguration Config(Dictionary<string, string?>? overrides = null)
|
||||
{
|
||||
@@ -51,6 +53,7 @@ public class SystemStatusServiceTests
|
||||
Mock<IBlobStorageService>? blob = null,
|
||||
Mock<IMfrClientFactory>? mfr = null,
|
||||
Mock<IComService>? com = null,
|
||||
Mock<IPdfService>? pdf = null,
|
||||
ProcessWebComSettings? mailer = null,
|
||||
FuchsEmailSettings? email = null,
|
||||
AzureBlobStorageSettings? blobSettings = null,
|
||||
@@ -60,13 +63,31 @@ public class SystemStatusServiceTests
|
||||
var env = new Mock<IHostEnvironment>();
|
||||
env.SetupGet(e => e.EnvironmentName).Returns("Development");
|
||||
|
||||
var comMock = com ?? new Mock<IComService>();
|
||||
if (com is null)
|
||||
{
|
||||
comMock.Setup(c => c.VerifyCredentialsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new MailerCredentialsCheckResult { Ok = true, Account = "acc" });
|
||||
}
|
||||
|
||||
// Default PDF stub: renders any letter to a fixed, recognisable byte payload.
|
||||
var pdfMock = pdf ?? new Mock<IPdfService>();
|
||||
if (pdf is null)
|
||||
{
|
||||
pdfMock.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), It.IsAny<bool>()))
|
||||
.ReturnsAsync(new MigraDoc.DocumentObjectModel.Document());
|
||||
pdfMock.Setup(p => p.DocToPdfBytes(It.IsAny<MigraDoc.DocumentObjectModel.Document>()))
|
||||
.Returns(FakePdfBytes);
|
||||
}
|
||||
|
||||
return new SystemStatusService(
|
||||
config ?? Config(),
|
||||
env.Object,
|
||||
provider ?? new ServiceCollection().BuildServiceProvider(),
|
||||
(blob ?? new Mock<IBlobStorageService>()).Object,
|
||||
(mfr ?? new Mock<IMfrClientFactory>()).Object,
|
||||
(com ?? new Mock<IComService>()).Object,
|
||||
comMock.Object,
|
||||
pdfMock.Object,
|
||||
Options.Create(mailer ?? new ProcessWebComSettings { Enabled = true, Token = "tok", BaseUrl = "https://api.example", AccountId = "acc", ServerId = "srv" }),
|
||||
Options.Create(email ?? new FuchsEmailSettings()),
|
||||
Options.Create(blobSettings ?? new AzureBlobStorageSettings { Enabled = true }),
|
||||
@@ -277,6 +298,63 @@ public class SystemStatusServiceTests
|
||||
Assert.False(r.Ok);
|
||||
}
|
||||
|
||||
// ── Email probe ────────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public async Task ProbeEmail_MailerDisabled_ReturnsDisabled()
|
||||
{
|
||||
var svc = Build(mailer: new ProcessWebComSettings { Enabled = false, AccountId = "acc", Token = "tok" });
|
||||
|
||||
var r = await svc.ProbeAsync("email");
|
||||
|
||||
Assert.Equal("email", r.Component);
|
||||
Assert.Equal("disabled", r.Status);
|
||||
Assert.False(r.Ok);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("", "tok")]
|
||||
[InlineData("acc", "")]
|
||||
public async Task ProbeEmail_CredentialsMissing_ReturnsUnconfigured(string accountId, string token)
|
||||
{
|
||||
var svc = Build(mailer: new ProcessWebComSettings { Enabled = true, AccountId = accountId, Token = token });
|
||||
|
||||
var r = await svc.ProbeAsync("email");
|
||||
|
||||
Assert.Equal("unconfigured", r.Status);
|
||||
Assert.False(r.Ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProbeEmail_MailerConfirms_ReturnsOkWithAccount()
|
||||
{
|
||||
var com = new Mock<IComService>();
|
||||
com.Setup(c => c.VerifyCredentialsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new MailerCredentialsCheckResult { Ok = true, Account = "acc-123" });
|
||||
var svc = Build(com: com);
|
||||
|
||||
var r = await svc.ProbeAsync("email");
|
||||
|
||||
Assert.Equal("email", r.Component);
|
||||
Assert.Equal("ok", r.Status);
|
||||
Assert.True(r.Ok);
|
||||
Assert.Equal("acc-123", r.Detail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProbeEmail_MailerRejectsCredentials_ReturnsError()
|
||||
{
|
||||
var com = new Mock<IComService>();
|
||||
com.Setup(c => c.VerifyCredentialsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new MailerCredentialsCheckResult { Ok = false, Detail = "API-Fehler 401 (Unauthorized): {}" });
|
||||
var svc = Build(com: com);
|
||||
|
||||
var r = await svc.ProbeAsync("email");
|
||||
|
||||
Assert.Equal("error", r.Status);
|
||||
Assert.False(r.Ok);
|
||||
Assert.Equal("API-Fehler 401 (Unauthorized): {}", r.Detail);
|
||||
}
|
||||
|
||||
// ── Key Vault probe ────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public async Task ProbeKeyVault_ClientNotRegistered_ReturnsUnconfigured()
|
||||
@@ -431,6 +509,140 @@ public class SystemStatusServiceTests
|
||||
Assert.Null(r.OverrideRecipient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendTestEmailAsync_AttachPdfDefault_AttachesRenderedPdfUnderFixedName()
|
||||
{
|
||||
Dictionary<string, byte[]>? capturedAttachments = null;
|
||||
FuchsPdf.FdsTextBlocks? capturedBlocks = null;
|
||||
var com = new Mock<IComService>();
|
||||
com.Setup(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>(), It.IsAny<string?>()))
|
||||
.Callback<string, string, string, string, string, Dictionary<string, byte[]>?, string?>(
|
||||
(_, _, _, _, _, att, _) => capturedAttachments = att)
|
||||
.ReturnsAsync(true);
|
||||
var pdf = new Mock<IPdfService>();
|
||||
pdf.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), false))
|
||||
.Callback<FuchsPdf.FdsTextBlocks, bool>((tb, _) => capturedBlocks = tb)
|
||||
.ReturnsAsync(new MigraDoc.DocumentObjectModel.Document());
|
||||
pdf.Setup(p => p.DocToPdfBytes(It.IsAny<MigraDoc.DocumentObjectModel.Document>())).Returns(FakePdfBytes);
|
||||
var svc = Build(com: com, pdf: pdf);
|
||||
|
||||
var r = await svc.SendTestEmailAsync("dest@example.test", "Betreff", "Zeile1\r\nZeile2");
|
||||
|
||||
Assert.True(r.Sent);
|
||||
Assert.Equal(SystemStatusService.TestPdfAttachmentName, r.AttachmentName);
|
||||
Assert.Contains(SystemStatusService.TestPdfAttachmentName, r.Message);
|
||||
Assert.NotNull(capturedAttachments);
|
||||
var (name, bytes) = Assert.Single(capturedAttachments!);
|
||||
Assert.Equal(SystemStatusService.TestPdfAttachmentName, name);
|
||||
Assert.Equal(FakePdfBytes, bytes);
|
||||
// The letter carries the mail's own subject/body and addresses the requested recipient.
|
||||
Assert.NotNull(capturedBlocks);
|
||||
Assert.Equal("Betreff", capturedBlocks!.Subject);
|
||||
Assert.Equal("Zeile1\nZeile2", capturedBlocks.Body);
|
||||
Assert.Contains("dest@example.test", capturedBlocks.Address);
|
||||
pdf.Verify(p => p.DocToPdfBytes(It.IsAny<MigraDoc.DocumentObjectModel.Document>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendTestEmailAsync_AttachPdfFalse_SendsWithoutAttachmentAndSkipsRendering()
|
||||
{
|
||||
Dictionary<string, byte[]>? capturedAttachments = new();
|
||||
var com = new Mock<IComService>();
|
||||
com.Setup(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>(), It.IsAny<string?>()))
|
||||
.Callback<string, string, string, string, string, Dictionary<string, byte[]>?, string?>(
|
||||
(_, _, _, _, _, att, _) => capturedAttachments = att)
|
||||
.ReturnsAsync(true);
|
||||
var pdf = new Mock<IPdfService>(MockBehavior.Strict); // any render call would throw
|
||||
var svc = Build(com: com, pdf: pdf);
|
||||
|
||||
var r = await svc.SendTestEmailAsync("dest@example.test", "Betreff", "Body", attachPdf: false);
|
||||
|
||||
Assert.True(r.Sent);
|
||||
Assert.Null(r.AttachmentName);
|
||||
Assert.Null(capturedAttachments);
|
||||
Assert.DoesNotContain("PDF", r.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendTestEmailAsync_PdfRenderingFails_DoesNotSendAndReportsError()
|
||||
{
|
||||
var com = new Mock<IComService>();
|
||||
var pdf = new Mock<IPdfService>();
|
||||
pdf.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), It.IsAny<bool>()))
|
||||
.ThrowsAsync(new InvalidOperationException("font resolver missing"));
|
||||
var svc = Build(com: com, pdf: pdf);
|
||||
|
||||
var r = await svc.SendTestEmailAsync("dest@example.test", "Betreff", "Body");
|
||||
|
||||
Assert.False(r.Sent);
|
||||
Assert.Null(r.AttachmentName);
|
||||
Assert.Contains("PDF-Anhang", r.Message);
|
||||
Assert.Contains("font resolver missing", r.Message);
|
||||
Assert.Equal("dest@example.test", r.RequestedRecipient);
|
||||
com.Verify(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendTestEmailAsync_MailerRejectsWithAttachment_ReportsAttachmentNotConfirmed()
|
||||
{
|
||||
var com = new Mock<IComService>();
|
||||
com.Setup(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(false); // ADR 0028: also the outcome when attachmentsProcessed ≠ sent
|
||||
var svc = Build(com: com);
|
||||
|
||||
var r = await svc.SendTestEmailAsync("dest@example.test", "Betreff", "Body");
|
||||
|
||||
Assert.False(r.Sent);
|
||||
Assert.Equal(SystemStatusService.TestPdfAttachmentName, r.AttachmentName);
|
||||
Assert.Contains("Anhang", r.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendTestEmailAsync_RealLetterRenderer_ProducesValidOnePagePdfAttachment()
|
||||
{
|
||||
// Real MigraDoc/PdfSharp rendering of the letter the service builds (raw, without the
|
||||
// Spire PDF/A pass, so the test does not depend on a Spire license) — proves the text
|
||||
// blocks the service assembles are renderable, not just that a stub was called.
|
||||
Dictionary<string, byte[]>? capturedAttachments = null;
|
||||
var com = new Mock<IComService>();
|
||||
com.Setup(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>(), It.IsAny<string?>()))
|
||||
.Callback<string, string, string, string, string, Dictionary<string, byte[]>?, string?>(
|
||||
(_, _, _, _, _, att, _) => capturedAttachments = att)
|
||||
.ReturnsAsync(true);
|
||||
var pdf = new Mock<IPdfService>();
|
||||
pdf.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), It.IsAny<bool>()))
|
||||
.Returns<FuchsPdf.FdsTextBlocks, bool>((tb, draft) => FuchsPdf.WriteLetter(tb, draft, FuchsPdf.DeCulture));
|
||||
pdf.Setup(p => p.DocToPdfBytes(It.IsAny<MigraDoc.DocumentObjectModel.Document>()))
|
||||
.Returns<MigraDoc.DocumentObjectModel.Document>(FuchsPdf.DocToPdfBytesRaw);
|
||||
var svc = Build(com: com, pdf: pdf);
|
||||
|
||||
var r = await svc.SendTestEmailAsync("dest@example.test", "Fuchs Intranet – Test-E-Mail", "Dies ist eine Test-E-Mail.\nZweite Zeile.");
|
||||
|
||||
Assert.True(r.Sent);
|
||||
byte[] bytes = Assert.Single(capturedAttachments!).Value;
|
||||
Assert.True(bytes.Length > 1000, "attachment should be a real rendered PDF");
|
||||
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(bytes, 0, 4));
|
||||
using var opened = PdfSharp.Pdf.IO.PdfReader.Open(new System.IO.MemoryStream(bytes), PdfSharp.Pdf.IO.PdfDocumentOpenMode.Import);
|
||||
Assert.Equal(1, opened.PageCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendTestEmailAsync_MissingRequiredFields_DoesNotRenderPdf()
|
||||
{
|
||||
var pdf = new Mock<IPdfService>(MockBehavior.Strict);
|
||||
var svc = Build(pdf: pdf);
|
||||
|
||||
var r = await svc.SendTestEmailAsync("", "Betreff", "Body");
|
||||
|
||||
Assert.False(r.Sent);
|
||||
Assert.Null(r.AttachmentName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendTestEmailAsync_OverrideActive_ReportsOverrideRecipient()
|
||||
{
|
||||
|
||||
@@ -69,13 +69,34 @@ public partial class IntranetController
|
||||
{
|
||||
if (!HasForm("to", "subject"))
|
||||
return BadRequest400();
|
||||
// attachpdf is the dialog's Ja/Nein select ("true"/"false"); absent → attach (default).
|
||||
bool attachPdf = !bool.TryParse(Form("attachpdf"), out bool parsed) || parsed;
|
||||
var result = await status.SendTestEmailAsync(
|
||||
Form("to"), Form("subject"), Form("body"), HttpContext.RequestAborted);
|
||||
_logger.LogInformation("Admin test email requested by user={User} to={To} sent={Sent}",
|
||||
UserAccountID, result.RequestedRecipient, result.Sent);
|
||||
Form("to"), Form("subject"), Form("body"), attachPdf, HttpContext.RequestAborted);
|
||||
_logger.LogInformation("Admin test email requested by user={User} to={To} attachment={Attachment} sent={Sent}",
|
||||
UserAccountID, result.RequestedRecipient, result.AttachmentName, result.Sent);
|
||||
return AdminJson(new { result });
|
||||
}
|
||||
|
||||
// Maintenance action, reachable only from the admin-gated "Specialties" page
|
||||
// (IntranetController.Specialties.cs / fis.specialties.js): backs up an invoice's
|
||||
// stored PDF and clears it so the document regenerates on next read (ADR 0025 /
|
||||
// Docs/Decisions/0027). GET-safe crawlers/prefetchers must never trigger this, so it
|
||||
// requires POST despite the rest of the Admin module tolerating either verb.
|
||||
case "backupclearinvoicefile":
|
||||
{
|
||||
if (!HttpContext.Request.Method.Equals("POST", StringComparison.OrdinalIgnoreCase))
|
||||
return BadRequest400();
|
||||
string invoiceId = string.IsNullOrWhiteSpace(code) ? Form("id") : code;
|
||||
if (string.IsNullOrWhiteSpace(invoiceId))
|
||||
return BadRequest400();
|
||||
var backup = await _invoiceFileBackup.BackupAndClearAsync(invoiceId, UserAccountID, DbSec, HttpContext.RequestAborted);
|
||||
_logger.LogWarning(
|
||||
"Admin invoice-file backup/clear requested by user={User} invoiceId={InvoiceId} success={Success} reason={Reason}",
|
||||
UserAccountID, invoiceId, backup.Success, backup.Reason);
|
||||
return AdminJson(new { backup });
|
||||
}
|
||||
|
||||
default:
|
||||
_logger.LogWarning("Admin: no handler for action={Action}, user={User}", id, UserAccountID);
|
||||
return BadRequest400();
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using OCORE.SQL;
|
||||
using static OCORE.commons;
|
||||
using static OCORE.OCORE_dictionaries;
|
||||
using static OCORE.SQL.sql;
|
||||
using static OCORE.web.mvc_helper_async;
|
||||
|
||||
namespace Fuchs.Controllers;
|
||||
|
||||
// Partial class: Administration user-group management module (fn=usrgrp).
|
||||
//
|
||||
// Split out as its own module (rather than folded into the usr module) per
|
||||
// Docs/Decisions/0021-user-group-management.md, following the pattern
|
||||
// Docs/Decisions/0015-split-admin-user-management-module.md already established for adding a new
|
||||
// admin-only capability. Shares the Admin/usr modules' fds_sys > 4 gate (AdminMinAuthExclusive,
|
||||
// GetSystemAdminAuthAsync, AdminJson — all in IntranetController.Admin.cs).
|
||||
//
|
||||
// The fis_admin_* stored procs/functions called below enforce the SAME SEPARATE, independent
|
||||
// authorization axis as the usr module: the per-user "intranet_admin" setting via
|
||||
// fis_admin_getUserAuth(@authaccount) > 1. An account with fds_sys > 4 but no intranet_admin
|
||||
// setting will reach these endpoints yet get empty results / no-op writes from SQL — accounts
|
||||
// doing group management need BOTH.
|
||||
public partial class IntranetController
|
||||
{
|
||||
private async Task<IActionResult> Do_Process_AdminGroups(string fn, string id, string code)
|
||||
{
|
||||
_logger.LogDebug("Do_Process_AdminGroups action={Action} code={Code} user={User}", id, code, UserAccountID);
|
||||
|
||||
int authLevel = await GetSystemAdminAuthAsync(fn, id, code);
|
||||
bool authorized = authLevel > AdminMinAuthExclusive;
|
||||
|
||||
if (id.Equals("auth", StringComparison.OrdinalIgnoreCase))
|
||||
return await JSONAsync(new { manage = authorized ? 1 : 0, level = authLevel });
|
||||
|
||||
if (!authorized)
|
||||
{
|
||||
_logger.LogWarning("AdminGroups access denied for user={User} (fds_sys={Level}) action={Action}",
|
||||
UserAccountID, authLevel, id);
|
||||
return Unauthorized401();
|
||||
}
|
||||
|
||||
switch (id.ToLowerInvariant())
|
||||
{
|
||||
case "list":
|
||||
{
|
||||
var res = await getSQLDatatable_async(
|
||||
"SELECT * FROM [dbo].[fis_admin_getUserGroupList](@authaccount, NULL) ORDER BY [order];",
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
StdParamlist(SQL_VarChar("@authaccount", UserAccountID)),
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
return AdminJson(new { groups = res.DataTable.toArrayofObjectDictionaries() });
|
||||
}
|
||||
|
||||
case "create":
|
||||
case "rename":
|
||||
{
|
||||
if (!HasForm("name")) return BadRequest400();
|
||||
string name = Form("name");
|
||||
string? groupId = id.Equals("rename", StringComparison.OrdinalIgnoreCase) ? Form("usergroup_id") : null;
|
||||
if (id.Equals("rename", StringComparison.OrdinalIgnoreCase) && string.IsNullOrWhiteSpace(groupId))
|
||||
return BadRequest400();
|
||||
|
||||
var pl = StdParamlist(
|
||||
SQL_VarChar("@authaccount", UserAccountID),
|
||||
SQL_VarChar("@usergroup_id", groupId, dbNull_IfEmpty: true),
|
||||
SQL_NVarChar("@name", name));
|
||||
|
||||
System.Data.DataRow? savedRow;
|
||||
try
|
||||
{
|
||||
var res = await getSQLDatatable_async(
|
||||
"EXECUTE [dbo].[fis_admin_setUserGroup] @authaccount, @usergroup_id, @name, @enc_key;",
|
||||
_intranet.Intranet__SQLConnectionString, pl,
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
savedRow = res.Count > 0 ? res.FirstRow : null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await NotifyAdminUserActionFailedAsync("Gruppe speichern fehlgeschlagen",
|
||||
"Eine Benutzergruppe konnte nicht gespeichert werden.", groupId ?? "", ex);
|
||||
return StatusCode(500, new { error = "save_failed" });
|
||||
}
|
||||
|
||||
if (savedRow == null)
|
||||
{
|
||||
_logger.LogWarning("AdminGroups {Action} not successful group={Group} user={User}", id, groupId, UserAccountID);
|
||||
return StatusCode(500, new { error = "save_failed" });
|
||||
}
|
||||
|
||||
_logger.LogInformation("AdminGroups {Action} succeeded group={Group} user={User}",
|
||||
id, savedRow.nz("usergroup_id"), UserAccountID);
|
||||
return AdminJson(new { ok = true, usergroup_id = savedRow.nz("usergroup_id"), name = savedRow.nz("name") });
|
||||
}
|
||||
|
||||
case "delete":
|
||||
{
|
||||
if (!HasForm("usergroup_id")) return BadRequest400();
|
||||
string groupId = Form("usergroup_id");
|
||||
|
||||
bool success;
|
||||
try
|
||||
{
|
||||
var res = await getSQLDatatable_async(
|
||||
"EXECUTE [dbo].[fis_admin_delUserGroup] @authaccount, @usergroup_id;",
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
StdParamlist(SQL_VarChar("@authaccount", UserAccountID), SQL_VarChar("@usergroup_id", groupId)),
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
success = res.Count > 0 && res.FirstRow[0] is true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await NotifyAdminUserActionFailedAsync("Gruppe löschen fehlgeschlagen",
|
||||
$"Die Benutzergruppe {groupId} konnte nicht gelöscht werden.", groupId, ex);
|
||||
return StatusCode(500, new { error = "delete_failed" });
|
||||
}
|
||||
|
||||
if (!success)
|
||||
{
|
||||
_logger.LogWarning("AdminGroups delete not successful group={Group} user={User}", groupId, UserAccountID);
|
||||
return StatusCode(500, new { error = "delete_failed" });
|
||||
}
|
||||
|
||||
_logger.LogInformation("AdminGroups delete succeeded group={Group} user={User}", groupId, UserAccountID);
|
||||
return AdminJson(new { ok = true });
|
||||
}
|
||||
|
||||
default:
|
||||
_logger.LogWarning("AdminGroups: no handler for action={Action}, user={User}", id, UserAccountID);
|
||||
return BadRequest400();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Web;
|
||||
using System.Web;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using OCORE.SQL;
|
||||
@@ -44,10 +44,16 @@ public partial class IntranetController
|
||||
{
|
||||
case "list":
|
||||
{
|
||||
// Optional useraccount_id narrows the result to a single account so the frontend can
|
||||
// refresh one row in place (e.g. after an email or group-membership change) instead
|
||||
// of re-rendering the whole grid and losing the current selection.
|
||||
string? filterId = HasForm("useraccount_id") ? Form("useraccount_id") : null;
|
||||
var res = await getSQLDatatable_async(
|
||||
"SELECT * FROM [dbo].[fis_admin_getUserAccountList](@authaccount, NULL, NULL, NULL) ORDER BY [order];",
|
||||
"SELECT * FROM [dbo].[fis_admin_getUserAccountList](@authaccount, @useraccount_id, NULL, NULL) ORDER BY [order];",
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
StdParamlist(SQL_VarChar("@authaccount", UserAccountID)),
|
||||
StdParamlist(
|
||||
SQL_VarChar("@authaccount", UserAccountID),
|
||||
SQL_VarChar("@useraccount_id", filterId, dbNull_IfEmpty: true)),
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
return AdminJson(new { users = res.DataTable.toArrayofObjectDictionaries() });
|
||||
}
|
||||
|
||||
@@ -55,22 +55,22 @@ public partial class IntranetController
|
||||
{
|
||||
importFailed = true;
|
||||
importFailure = exc.Message;
|
||||
_logger.LogError(exc,
|
||||
"Banking upload SQL exception — file={File} destTable={DestTable} user={User}",
|
||||
fle.FileName, dtwa.DestinationTableName, UserAccountID);
|
||||
_intranet.debug_log("IntranetController.bam.up - sql exception",
|
||||
exc, UserAccountID, new { uid = dtwa.InstanceGUID, tmptbl });
|
||||
// EventId.Name keeps the exact CodeReference the DB log used before the
|
||||
// generic SQL sink existed (ADR 0024); {Data} carries uid/tmptbl into it.
|
||||
_logger.LogError(new EventId(0, "IntranetController.bam.up - sql exception"), exc,
|
||||
"Banking upload SQL exception — file={File} destTable={DestTable} user={User} data={Data}",
|
||||
fle.FileName, dtwa.DestinationTableName, UserAccountID,
|
||||
new { uid = dtwa.InstanceGUID, tmptbl });
|
||||
};
|
||||
dtwa.OnCommandAfterError += (_, exc) =>
|
||||
{
|
||||
importFailed = true;
|
||||
importFailure = exc.Message;
|
||||
_logger.LogError(exc,
|
||||
_logger.LogError(new EventId(0, "IntranetController.bam.up - command-after exception"), exc,
|
||||
"Banking upload merge-command exception — file={File} destTable={DestTable} " +
|
||||
"rescueTable={RescueTable} user={User}",
|
||||
fle.FileName, dtwa.DestinationTableName, tmptbl, UserAccountID);
|
||||
_intranet.debug_log("IntranetController.bam.up - command-after exception",
|
||||
exc, UserAccountID, new { uid = dtwa.InstanceGUID, tmptbl });
|
||||
"rescueTable={RescueTable} user={User} data={Data}",
|
||||
fle.FileName, dtwa.DestinationTableName, tmptbl, UserAccountID,
|
||||
new { uid = dtwa.InstanceGUID, tmptbl });
|
||||
};
|
||||
_logger.LogDebug("Banking upload parsed {Rows} rows → temp table submit (user={User})",
|
||||
tbl.Rows.Count, UserAccountID);
|
||||
|
||||
@@ -3,6 +3,7 @@ using Fuchs.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using static OCORE.SQL.sql;
|
||||
using static OCORE.web.mvc_helper_async;
|
||||
|
||||
namespace Fuchs.Controllers;
|
||||
@@ -14,6 +15,20 @@ namespace Fuchs.Controllers;
|
||||
// only signals (ADR 0007).
|
||||
public partial class IntranetController
|
||||
{
|
||||
/// <summary>
|
||||
/// The date the calling user may backdate an invoice to (last working day of the previous month), or
|
||||
/// null when not allowed — fds_inv > 2 and no invoice finalized after that date yet. The SQL function
|
||||
/// is the single source; fds__setInvoiceFinal re-checks it when @backdate = 1 (ADR 0032).
|
||||
/// </summary>
|
||||
private async Task<DateOnly?> GetInvoiceBackdateAsync(string fn, string id, string code)
|
||||
{
|
||||
var val = await getSQLValue_async<string>(
|
||||
"SELECT CONVERT(varchar(10), [dbo].[fds__fn_invoiceBackdate](@authuser), 23);",
|
||||
_intranet.Intranet__SQLConnectionString, "",
|
||||
StdParamlist(), Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
return InvoiceDates.ParseBackdate(val.Result);
|
||||
}
|
||||
|
||||
/// <summary>Standard 410 when a session token is unknown/expired — the client re-opens the draft.</summary>
|
||||
private IActionResult DraftGone() => StatusCode(410, new { error = "expired" });
|
||||
|
||||
@@ -71,14 +86,20 @@ public partial class IntranetController
|
||||
return await JSONAsync(new { ok = true, version = session.Version });
|
||||
}
|
||||
|
||||
// POST inv/dpreview — { token } → { img[], total } (rendered straight from the cache)
|
||||
// POST inv/dpreview — { token } → { img[], total, backdate } (rendered straight from the cache).
|
||||
// backdate ("yyyy-MM-dd" or null) drives the admin-only "Rechnungsdatum Vormonat" checkbox (ADR 0032).
|
||||
private async Task<IActionResult> HandleDraftPreview(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("token")) return BadRequest400();
|
||||
var doc = await _invoiceDrafts.RenderPreviewAsync(Form("token"));
|
||||
if (doc == null) return DraftGone();
|
||||
var imgcol = await _pdf.DocToImageCollectionAsync(doc);
|
||||
return await JSONAsync(new { img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||
string token = Form("token");
|
||||
return await PdfPreviewAsync("inv/dpreview", token, async () =>
|
||||
{
|
||||
var doc = await _invoiceDrafts.RenderPreviewAsync(token);
|
||||
if (doc == null) return DraftGone();
|
||||
var imgcol = await _pdf.DocToImageCollectionAsync(doc);
|
||||
DateOnly? backdate = await GetInvoiceBackdateAsync(fn, id, code);
|
||||
return await JSONAsync(new { img = imgcol.ImgB64Array, total = imgcol.TotalPages, backdate = backdate?.ToString("yyyy-MM-dd") });
|
||||
});
|
||||
}
|
||||
|
||||
// POST inv/dsave — { token } → { ok, invid }; flush cache→DB + business event + draftReady
|
||||
@@ -86,16 +107,15 @@ public partial class IntranetController
|
||||
{
|
||||
if (!HasForm("token")) return BadRequest400();
|
||||
string token = Form("token");
|
||||
var before = _invoiceDrafts.Get(token);
|
||||
if (before == null) return DraftGone();
|
||||
bool existed = !string.IsNullOrEmpty(before.InvId);
|
||||
|
||||
var fdInv = await _invoiceDrafts.FlushToDbAsync(token, UserAccountID, DbSec);
|
||||
if (fdInv == null) return DraftGone();
|
||||
// Create vs update is decided inside the flush (flushes of one session are serialised), not
|
||||
// from the session state seen here: a save overlapping the first registration is an update.
|
||||
var flush = await _invoiceDrafts.FlushToDbAsync(token, UserAccountID, DbSec);
|
||||
if (flush == null) return DraftGone();
|
||||
var fdInv = flush.Data;
|
||||
if (string.IsNullOrEmpty(fdInv.Id))
|
||||
return await InvoiceIssueResult("Der Zwischenstand konnte aufgrund eines Fehlers nicht gespeichert werden.");
|
||||
|
||||
await _events.InvoiceDraftRegisteredAsync(fdInv, existed, UserAccountID);
|
||||
await _events.InvoiceDraftRegisteredAsync(fdInv, flush.Updated, UserAccountID);
|
||||
var after = _invoiceDrafts.Get(token);
|
||||
if (after != null) await _draftNotifier.SignalDraftReadyAsync(after.Token, after.Version);
|
||||
return await JSONAsync(new { ok = true, invid = fdInv.Id });
|
||||
|
||||
@@ -131,6 +131,7 @@ public partial class IntranetController
|
||||
return await HandleDatev(fn, id, code);
|
||||
|
||||
case "rdoc":
|
||||
if (!HasForm("id")) { _logger.LogWarning("rdoc: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); }
|
||||
_logger.LogDebug("rdoc: report document get id={DocId}, user={User}", Form("id"), UserAccountID);
|
||||
return await HandleReportDoc(fn, id, code, Form("id"));
|
||||
|
||||
|
||||
@@ -16,10 +16,22 @@ namespace Fuchs.Controllers;
|
||||
// Partial class: Invoice sub-handlers
|
||||
public partial class IntranetController
|
||||
{
|
||||
/// <summary>True when <paramref name="raw"/> is an mfr invoice id (positive integer); Intranet-created
|
||||
/// invoices use alphanumeric ids and have no mfr entity to reset.</summary>
|
||||
internal static bool TryGetMfrInvoiceId(string? raw, out long mfrId)
|
||||
=> long.TryParse(raw?.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out mfrId) && mfrId > 0;
|
||||
|
||||
private async Task<IActionResult> HandleInvoicePget(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("id")) { _logger.LogWarning("HandleInvoicePget: missing 'id' form field user={User}", UserAccountID); return BadRequest400(); }
|
||||
if (!long.TryParse(Form("id"), out long tgtid)) { _logger.LogWarning("HandleInvoicePget: invalid 'id' value='{Value}' user={User}", Form("id"), UserAccountID); return BadRequest400(); }
|
||||
if (!TryGetMfrInvoiceId(Form("id"), out long tgtid))
|
||||
{
|
||||
// Intranet-created invoices carry an alphanumeric Id (fds__fn_invoice_id) and have no mfr
|
||||
// entity to reset. Both storno paths call pget first, so rejecting them here (400) left the
|
||||
// storno window empty. Legacy parity: skip the mfr reset and report success.
|
||||
_logger.LogDebug("HandleInvoicePget: non-mfr invoice id='{Value}', nothing to reset user={User}", Form("id"), UserAccountID);
|
||||
return await JSONAsync(new { ok = true });
|
||||
}
|
||||
_logger.LogDebug("HandleInvoicePget tgtid={TgtId} user={User}", tgtid, UserAccountID);
|
||||
|
||||
using (var mfr = _mfrFactory.Create())
|
||||
@@ -263,6 +275,16 @@ public partial class IntranetController
|
||||
return await JSONAsync(new { payments = sqldt.Tables("items").toArrayofObjectDictionaries() });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DATEV overview (<c>inv/datev</c>). Parameters are bound by name: the proc's signature is
|
||||
/// (@tgtdate, @mode, @files, @authuser) and the former positional call put @authuser into
|
||||
/// @files and never supplied @authuser, so every call failed. @files = 0 — the overview needs
|
||||
/// no PDF bytes. Result sets in proc order: header, invoice documents, bookings, debtors.
|
||||
/// </summary>
|
||||
internal const string DatevOverviewSql =
|
||||
"EXECUTE [dbo].[fds__getDatevExports] @tgtdate = @tgtdate, @mode = @mode, @files = 0, @authuser = @authuser;";
|
||||
internal static readonly string[] DatevOverviewTables = { "admin", "files", "invoices", "debits" };
|
||||
|
||||
private async Task<IActionResult> HandleDatev(string fn, string id, string code)
|
||||
{
|
||||
if (!DateTime.TryParseExact(Form("tgt"), "yy-MM-dd",
|
||||
@@ -274,10 +296,10 @@ public partial class IntranetController
|
||||
string mode = Form("mode").ne("m");
|
||||
_logger.LogDebug("HandleDatev tgtdate={TgtDate} mode={Mode} user={User}", tgtdate.ToString("yy-MM-dd"), mode, UserAccountID);
|
||||
var dset = await getSQLDataSet_async(
|
||||
"EXECUTE [dbo].[fds__getDatevExports] @tgtdate, @mode, @authuser;",
|
||||
DatevOverviewSql,
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
StdParamlist(SQL_Date("@tgtdate", tgtdate), SQL_VarChar("@mode", mode)),
|
||||
tablenames: new[] { "files", "invoices", "debits" },
|
||||
tablenames: DatevOverviewTables,
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
_logger.LogDebug("HandleDatev tgtdate={TgtDate} files={FileCount} invoices={InvCount} user={User}",
|
||||
tgtdate.ToString("yy-MM-dd"), dset.Tables("files").Rows.Count, dset.Tables("invoices").Rows.Count, UserAccountID);
|
||||
@@ -293,18 +315,49 @@ public partial class IntranetController
|
||||
_logger.LogDebug("HandleReportDoc reportid={ReportId} typ={Typ} user={User}", reportid, Form("typ"), UserAccountID);
|
||||
byte[]? content = null;
|
||||
var file = _mfr.GetReportDoc(ref content, reportid);
|
||||
if (file == null)
|
||||
string fileName, mimeType;
|
||||
if (file != null && content is { Length: > 0 })
|
||||
{
|
||||
_logger.LogWarning("HandleReportDoc: document not found reportid={ReportId} user={User}", reportid, UserAccountID);
|
||||
return StatusCode(404, new { error = "Dokument wurde nicht gefunden" });
|
||||
fileName = file.Name;
|
||||
mimeType = file.MimeType();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Regenerate-on-miss (ADR 0025). Finalised, non-external only: fds__setInvoiceFile
|
||||
// writes WHERE [file] IS NULL, so persisting a draft render here would permanently
|
||||
// block the real final PDF at finalisation.
|
||||
var fdInv = await _invoices.LoadInvoiceAsync(reportid, UserAccountID, DbSec);
|
||||
bool isExternal = fdInv.InvoiceRegistration?.getItem("IsExternal", false) is true;
|
||||
if (!string.IsNullOrEmpty(fdInv.Id) && !fdInv.IsDraft && !isExternal)
|
||||
{
|
||||
_logger.LogWarning("HandleReportDoc: no stored document for reportid={ReportId}, regenerating, user={User}", reportid, UserAccountID);
|
||||
content = await _invoices.StoreInvoiceDocumentFileAsync(fdInv, fdInv.IsDraft, UserAccountID, DbSec);
|
||||
}
|
||||
if (content == null || content.Length == 0)
|
||||
{
|
||||
_logger.LogWarning("HandleReportDoc: document not found reportid={ReportId} user={User}", reportid, UserAccountID);
|
||||
return StatusCode(404, new { error = "Dokument wurde nicht gefunden" });
|
||||
}
|
||||
fileName = SafeDocumentFileName(
|
||||
fdInv.InvoiceRegistration?.getString("DocumentName") ?? "", $"Rechnung_{fdInv.Id.ne(reportid)}.pdf");
|
||||
mimeType = "application/pdf";
|
||||
}
|
||||
_logger.LogDebug("HandleReportDoc found reportid={ReportId} fileName={FileName} mimeType={MimeType} size={Size} user={User}",
|
||||
reportid, file.Name, file.MimeType(), content?.Length ?? 0, UserAccountID);
|
||||
return Form("typ") != "img"
|
||||
? await FileContentResultAsync(content!, file.MimeType(), file.Name)
|
||||
: await JSONAsync(new { id = reportid, img = await BuildPdfImageArray(content!) });
|
||||
reportid, fileName, mimeType, content.Length, UserAccountID);
|
||||
if (Form("typ") != "img")
|
||||
return await FileContentResultAsync(content, mimeType, fileName);
|
||||
return await PdfPreviewAsync("inv/rdoc", reportid, async () =>
|
||||
await JSONAsync(new { id = reportid, img = await BuildPdfImageArray(content!) }));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Download filename for a regenerated document — the one shared sanitiser
|
||||
/// (<see cref="fds.FdsDocumentNames.SafeFileName"/>) that <c>FdsMfr.GetReportDoc</c> also
|
||||
/// applies to stored documents, so both paths name the same DocumentName identically.
|
||||
/// </summary>
|
||||
internal static string SafeDocumentFileName(string documentName, string fallback) =>
|
||||
fds.FdsDocumentNames.SafeFileName(documentName, fallback);
|
||||
|
||||
private async Task<IActionResult> HandleReportDocByName(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("name")) { _logger.LogWarning("HandleReportDocByName: missing 'name' form field user={User}", UserAccountID); return BadRequest400(); }
|
||||
@@ -422,7 +475,9 @@ public partial class IntranetController
|
||||
{
|
||||
["Id"] = d["Id"],
|
||||
["net_val"] = net,
|
||||
["vat_val"] = net * vat * 0.01,
|
||||
// Cent-rounded in decimal like InvoiceDraftCalculator; the former double product
|
||||
// (e.g. -615.3757999999999) carried float noise into the draft's VAT sums.
|
||||
["vat_val"] = Math.Round((decimal)net * (decimal)vat / 100m, 2, MidpointRounding.AwayFromZero),
|
||||
["vat"] = vat == 0 ? "" : vat.ToString("0.00", FuchsPdf.DeCulture) + "%",
|
||||
["svcnet_val"] = d.no("value_service", 0),
|
||||
["net"] = d.no("value", 0),
|
||||
@@ -434,7 +489,10 @@ public partial class IntranetController
|
||||
["NameOrNumber"] = "",
|
||||
["htmltext"] = d["Text"],
|
||||
["position"] = d["Position"],
|
||||
["SortOrder"] = d["SortOrder"]
|
||||
["SortOrder"] = d["SortOrder"],
|
||||
// Round-trips the referenced invoice (CanceledInvoice / IntInvoiceAllocation) so a
|
||||
// re-saved draft keeps it — fds__setInvoiceFinal needs it for Replaces/allocations.
|
||||
["reference_InvId"] = d.nz("reference_InvId") is { Length: > 0 } refId ? refId : null
|
||||
});
|
||||
}
|
||||
sdic["items"] = itm;
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
using System.Diagnostics;
|
||||
using Fuchs.Observability;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Fuchs.Controllers;
|
||||
|
||||
// Partial class: shared guard for every "PDF-Vorschau" endpoint (inv/dpreview, rem/dpreview,
|
||||
// req/idoc+img, rem/idoc+img, inv/rdoc+img, rem/rdoc+img).
|
||||
//
|
||||
// A preview render can fail deep inside MigraDoc / Spire (fonts, licence, malformed HTML in an
|
||||
// item text, an oversized inline image, ...). Before this guard such a failure only surfaced as
|
||||
// the generic catch-all in Do(): an ErrorLog.txt line on the web server, nothing in the DB log
|
||||
// support actually reads, and an empty "Der Server hat einen Fehler gemeldet:" alert in the
|
||||
// browser. Every preview now goes through PdfPreviewAsync so the failure is (1) logged with
|
||||
// what/ref/user/duration — which the SQL log sink (ADR 0024) additionally persists to
|
||||
// fds__debuglog — (2) counted, (3) toasted to the user and (4) returned with a readable
|
||||
// description header.
|
||||
public partial class IntranetController
|
||||
{
|
||||
internal const string PdfPreviewLogReference = "PDF preview failed";
|
||||
|
||||
/// <summary>
|
||||
/// Runs <paramref name="render"/> (the endpoint body that produces the preview response) and
|
||||
/// turns any exception into a fully logged, user-readable 500.
|
||||
/// </summary>
|
||||
/// <param name="what">Endpoint tag, e.g. <c>inv/dpreview</c>.</param>
|
||||
/// <param name="refId">Draft token / invoice / reminder / report id the preview is for.</param>
|
||||
private async Task<IActionResult> PdfPreviewAsync(string what, string refId, Func<Task<IActionResult>> render)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
using var act = FuchsTelemetry.StartActivity("pdf.preview");
|
||||
act?.SetTag("fuchs.pdf.preview.what", what);
|
||||
act?.SetTag("fuchs.pdf.preview.ref", refId);
|
||||
_logger.LogDebug("PDF preview {What} ref={RefId} user={User} started", what, refId, UserAccountID);
|
||||
try
|
||||
{
|
||||
var result = await render();
|
||||
sw.Stop();
|
||||
|
||||
// The client (postXT) gives up after a fixed timeout and aborts the connection while a
|
||||
// slow render/regenerate-on-miss (ADR 0025) is still running server-side; that work is
|
||||
// deliberately NOT cancelled — the regenerated document must still be stored so the
|
||||
// next click is fast (ADR 0025) — but writing the now-finished result back to a socket
|
||||
// the client already closed would only surface as a low-level framework/Kestrel
|
||||
// exception that never reaches an EventId'd `_logger` call: it is either below the SQL
|
||||
// sink's Warning floor or purely a debugger first-chance notification that never goes
|
||||
// through ILogger at all — exactly the "exception in the debug console but nothing in
|
||||
// fds__debuglog" gap. Recognise the disconnect here instead and log it deliberately, at
|
||||
// Warning, so it is guaranteed to land in fds__debuglog (ADR 0024) — and skip the write.
|
||||
if (HttpContext.RequestAborted.IsCancellationRequested)
|
||||
{
|
||||
FuchsTelemetry.PdfPreviews.Add(1,
|
||||
new KeyValuePair<string, object?>("what", what),
|
||||
new KeyValuePair<string, object?>("outcome", "client-disconnected"));
|
||||
_logger.LogWarning(
|
||||
"PDF preview {What} ref={RefId} user={User} finished in {Ms} ms but the client had already disconnected (timeout) — result discarded",
|
||||
what, refId, UserAccountID, sw.ElapsedMilliseconds);
|
||||
return new EmptyResult();
|
||||
}
|
||||
|
||||
// A draft session that expired between page load and preview click is an expected
|
||||
// client-driven outcome (the browser reopens the draft), not a render failure — keep
|
||||
// it out of the failure telemetry/logging this guard exists to produce.
|
||||
var outcome = result is ObjectResult { StatusCode: 410 } ? "gone" : "success";
|
||||
FuchsTelemetry.PdfPreviews.Add(1,
|
||||
new KeyValuePair<string, object?>("what", what),
|
||||
new KeyValuePair<string, object?>("outcome", outcome));
|
||||
_logger.LogInformation("PDF preview {What} ref={RefId} user={User} {Outcome} in {Ms} ms",
|
||||
what, refId, UserAccountID, outcome, sw.ElapsedMilliseconds);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
act?.SetStatus(ActivityStatusCode.Error, ex.Message);
|
||||
// Same client-gone rationale as above, but the render itself threw (e.g. it tried to
|
||||
// write partial output to the aborted connection). Still an expected outcome of a
|
||||
// client timeout, not a render defect — logged at Warning, not Error, but still
|
||||
// guaranteed to reach fds__debuglog (ADR 0024) rather than only a debug console.
|
||||
if (HttpContext.RequestAborted.IsCancellationRequested)
|
||||
{
|
||||
FuchsTelemetry.PdfPreviews.Add(1,
|
||||
new KeyValuePair<string, object?>("what", what),
|
||||
new KeyValuePair<string, object?>("outcome", "client-disconnected"));
|
||||
_logger.LogWarning(ex,
|
||||
"PDF preview {What} ref={RefId} user={User} failed after {Ms} ms because the client had already disconnected",
|
||||
what, refId, UserAccountID, sw.ElapsedMilliseconds);
|
||||
return new EmptyResult();
|
||||
}
|
||||
FuchsTelemetry.PdfPreviews.Add(1,
|
||||
new KeyValuePair<string, object?>("what", what),
|
||||
new KeyValuePair<string, object?>("outcome", "failure"));
|
||||
// EventId.Name keeps the exact "PDF preview failed (what)" CodeReference the DB log
|
||||
// used before the generic SQL sink existed (ADR 0024).
|
||||
_logger.LogError(new EventId(0, $"{PdfPreviewLogReference} ({what})"), ex,
|
||||
"PDF preview {What} ref={RefId} user={User} failed after {Ms} ms",
|
||||
what, refId, UserAccountID, sw.ElapsedMilliseconds);
|
||||
// Pre-auth flows (some preview endpoints are in _allowedGet, see IntranetController.cs)
|
||||
// have no user session to notify, matching the guard the Do() catch-all uses.
|
||||
if (UserIdent.IsAuthenticated)
|
||||
{
|
||||
await _events.UserIssueAsync(
|
||||
"PDF-Vorschau fehlgeschlagen",
|
||||
"Die PDF-Vorschau konnte nicht erstellt werden. Der Fehler wurde protokolliert.",
|
||||
UserAccountID,
|
||||
new Dictionary<string, object?> { ["what"] = what, ["ref"] = refId });
|
||||
}
|
||||
return ServerError(PdfPreviewFailureMessage(ex, UserIdent.IsAuthenticated));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// User-facing message. The innermost exception text is only included for an authenticated
|
||||
/// intranet user — some preview endpoints are reachable unauthenticated (see _allowedGet in
|
||||
/// IntranetController.cs), so an anonymous caller gets a generic message instead.
|
||||
/// </summary>
|
||||
internal static string PdfPreviewFailureMessage(Exception ex, bool includeDetail)
|
||||
{
|
||||
if (!includeDetail) return "Die PDF-Vorschau konnte nicht erstellt werden.";
|
||||
string detail = ex.GetBaseException().Message?.Trim() ?? "";
|
||||
return detail.Length == 0
|
||||
? "Die PDF-Vorschau konnte nicht erstellt werden."
|
||||
: $"Die PDF-Vorschau konnte nicht erstellt werden: {detail}";
|
||||
}
|
||||
}
|
||||
@@ -89,9 +89,11 @@ public partial class IntranetController
|
||||
_logger.LogWarning("Reminder rdoc: document not found for reminder {RemId} user={User}", Form("id"), UserAccountID);
|
||||
return StatusCode(404, new { error = "Dokument wurde nicht gefunden" });
|
||||
}
|
||||
return Form("typ") != "img"
|
||||
? await FileContentResultAsync(fc, file.MimeType(), file.Name)
|
||||
: await JSONAsync(new { id = Form("id"), img = await BuildPdfImageArray(fc) });
|
||||
if (Form("typ") != "img")
|
||||
return await FileContentResultAsync(fc, file.MimeType(), file.Name);
|
||||
string remId = Form("id");
|
||||
return await PdfPreviewAsync("rem/rdoc", remId, async () =>
|
||||
await JSONAsync(new { id = remId, img = await BuildPdfImageArray(fc) }));
|
||||
}
|
||||
|
||||
case "idoc": return await HandleReminderIdoc(fn, id, code);
|
||||
@@ -153,8 +155,11 @@ public partial class IntranetController
|
||||
{
|
||||
var remdoc = new Dictionary<string, byte[]>
|
||||
{ [frdic.nz("DocumentName", "").ne("Zahlungserinnerung.pdf")] = filebyte };
|
||||
// The invoice copy is optional: an empty varbinary (invoice file never stored) must
|
||||
// not attach a 0-byte file — the mailer refuses empty attachments and the whole
|
||||
// reminder would fail instead of going out without the copy.
|
||||
if (!string.IsNullOrEmpty(frdic.nz("InvoiceFileName")) &&
|
||||
frdic.no("InvoiceFile", null!) is byte[] invFile)
|
||||
frdic.no("InvoiceFile", null!) is byte[] { Length: > 0 } invFile)
|
||||
remdoc[frdic.nz("InvoiceFileName")] = invFile;
|
||||
|
||||
_logger.LogInformation("Reminder conf: emailing finalized reminder {RemId} to {Email} user={User}",
|
||||
@@ -206,13 +211,23 @@ public partial class IntranetController
|
||||
string filename = fdRem.ReminderRegistration!.nz("DocumentName").ne($"Zahlungserinnerung_{fdRem.Id}.pdf");
|
||||
if (Form("typ") != "img")
|
||||
{
|
||||
byte[] ct = Form("create", "0") != "1"
|
||||
byte[]? ct = Form("create", "0") != "1"
|
||||
? (await _reminders.GetReminderFileAsync(fdRem, fdRem.IsDraft, _mfr, UserAccountID, DbSec)) is { Length: > 0 } f1 ? f1 : await _reminders.StoreReminderDocumentFileAsync(fdRem, fdRem.IsDraft, UserAccountID, DbSec)
|
||||
: _pdf.DocToPdfBytes(_reminders.GenerateReminderPdf(fdRem, fdRem.IsDraft));
|
||||
// StoreReminderDocumentFileAsync signals failure with Array.Empty<byte>() — without this
|
||||
// guard a failed render/store would be served as a 0-byte "PDF".
|
||||
if (ct is not { Length: > 0 })
|
||||
{
|
||||
_logger.LogError("HandleReminderIdoc: no PDF bytes available for reminder {RemId} user={User}", fdRem.Id, UserAccountID);
|
||||
return await ReminderIssueResult("Die Mahnungs-PDF konnte aufgrund eines Fehlers nicht erstellt werden.", fdRem.Id);
|
||||
}
|
||||
return await FileContentResultAsync(ct, "application/pdf", filename, inline: true);
|
||||
}
|
||||
var imgcol = await _pdf.DocToImageCollectionAsync(_reminders.GenerateReminderPdf(fdRem, fdRem.IsDraft));
|
||||
return await JSONAsync(new { id = fdRem.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||
return await PdfPreviewAsync("rem/idoc", fdRem.Id, async () =>
|
||||
{
|
||||
var imgcol = await _pdf.DocToImageCollectionAsync(_reminders.GenerateReminderPdf(fdRem, fdRem.IsDraft));
|
||||
return await JSONAsync(new { id = fdRem.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<IActionResult> HandleReminderResend(string fn, string id, string code)
|
||||
@@ -235,8 +250,11 @@ public partial class IntranetController
|
||||
{
|
||||
var remdoc = new Dictionary<string, byte[]>
|
||||
{ [frdic.nz("DocumentName", "").ne("Zahlungserinnerung.pdf")] = filebyte };
|
||||
// The invoice copy is optional: an empty varbinary (invoice file never stored) must
|
||||
// not attach a 0-byte file — the mailer refuses empty attachments and the whole
|
||||
// reminder would fail instead of going out without the copy.
|
||||
if (!string.IsNullOrEmpty(frdic.nz("InvoiceFileName")) &&
|
||||
frdic.no("InvoiceFile", null!) is byte[] invFile)
|
||||
frdic.no("InvoiceFile", null!) is byte[] { Length: > 0 } invFile)
|
||||
remdoc[frdic.nz("InvoiceFileName")] = invFile;
|
||||
bool sent = await _comService.SendEmailAsync($"rem_{remId}",
|
||||
$"SanitärFuchs - {frdic.nz("subject").ne(frdic.nz("DocumentName"))}",
|
||||
|
||||
@@ -67,10 +67,14 @@ public partial class IntranetController
|
||||
private async Task<IActionResult> HandleReminderDraftPreview(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("token")) return BadRequest400();
|
||||
var doc = _reminderDrafts.RenderPreview(Form("token"));
|
||||
if (doc == null) return DraftGone();
|
||||
var imgcol = await _pdf.DocToImageCollectionAsync(doc);
|
||||
return await JSONAsync(new { img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||
string token = Form("token");
|
||||
return await PdfPreviewAsync("rem/dpreview", token, async () =>
|
||||
{
|
||||
var doc = _reminderDrafts.RenderPreview(token);
|
||||
if (doc == null) return DraftGone();
|
||||
var imgcol = await _pdf.DocToImageCollectionAsync(doc);
|
||||
return await JSONAsync(new { img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||
});
|
||||
}
|
||||
|
||||
// POST rem/dsave — { token } → { ok, remid }; flush cache→DB + business event + draftReady
|
||||
@@ -78,16 +82,14 @@ public partial class IntranetController
|
||||
{
|
||||
if (!HasForm("token")) return BadRequest400();
|
||||
string token = Form("token");
|
||||
var before = _reminderDrafts.Get(token);
|
||||
if (before == null) return DraftGone();
|
||||
bool existed = !string.IsNullOrEmpty(before.RemId);
|
||||
|
||||
var fdRem = await _reminderDrafts.FlushToDbAsync(token, UserAccountID, DbSec);
|
||||
if (fdRem == null) return DraftGone();
|
||||
// Create vs update is decided inside the flush (serialised per session) — see HandleDraftSave.
|
||||
var flush = await _reminderDrafts.FlushToDbAsync(token, UserAccountID, DbSec);
|
||||
if (flush == null) return DraftGone();
|
||||
var fdRem = flush.Data;
|
||||
if (string.IsNullOrEmpty(fdRem.Id))
|
||||
return await ReminderIssueResult("Der Zwischenstand konnte aufgrund eines Fehlers nicht gespeichert werden.");
|
||||
|
||||
await _events.ReminderDraftRegisteredAsync(fdRem, existed, UserAccountID);
|
||||
await _events.ReminderDraftRegisteredAsync(fdRem, flush.Updated, UserAccountID);
|
||||
var after = _reminderDrafts.Get(token);
|
||||
if (after != null) await _draftNotifier.SignalDraftReadyAsync(after.Token, after.Version);
|
||||
return await JSONAsync(new { ok = true, remid = fdRem.Id });
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Globalization;
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Observability;
|
||||
using Fuchs.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.SqlClient;
|
||||
@@ -302,14 +303,41 @@ public partial class IntranetController
|
||||
private async Task<IActionResult> HandleRequestSconf(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("id")) { _logger.LogWarning("HandleRequestSconf: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); }
|
||||
_logger.LogInformation("HandleRequestSconf: finalizing invoice {InvId} user={User}", Form("id"), UserAccountID);
|
||||
// Admin option: date the invoice to the last working day of the previous month (ADR 0032).
|
||||
// fds__setInvoiceFinal re-validates (fds_inv > 2, no later invoice) and throws otherwise.
|
||||
bool backdate = InvoiceDates.IsBackdateRequested(Form("backdate"));
|
||||
_logger.LogInformation("HandleRequestSconf: finalizing invoice {InvId} backdate={Backdate} user={User}", Form("id"), backdate, UserAccountID);
|
||||
var dt = await getSQLDatatable_async(
|
||||
"EXECUTE [dbo].[fds__setInvoiceFinal] @Id, @authuser;",
|
||||
"EXECUTE [dbo].[fds__setInvoiceFinal] @Id, @authuser, @backdate;",
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
StdParamlist(SQL_VarChar("@Id", Form("id"))),
|
||||
StdParamlist(SQL_VarChar("@Id", Form("id")), SQL_Bit("@backdate", backdate)),
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
if (!string.IsNullOrEmpty(dt.Exception))
|
||||
_logger.LogError("HandleRequestSconf: SQL error finalizing invoice {InvId}: {SqlError}, user={User}", Form("id"), dt.Exception, UserAccountID);
|
||||
_logger.LogError("HandleRequestSconf: SQL error {SqlErrorNumber} finalizing invoice {InvId}: {SqlError}, user={User}", dt.ExceptionCode, Form("id"), dt.Exception, UserAccountID);
|
||||
// Only SQL error 60001 is a backdate rejection. A 0-row result means the invoice is already
|
||||
// final/sent (double submit) — it must not be reported as "create it again without backdating".
|
||||
var outcome = InvoiceDates.ClassifyFinalizeResult(dt.Exception, dt.ExceptionCode, dt.Count);
|
||||
if (backdate)
|
||||
{
|
||||
FuchsTelemetry.InvoicesBackdated.Add(1, new KeyValuePair<string, object?>("outcome", InvoiceDates.OutcomeTag(outcome)));
|
||||
if (outcome == InvoiceFinalizeOutcome.Finalized)
|
||||
_logger.LogInformation(new EventId(0, "InvoiceBackdated"),
|
||||
"Invoice {InvId} finalized with backdated invoice date {DateFinalized} user={User}",
|
||||
Form("id"), dt.FirstRow.toObjectDictionary().nz("DateFinalized"), UserAccountID);
|
||||
}
|
||||
switch (outcome)
|
||||
{
|
||||
case InvoiceFinalizeOutcome.BackdateNotAllowed:
|
||||
return await InvoiceIssueResult(
|
||||
"Die Rechnung wurde nicht erstellt: Das Rechnungsdatum kann nicht mehr auf den Vormonat gesetzt werden (es wurde bereits eine Rechnung im neuen Monat erstellt). Bitte ohne Rückdatierung erneut erstellen.",
|
||||
Form("id"));
|
||||
case InvoiceFinalizeOutcome.AlreadyFinal:
|
||||
return await InvoiceIssueResult(
|
||||
"Die Rechnung ist bereits abgeschlossen oder versendet und wurde nicht erneut erstellt.",
|
||||
Form("id"));
|
||||
case InvoiceFinalizeOutcome.Error:
|
||||
return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht erstellt werden.", Form("id"));
|
||||
}
|
||||
var frdic = dt.FirstRow.toObjectDictionary();
|
||||
if (frdic.TryGetValue("IsFinal", out var isFinal) && isFinal is true)
|
||||
{
|
||||
@@ -357,7 +385,7 @@ public partial class IntranetController
|
||||
else if (filebyte.Length == 0)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Invoice PDF render returned 0 bytes — invoiceId={InvoiceId} user={User}",
|
||||
"Invoice PDF could not be rendered or stored (see preceding InvoiceService/SQL error) — invoiceId={InvoiceId} user={User}",
|
||||
invId, UserAccountID);
|
||||
await _events.InvoiceIssueAsync(
|
||||
$"Die Rechnungs-PDF {frdic.nz("DocumentName").ne($"Rechnung_{invId}.pdf")} konnte nicht erstellt werden.",
|
||||
@@ -387,7 +415,7 @@ public partial class IntranetController
|
||||
|
||||
private async Task<IActionResult> HandleRequestIdoc(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("id") || string.IsNullOrEmpty(Form("id"))) { _logger.LogWarning("HandleRequestIdoc: missing/empty form field 'id', user={User}", UserAccountID); return StatusCode(404); }
|
||||
if (!HasForm("id")) { _logger.LogWarning("HandleRequestIdoc: missing form field 'id', user={User}", UserAccountID); return StatusCode(404); }
|
||||
_logger.LogDebug("HandleRequestIdoc: invoiceId={InvId} typ={Typ} create={Create} user={User}", Form("id"), Form("typ"), Form("create", "0"), UserAccountID);
|
||||
var fdInv = await _invoices.LoadInvoiceAsync(Form("id"), UserAccountID, DbSec);
|
||||
if (string.IsNullOrEmpty(fdInv.Id)) { _logger.LogWarning("HandleRequestIdoc: invoice not found id={InvId} user={User}", Form("id"), UserAccountID); return StatusCode(404, new { error = "Rechnung wurde nicht gefunden" }); }
|
||||
@@ -397,16 +425,24 @@ public partial class IntranetController
|
||||
byte[]? ct = Form("create", "0") != "1"
|
||||
? await _invoices.GetInvoiceFileAsync(fdInv, fdInv.IsDraft, _mfr) is { Length: > 0 } f1 ? f1 : await _invoices.StoreInvoiceDocumentFileAsync(fdInv, fdInv.IsDraft, UserAccountID, DbSec)
|
||||
: _pdf.DocToPdfBytes(await _invoices.GenerateInvoicePdfAsync(fdInv, fdInv.IsDraft));
|
||||
if (ct == null)
|
||||
// StoreInvoiceDocumentFileAsync signals failure with Array.Empty<byte>(), not null — a
|
||||
// bare `ct == null` check never catches that and would silently serve a 0-byte "PDF".
|
||||
if (ct is not { Length: > 0 })
|
||||
{
|
||||
_logger.LogError("HandleRequestIdoc: no PDF bytes available for invoice {InvId} user={User}", fdInv.Id, UserAccountID);
|
||||
return await InvoiceIssueResult("Die Rechnungs-PDF konnte aufgrund eines Fehlers nicht erstellt werden.", fdInv.Id);
|
||||
}
|
||||
// Serve inline for the in-browser viewer, but carry the real DocumentName so the browser's
|
||||
// "download" uses "Rechnung R2026-0121.pdf" instead of the "idoc" endpoint segment. (The
|
||||
// OCORE FileContentResult helper drops the filename under ASP.NET Core, so set it here.)
|
||||
SetInlinePdfFilename(filename);
|
||||
return File(ct, "application/pdf");
|
||||
}
|
||||
var imgcol = await _pdf.DocToImageCollectionAsync(await _invoices.GenerateInvoicePdfAsync(fdInv, fdInv.IsDraft));
|
||||
return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||
return await PdfPreviewAsync("req/idoc", fdInv.Id, async () =>
|
||||
{
|
||||
var imgcol = await _pdf.DocToImageCollectionAsync(await _invoices.GenerateInvoicePdfAsync(fdInv, fdInv.IsDraft));
|
||||
return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<IActionResult> HandleRequestResend(string fn, string id, string code)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using static OCORE.web.mvc_helper_async;
|
||||
|
||||
namespace Fuchs.Controllers;
|
||||
|
||||
// Partial class: "Specialties" module — the admin page (URL /specialties, menu entry under
|
||||
// Administration) that surfaces the otherwise hidden maintenance actions as small card forms.
|
||||
//
|
||||
// This partial only answers the module's auth probe; the cards call the real action endpoints
|
||||
// directly (e.g. POST /do/admin/backupclearinvoicefile/<id> in IntranetController.Admin.cs),
|
||||
// each of which enforces the same fds_sys > 4 gate server-side. See
|
||||
// Docs/Concepts/admin-specialties.md.
|
||||
public partial class IntranetController
|
||||
{
|
||||
private async Task<IActionResult> Do_Process_Specialties(string fn, string id, string code)
|
||||
{
|
||||
_logger.LogDebug("Do_Process_Specialties action={Action} user={User}", id, UserAccountID);
|
||||
|
||||
int authLevel = await GetSystemAdminAuthAsync(fn, id, code);
|
||||
bool authorized = authLevel > AdminMinAuthExclusive;
|
||||
|
||||
if (id.Equals("auth", StringComparison.OrdinalIgnoreCase))
|
||||
return await JSONAsync(new { manage = authorized ? 1 : 0, level = authLevel });
|
||||
|
||||
if (!authorized)
|
||||
{
|
||||
_logger.LogWarning("Specialties access denied for user={User} (fds_sys={Level}) action={Action}",
|
||||
UserAccountID, authLevel, id);
|
||||
return Unauthorized401();
|
||||
}
|
||||
|
||||
_logger.LogWarning("Specialties: no handler for action={Action}, user={User}", id, UserAccountID);
|
||||
return BadRequest400();
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
private readonly IWidgetService _widgets;
|
||||
private readonly IReportService _reports;
|
||||
private readonly IInvoiceService _invoices;
|
||||
private readonly IInvoiceFileBackupService _invoiceFileBackup;
|
||||
private readonly IReminderService _reminders;
|
||||
private readonly IEventService _events;
|
||||
private readonly IInvoiceDraftService _invoiceDrafts;
|
||||
@@ -65,6 +66,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
IWidgetService widgets,
|
||||
IReportService reports,
|
||||
IInvoiceService invoices,
|
||||
IInvoiceFileBackupService invoiceFileBackup,
|
||||
IReminderService reminders,
|
||||
IEventService events,
|
||||
IInvoiceDraftService invoiceDrafts,
|
||||
@@ -82,6 +84,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
_widgets = widgets;
|
||||
_reports = reports;
|
||||
_invoices = invoices;
|
||||
_invoiceFileBackup = invoiceFileBackup;
|
||||
_reminders = reminders;
|
||||
_events = events;
|
||||
_invoiceDrafts = invoiceDrafts;
|
||||
@@ -123,7 +126,25 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
// ── Action helpers ────────────────────────────────────────────────────────
|
||||
protected IActionResult Unauthorized401() => StatusCode(401);
|
||||
protected IActionResult BadRequest400() => BadRequest();
|
||||
protected IActionResult ServerError(string msg = "") => StatusCode(500, new { error = msg });
|
||||
/// <summary>
|
||||
/// 500 with the message both in the JSON body and in the <c>x-ocms-desc</c> header, which is
|
||||
/// the only place the frontend's default failure handler (<c>$ocms.failure</c>) reads a
|
||||
/// description from — without it the user only ever saw an empty "Der Server hat einen Fehler
|
||||
/// gemeldet:" alert.
|
||||
/// </summary>
|
||||
protected IActionResult ServerError(string msg = "")
|
||||
{
|
||||
if (!string.IsNullOrEmpty(msg))
|
||||
Response.Headers["x-ocms-desc"] = EncodeResponseDescription(msg);
|
||||
return StatusCode(500, new { error = msg });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Header-safe (ASCII, percent-encoded) form of a user message; the client decodes it with
|
||||
/// <c>decodeURIComponent</c> after mapping <c>+</c> to a space, so spaces must be <c>%20</c>.
|
||||
/// </summary>
|
||||
internal static string EncodeResponseDescription(string msg) =>
|
||||
Uri.EscapeDataString((msg ?? "").Replace("\r", " ").Replace("\n", " ").Trim());
|
||||
|
||||
// ── Index (GET /) ─────────────────────────────────────────────────────────
|
||||
[AllowAnonymous]
|
||||
@@ -167,7 +188,9 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
"rep" => await Do_Process_Reports(fn, id, code),
|
||||
"bam" => await Do_Process_Bankings(fn, id, code),
|
||||
"admin" => await Do_Process_Admin(fn, id, code),
|
||||
"specialties" => await Do_Process_Specialties(fn, id, code),
|
||||
"usr" => await Do_Process_AdminUsers(fn, id, code),
|
||||
"usrgrp" => await Do_Process_AdminGroups(fn, id, code),
|
||||
"auth" => await HandleAuth(fn, id, code),
|
||||
"spwc" => await HandleSendPasswordCode(fn, id, code),
|
||||
"spw" => await HandleSendPassword(fn, id, code),
|
||||
@@ -186,8 +209,11 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unhandled exception in Do fn={Fn} id={Id} code={Code} user={User}",
|
||||
fn, id, code, UserAccountID);
|
||||
// EventId.Name keeps the exact CodeReference the DB log used before the generic SQL
|
||||
// sink existed (ADR 0024); formKeys is form field NAMES only, never values (PII/tokens).
|
||||
_logger.LogError(new EventId(0, $"Unhandled exception in Do {fn}/{id}"), ex,
|
||||
"Unhandled exception in Do fn={Fn} id={Id} code={Code} user={User} formKeys={FormKeys}",
|
||||
fn, id, code, UserAccountID, RequestFormKeys());
|
||||
// Standing rule: an exception that interrupts a user-initiated action must reach the
|
||||
// user as a notification, not only the log. This is the catch-all safety net for any
|
||||
// Do_Process_* action that throws without first publishing its own (more specific)
|
||||
@@ -201,10 +227,14 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
UserAccountID,
|
||||
new Dictionary<string, object?> { ["fn"] = fn });
|
||||
}
|
||||
return ServerError();
|
||||
return ServerError("Die Aktion konnte aufgrund eines unerwarteten Fehlers nicht abgeschlossen werden.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Form field names of the current request (never values — they may carry PII/tokens).</summary>
|
||||
private string[] RequestFormKeys() =>
|
||||
Request.HasFormContentType ? Request.Form.Keys.ToArray() : Array.Empty<string>();
|
||||
|
||||
// ── Auth helper ───────────────────────────────────────────────────────────
|
||||
private async Task<IActionResult> HandleAuth(string fn, string id, string code)
|
||||
{
|
||||
|
||||
@@ -196,8 +196,8 @@ Cookie-based authentication (`CookieAuthenticationDefaults`) with custom claims
|
||||
|
||||
> **Status: DONE.** The services below are implemented and DI-registered in
|
||||
> `Program.cs`. The original extraction rationale is retained for reference /
|
||||
> history. `FuchsFdsEmail` → `IComService` (ProcessWeb Mailer API, inline
|
||||
> base64 attachments), `FuchsWidgets` → `IWidgetService`, `FuchsPdf` →
|
||||
> history. `FuchsFdsEmail` → `IComService` (ProcessWeb Mailer API, multipart
|
||||
> attachments — see `Docs/Concepts/processweb-mailer-interface.md`), `FuchsWidgets` → `IWidgetService`, `FuchsPdf` →
|
||||
> `IPdfService`, `Banking` → `IBankingService` (now MT940 **and** CAMT),
|
||||
> `FdsInvoiceData`/`FdsReminderData` → `IInvoiceService`/`IReminderService`
|
||||
> (data classes are now pure POCOs), `FuchsReports` → `IReportService`
|
||||
@@ -446,3 +446,4 @@ public class MfrClientFactory : IMfrClientFactory, IDisposable
|
||||
|
||||
- The SQL schema lives in the **`Fuchs_Database`** SSDT project (source of truth). The backend is SQL-first: it calls stored procedures, table-valued types (e.g. `fds__tt__bankingtransactions`) and functions via OCORE ADO.NET helpers.
|
||||
- When changing a stored procedure's name/parameters or a table type, update both the SSDT project and the calling C# in the same change, and keep the MT940/CAMT banking column mappings aligned with `fds__tt__bankingtransactions`.
|
||||
- The SSDT project is the compile step for all T-SQL: it references the `master` system dacpac and treats T-SQL warnings as errors (`TreatTSqlWarningsAsErrors`, ADR 0036), so an unresolved or ambiguous reference fails `msbuild Fuchs_Database/FuchsDatabase.sqlproj` instead of surfacing at runtime.
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
status: Active
|
||||
lastUpdated: 2026-09-16
|
||||
applyTo:
|
||||
- "Fuchs/Controllers/IntranetController.AdminGroups.cs"
|
||||
- "Fuchs/js/intranet/modules/fis.usrgrp*.js"
|
||||
- "Fuchs/js/intranet/modules/fis.usrgrp.scss"
|
||||
- "Fuchs/js/intranet/fis_main_menu.js"
|
||||
- "Fuchs_Database/dbo/Functions/fis_admin_getUserGroupList.sql"
|
||||
- "Fuchs_Database/dbo/Stored Procedures/fis_admin_setUserGroup.sql"
|
||||
- "Fuchs_Database/dbo/Stored Procedures/fis_admin_delUserGroup.sql"
|
||||
relatedDecisions:
|
||||
- "0021-user-group-management.md"
|
||||
- "0014-admin-user-management.md"
|
||||
- "0015-split-admin-user-management-module.md"
|
||||
---
|
||||
|
||||
# Group Management module (`usrgrp`)
|
||||
|
||||
## Summary
|
||||
The **Gruppenverwaltung** module lets a privileged operator see every non-deleted usergroup,
|
||||
create a new one, rename it, and delete it. It's the catalog-management counterpart to the `usr`
|
||||
module's per-user "Gruppen" dialog, which toggles an individual user's membership in these groups
|
||||
but cannot create/rename/delete the groups themselves. Same sidemenu-visibility and server-side
|
||||
authorization gate as `usr`/`admin` (`fds_sys > 4`), own `fn=usrgrp`, own frontend bundle, own
|
||||
controller partial. See [0021](../Decisions/0021-user-group-management.md) for why this is a
|
||||
separate module rather than folded into `usr`.
|
||||
|
||||
## How it works
|
||||
|
||||
### Authorization (two independent axes — same as `usr`, read [0014](../Decisions/0014-admin-user-management.md) first)
|
||||
1. **Menu visibility + `fds_sys` gate** — `fis_main_menu.js#addAdminMenuButtonsIfAuthorized`
|
||||
checks `fds_sys > 4` once and, if authorized, adds the "Administration" sidemenu dropdown with
|
||||
three entries: Benutzerverwaltung (`init:usr`), Gruppenverwaltung (`init:usrgrp`) and
|
||||
Systemüberwachung (`init:admin`). Every `/do/usrgrp/*` endpoint re-checks `fds_sys > 4`
|
||||
server-side (`Do_Process_AdminGroups`, reusing `AdminMinAuthExclusive`/`GetSystemAdminAuthAsync`
|
||||
from `IntranetController.Admin.cs`) and 401s otherwise.
|
||||
2. **Separate SQL-layer gate** — every `fis_admin_*` proc/function called below independently
|
||||
checks the caller's `intranet_admin` setting via `fis_admin_getUserAuth(@authaccount) > 1`. An
|
||||
account with `fds_sys > 4` but no `intranet_admin` setting reaches every `usrgrp` endpoint but
|
||||
gets empty results / silent no-op writes — not an error. This also applies to
|
||||
`fis_admin_addGroupMember`/`remGroupMember` (used by the `usr` module's membership dialog,
|
||||
documented there) — all five group-related `fis_admin_*` write/read objects now carry this
|
||||
check as of [0021](../Decisions/0021-user-group-management.md), which closed a real gap where
|
||||
three of them had none.
|
||||
|
||||
### Request flow
|
||||
```
|
||||
click "Gruppenverwaltung" → $ocms.init('usrgrp')
|
||||
POST /do/usrgrp/auth → { manage, level } (manage>0 ⇒ authorized)
|
||||
load /web/fis.usrgrp.de.js + /web/fis.usrgrp.css
|
||||
$ocms.usrgrp.init2() → init3() → renderLst()
|
||||
POST /do/usrgrp/list → { groups: [...] } every non-deleted group
|
||||
base ribbon (eM) → "Anlegen" (always available)
|
||||
POST /do/usrgrp/create {name} → { ok, usergroup_id, name }
|
||||
row click → per-row ribbon (iMn): Umbenennen / Löschen
|
||||
POST /do/usrgrp/rename {usergroup_id, name} → { ok, usergroup_id, name }
|
||||
POST /do/usrgrp/delete {usergroup_id} → { ok }
|
||||
```
|
||||
|
||||
### Backend (`IntranetController.AdminGroups.cs`)
|
||||
- `list` → `fis_admin_getUserGroupList(@authaccount, NULL)`, all non-deleted groups with
|
||||
decrypted names.
|
||||
- `create`/`rename` → both call `fis_admin_setUserGroup(@authaccount, @usergroup_id, @name,
|
||||
@enc_key)`; `create` passes `@usergroup_id = NULL` (the proc generates a new id via
|
||||
`fis_fn_id`), `rename` passes the existing id.
|
||||
- `delete` → `fis_admin_delUserGroup(@authaccount, @usergroup_id)` (soft-delete: sets
|
||||
`UserDeleted`/`DateDeleted`). No check for remaining members — a deleted group's membership
|
||||
rows simply stop being returned by `fis_admin_getUserGroupMembership` (filtered on the group's
|
||||
own `DateDeleted`), see [0021](../Decisions/0021-user-group-management.md) Consequences.
|
||||
|
||||
### Frontend (`fis.usrgrp.js` + `fis.usrgrp_txt_de.js` + `fis.usrgrp.scss`)
|
||||
Same list+ribbon pattern as `fis.usr.js`: `renderLst` builds a single-column card-style row list;
|
||||
a row click toggles `.selected` and swaps the `#topbar` ribbon between the base ribbon (`eM`:
|
||||
"Anlegen") and the per-row actions (`iMn`: Umbenennen/Löschen). `createDlg`/`renameDlg` use
|
||||
`$ocms.dlgform`; `deleteGroup` follows the `confirm(...)`-before-destructive-action pattern (no
|
||||
dialog, nothing to collect beyond the confirmation).
|
||||
|
||||
## Key files
|
||||
- `Fuchs/Controllers/IntranetController.AdminGroups.cs` — `Do_Process_AdminGroups` dispatch.
|
||||
- `Fuchs/Controllers/IntranetController.Admin.cs` — shared `AdminMinAuthExclusive`/
|
||||
`GetSystemAdminAuthAsync`/`AdminJson` this module reuses.
|
||||
- `Fuchs/js/intranet/modules/fis.usrgrp.js`, `fis.usrgrp_txt_de.js`, `fis.usrgrp.scss` — the
|
||||
module, texts, styles (bundled via `bdlconfig.json`, `intranet:usrgrp` context).
|
||||
- `Fuchs/js/intranet/fis_main_menu.js` (`addAdminMenuButtonsIfAuthorized`) — sidemenu wiring.
|
||||
- `Fuchs_Database/dbo/Functions/fis_admin_getUserGroupList.sql` — read-side group query.
|
||||
- `Fuchs_Database/dbo/Stored Procedures/fis_admin_setUserGroup.sql`,
|
||||
`fis_admin_delUserGroup.sql` — write-side group procs.
|
||||
|
||||
## Related decisions
|
||||
- [0021 — User group management (new module, closed the missing intranet_admin gate)](../Decisions/0021-user-group-management.md)
|
||||
- [0014 — Admin user management (dual auth axis, original decisions)](../Decisions/0014-admin-user-management.md)
|
||||
- [0015 — Split Admin into separate Status/User-Management modules](../Decisions/0015-split-admin-user-management-module.md)
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
status: Active
|
||||
lastUpdated: 2026-09-17
|
||||
applyTo:
|
||||
- "Fuchs/Controllers/IntranetController.Specialties.cs"
|
||||
- "Fuchs/js/intranet/modules/fis.specialties*"
|
||||
- "Fuchs/js/intranet/fis_main_menu.js"
|
||||
- "Fuchs/js/intranet/fis_main_go.js"
|
||||
relatedDecisions:
|
||||
- "0027-admin-invoice-file-backup-and-clear.md"
|
||||
---
|
||||
|
||||
# Admin "Specialties" page (`/specialties`)
|
||||
|
||||
## Summary
|
||||
|
||||
An admin-only page that surfaces the otherwise hidden maintenance actions as
|
||||
cards, each a small form. It exists so support does not need SSMS or a
|
||||
hand-crafted POST for actions like resetting an invoice's stored PDF. It is
|
||||
a regular lazily loaded frontend module (`fn = specialties`), gated like the
|
||||
rest of the Administration menu (`fds_sys > 4`), and reachable both from the
|
||||
menu (Administration → Spezialfunktionen) and by opening the URL
|
||||
`/specialties` directly.
|
||||
|
||||
The menu entry is deliberately hidden: it only appears when the Administration
|
||||
dropdown is opened with **Shift** held (Shift+click, or Shift+Enter/Space on
|
||||
the focused toggle); a plain open hides it again
|
||||
(`$fis.bindShiftOnlyMenuItems` in `fis_main_menu.js`, driven by the item's
|
||||
`lclass: 'shift-only'`). This keeps the maintenance actions out of casual
|
||||
reach without adding another authorization level — the item is still part of
|
||||
the menu array, so the `/specialties` deep link is unaffected.
|
||||
|
||||
## How it works
|
||||
|
||||
- **Module load** follows the standard contract (see
|
||||
`admin-system-status.md`): the menu item `init:specialties` →
|
||||
`$ocms.init('specialties')` → `POST /do/specialties/auth` → when
|
||||
`manage > 0`, import `web/fis.specialties.de.js` + `fis.specialties.css`
|
||||
and call `init2`. `IntranetController.Specialties.cs` answers only that
|
||||
auth probe (same `GetSystemAdminAuthAsync` gate as the Admin module) and
|
||||
401s everything else.
|
||||
- **Deep link.** `IntranetController.Index` renders the same SPA shell for
|
||||
every `/{fn}` URL and ignores `fn`; there is no client router. `fis_main_go.js`
|
||||
therefore calls `$fis.openModuleFromPath()` once the admin menu has settled:
|
||||
it takes the first path segment and calls `$ocms.init(fn)` **only if the
|
||||
user's menu offers an `init:<fn>` item** (admin dropdown included). A
|
||||
non-admin opening `/specialties` sees the dashboard, as before; arbitrary
|
||||
paths never trigger a module load.
|
||||
- **Cards.** `fis.specialties.js` keeps an array `cards` of builder
|
||||
functions. Each card is `title + description + <form> + result box`;
|
||||
`$spc.submit(c, { button, confirm, run, report })` wires HTML5 validation,
|
||||
a `confirm()`, the `postXT` call (`run(values)` returns the request) and
|
||||
the inline result (`report(response)` → `{ ok, lines }`). The card calls
|
||||
the action's **real** endpoint — every such endpoint enforces the admin
|
||||
gate itself; the page adds no authority.
|
||||
- **Current cards**
|
||||
- *Rechnungsdatei zurücksetzen (Soft-Delete)* → `POST
|
||||
/do/admin/backupclearinvoicefile/<internal Id>` (ADR 0027). Shows the
|
||||
outcome and the blob backup URI, or the German text for the service's
|
||||
`reason` (`invoice_not_found`, `not_final`, `external`, `no_file`,
|
||||
`db_error`). An invoice without a stored file (`hasFile` from
|
||||
`fds__getInvoice`) is answered `no_file` before any blob copy, so
|
||||
repeated clicks leave no spare `backup_*` blobs.
|
||||
- **Adding a card:** append a builder to `cards`, add its texts to
|
||||
`fis.specialties_txt_de.js`, run `npx gulp min`. No server change is
|
||||
needed unless the action itself is new.
|
||||
- **Build:** bundle entries in `Fuchs/bdlconfig.json`
|
||||
(`intranet:specialties`); generated files under `wwwroot/web/`.
|
||||
|
||||
## Key files
|
||||
|
||||
- `Fuchs/Controllers/IntranetController.Specialties.cs` — auth probe.
|
||||
- `Fuchs/Controllers/IntranetController.cs` — `"specialties"` case in `Do`.
|
||||
- `Fuchs/js/intranet/modules/fis.specialties.js` / `_txt_de.js` / `.scss`.
|
||||
- `Fuchs/js/intranet/fis_main_menu.js` — menu item + `openModuleFromPath`.
|
||||
- `Fuchs/js/intranet/fis_main_go.js` — deep link after menu settles.
|
||||
- `Fuchs/js/intranet/fis_texts_gui_de.js` — `m_specialties` label.
|
||||
|
||||
## Related decisions
|
||||
|
||||
- [0027 — Admin invoice-file backup-and-clear action](../Decisions/0027-admin-invoice-file-backup-and-clear.md)
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: Active
|
||||
lastUpdated: 2026-09-09
|
||||
lastUpdated: 2026-09-15
|
||||
applyTo:
|
||||
- "Fuchs/Controllers/IntranetController.Admin.cs"
|
||||
- "Fuchs/Services/SystemStatusService.cs"
|
||||
@@ -32,7 +32,10 @@ Access is restricted to users whose `fds_sys` module authorization is greater th
|
||||
does it push a single "Administration" dropdown entry into `$ocms.ocmsmenu` — containing this
|
||||
module's "Systemüberwachung" (`init:admin`) item alongside `usr`'s "Benutzerverwaltung"
|
||||
(`init:usr`) item — and re-render `#mainmenu`. Users below the threshold never see the
|
||||
dropdown and never fetch either module's script.
|
||||
dropdown and never fetch either module's script. Within the dropdown, "Systemüberwachung"
|
||||
(like "Spezialfunktionen") is a `shift-only` item: it is listed only when the dropdown is
|
||||
opened with **Shift** held (`$fis.bindShiftOnlyMenuItems`); the `/admin` deep link is
|
||||
unaffected. This is a convenience/accident guard, not an authorization level.
|
||||
2. **Server-side gate** — every `/do/admin/*` endpoint resolves
|
||||
`fis_getModuleAuth('fds_sys', authuser)` and returns 401 unless it is > 4. The only
|
||||
exception is `admin/auth`, which returns `{ manage: 0 }` for unauthorized users so the
|
||||
@@ -47,7 +50,7 @@ click "Administration" → $ocms.init('admin')
|
||||
$ocms.admin.init2() → init3() renders the page
|
||||
POST /do/admin/status → { info, probes } full snapshot + all probes
|
||||
per-card "Aktualisieren" → POST /do/admin/probe/<component> → { probe }
|
||||
"Test-E-Mail senden" → POST /do/admin/testmail (to/subject/body) → { result }
|
||||
"Test-E-Mail senden" → POST /do/admin/testmail (to/subject/body/attachpdf) → { result }
|
||||
```
|
||||
|
||||
### Backend (`ISystemStatusService` / `SystemStatusService`, scoped)
|
||||
@@ -56,21 +59,58 @@ click "Administration" → $ocms.init('admin')
|
||||
**never the password**), email (mailer enabled/base-url/account/server-id/token-present +
|
||||
OverrideRecipient), blob (enabled/configured/containers), MFR (host/creds-present/sync),
|
||||
Key Vault (vault-uri/app-prefix/managed-secret-count/client-registered).
|
||||
- Probes (`database`, `keyvault`, `blob`, `mfr`) each return a `SystemProbeResult`
|
||||
- Probes (`database`, `email`, `keyvault`, `blob`, `mfr`) each return a `SystemProbeResult`
|
||||
(`status` = `ok`/`error`/`disabled`/`unconfigured`, `ok`, `message`, `detail`, `durationMs`,
|
||||
optional `metrics`). They never throw — failures are captured in the result. `database` runs
|
||||
`SELECT @@SERVERNAME, DB_NAME(), SUSER_SNAME()`; `keyvault` reads the first managed secret via
|
||||
the DI `SecretClient`; `blob` calls `IBlobStorageService.CheckConnectivityAsync` (account-info
|
||||
request **plus a per-container blob count** for the invoice/reminder containers, surfaced as
|
||||
`metrics` and totalled in the message); `mfr` calls `IMfrClientFactory.Create().GetEntities()`.
|
||||
The generic `metrics` (label/value pairs) is how a probe reports extra facts for display — the
|
||||
blob probe uses it for the file counts.
|
||||
- `SendTestEmailAsync(to, subject, body)` HTML-encodes the body and sends via `IComService`, so
|
||||
the `Fuchs:Email:OverrideRecipient` redirect applies exactly as for any other mail; the result
|
||||
reports the requested recipient and, when active, the override target.
|
||||
`SELECT @@SERVERNAME, DB_NAME(), SUSER_SNAME()`; `email` calls
|
||||
`IComService.VerifyCredentialsAsync` (GET `{BaseUrl}/api/mailer/verify` with the configured
|
||||
Basic-auth account/token — see the Mailer API contract) and only turns `ok` when the Mailer API
|
||||
itself confirms the credentials (`ok:true`); when `ProcessWebComSettings.ServerId` is configured
|
||||
it is sent along as `&serverid=...` so the API also confirms this server belongs to the account
|
||||
(an account/token that is valid but for a server the account doesn't own comes back `ok:false`
|
||||
with the mismatched `serverid` echoed, surfaced in `detail`); a configured-but-wrong token
|
||||
surfaces as `error`, never as a false-positive `ok` — the `mailerEnabled`/`tokenConfigured` flags
|
||||
in `GetInfo()` describe configuration only and were never proof the credentials actually work;
|
||||
`keyvault` reads
|
||||
the first managed secret via the DI `SecretClient`; `blob` calls
|
||||
`IBlobStorageService.CheckConnectivityAsync` (account-info request **plus a per-container blob
|
||||
count** for the invoice/reminder containers, surfaced as `metrics` and totalled in the message);
|
||||
`mfr` calls `IMfrClientFactory.Create().GetEntities()`. The generic `metrics` (label/value pairs)
|
||||
is how a probe reports extra facts for display — the blob probe uses it for the file counts.
|
||||
- `SendTestEmailAsync(to, subject, body, attachPdf)` HTML-encodes the body and sends via
|
||||
`IComService`, so the `Fuchs:Email:OverrideRecipient` redirect applies exactly as for any other
|
||||
mail; the result reports the requested recipient and, when active, the override target.
|
||||
With `attachPdf` (dialog select "PDF-Anhang mitsenden", default *Ja*; absent form value ⇒ on)
|
||||
a one-page letter PDF (`Fuchs-Intranet-Testmail.pdf`) is rendered in memory via
|
||||
`IPdfService.WriteLetterAsync` + `DocToPdfBytes` — standard Fuchs letterhead, the recipient in
|
||||
the address window, `Datum`/`Umgebung`/`Nummer=admin_test` in the admin block, the mail's own
|
||||
subject and body as the letter text; no DB or stored document is touched. This exercises the
|
||||
Mailer's multipart/attachment path, so per ADR 0028 `sent` is only `true` when the Mailer also
|
||||
confirmed the attachment (`attachmentsProcessed == 1`); the result's `attachmentName` says
|
||||
whether an attachment was part of the send. A PDF rendering failure is reported in the result
|
||||
(`sent=false`, message names the error) and nothing is sent.
|
||||
- Every probe emits the `fuchs.systemstatus.probes` counter tagged by component + status and an
|
||||
`systemstatus.probe` activity span.
|
||||
|
||||
### Cards on the page
|
||||
The page renders one non-probe system card, five refreshable connectivity cards (in this
|
||||
order), and the non-refreshable startup-checks card. UI labels are from `fis.admin_txt_de.js`.
|
||||
|
||||
| Card (label) | Shows | Data source |
|
||||
| --- | --- | --- |
|
||||
| System | Hostname, OS, .NET framework, Environment, Test-Deployment flag, Prozess-ID, Laufzeit/Start (UTC) | `GetInfo()` — `Environment`/`Process.GetCurrentProcess()`, no network call |
|
||||
| SQL-Datenbank (`database`) | Server, Datenbank, Login (never the password) | Config: `fuchs_fds_ConnectionString` parsed. Probe: `SELECT @@SERVERNAME, DB_NAME(), SUSER_SNAME()` |
|
||||
| E-Mail-Dienst (`email`) | Mailer aktiv, Base-URL, Account, Server-ID, Token hinterlegt, OverrideRecipient/Umleitung aktiv | Config: `ProcessWebComSettings` / `FuchsEmailSettings`. Probe: `IComService.VerifyCredentialsAsync` → `GET {BaseUrl}/api/mailer/verify` at ProcessWeb, `ok` only when the Mailer API confirms account (+ server-id when configured) |
|
||||
| Azure Blob Storage (`blob`) | Aktiviert, Konfiguriert, Rechnungs-/Mahnungs-Container, per-container file counts (Metrics) | Config: `AzureBlobStorageSettings` / `AzureBlobStorage_ConnectionString`. Probe: `IBlobStorageService.CheckConnectivityAsync` (account-info + blob count per container) |
|
||||
| MFR (ERP) (`mfr`) | Host, Zugangsdaten hinterlegt, Sync aktiv | Config: `Fds:MFR_host` / `Fds:MFR_UserName` / `Fds:SyncEnabled`. Probe: `IMfrClientFactory.Create().GetEntities()` |
|
||||
| Azure Key Vault (`keyvault`) | Vault-URI, App-Präfix, Verwaltete Secrets (count), Client registriert | Config: `SecretManagement:VaultUri`/`AppName`/`ManagedSecretKeys`, whether a `SecretClient` is DI-registered. Probe: reads the first key in `ManagedSecretKeys` via `SecretClient.GetSecretAsync` |
|
||||
| Startup-Prüfungen (non-refreshable) | OK/Fehler/übersprungen per check: Key Vault, SQL-Datenbank, MFR, Spire.PDF-Lizenz, E-Mail (Startup-Probe) | `StartupCheckReporter.Latest`, captured once by `StartupSelfTestService` at boot; see next section |
|
||||
|
||||
Each connectivity card also carries a colored status pill (`ok`/`error`/`disabled`/`unconfigured`)
|
||||
and a per-card "Aktualisieren" button (`POST /do/admin/probe/<component>`); the topbar "Alle
|
||||
prüfen" button re-runs all five in parallel via `POST /do/admin/status`. The E-Mail-Dienst card
|
||||
additionally hosts the test-email dialog (see `SendTestEmailAsync` below).
|
||||
|
||||
### Startup-checks widget (non-refreshable)
|
||||
`StartupSelfTestService` runs the boot self-test once (Key Vault / Database / MFR / PDF-license /
|
||||
mailer, gated by `Fuchs:StartupChecks:*`). It now also writes its outcome to the singleton
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: Active
|
||||
lastUpdated: 2026-09-14
|
||||
lastUpdated: 2026-09-16
|
||||
applyTo:
|
||||
- "Fuchs/Controllers/IntranetController.AdminUsers.cs"
|
||||
- "Fuchs/js/intranet/modules/fis.usr*.js"
|
||||
@@ -12,6 +12,7 @@ relatedDecisions:
|
||||
- "0014-admin-user-management.md"
|
||||
- "0015-split-admin-user-management-module.md"
|
||||
- "0020-user-management-create-and-delete.md"
|
||||
- "0021-user-group-management.md"
|
||||
---
|
||||
|
||||
# User Management module (`usr`)
|
||||
@@ -51,7 +52,8 @@ click "Benutzerverwaltung" → $ocms.init('usr')
|
||||
POST /do/usr/auth → { manage, level } (manage>0 ⇒ authorized)
|
||||
load /web/fis.usr.de.js + /web/fis.usr.css
|
||||
$ocms.usr.init2() → init3() → renderLst()
|
||||
POST /do/usr/list → { users: [...] } grid of every non-deleted account
|
||||
POST /do/usr/list → { users: [...] } grid of every non-deleted account (incl. `groups` csv)
|
||||
POST /do/usr/list {useraccount_id} → { users: [one] } single-row refresh (refreshRow)
|
||||
base ribbon (eM) → "Neuer Benutzer" (always available, not just when a row is selected)
|
||||
POST /do/usr/create {name, firstname, email, title?, gender?, mobile?} → { ok, useraccount_id, emailed } | 409 duplicate_email
|
||||
row click → per-row ribbon (iMn): Passwort zurücksetzen / E-Mail ändern / Gruppenzugehörigkeit / Benutzer löschen
|
||||
@@ -63,8 +65,12 @@ click "Benutzerverwaltung" → $ocms.init('usr')
|
||||
```
|
||||
|
||||
### Backend (`IntranetController.AdminUsers.cs`)
|
||||
- `list` → `fis_admin_getUserAccountList(@authaccount, NULL, NULL, NULL)`, all non-deleted
|
||||
accounts with decrypted name/email/disabled flag.
|
||||
- `list` → `fis_admin_getUserAccountList(@authaccount, @useraccount_id, NULL, NULL)`, all non-deleted
|
||||
accounts with decrypted name/email/disabled flag plus a `groups` column — a `', '`-separated,
|
||||
alphabetically ordered csv of the account's active group memberships (`STRING_AGG` over
|
||||
decrypted `fis_usergroups.name_enc`, deleted groups/memberships excluded). An optional
|
||||
`useraccount_id` form field narrows the result to one account so the frontend can refresh a
|
||||
single row without re-rendering the grid.
|
||||
- `pwdreset` → the admin may type a specific password or leave it blank; validated with a plain
|
||||
`ValidatePassword(minLength: 6, numUpper: 0, numLower: 0, numNumbers: 0, numSpecial: 0)` — no
|
||||
hidden composition requirements, matching what the UI actually tells the admin (a prior bug
|
||||
@@ -82,7 +88,9 @@ click "Benutzerverwaltung" → $ocms.init('usr')
|
||||
changed (`@password = NULL`, so the `WHEN MATCHED` update path leaves `password_enc` alone). A
|
||||
duplicate email (`THROW 60001` in the proc) surfaces as HTTP 409 `{ error: "duplicate_email" }`.
|
||||
- `groups` → the new `fis_admin_getUserGroupMembership(@authaccount, @useraccount_id)` function
|
||||
returns every group plus a `member` bit for that one user.
|
||||
returns every group plus a `member` bit for that one user. The group catalog itself (create/
|
||||
rename/delete) is managed in the separate `usrgrp` module — see
|
||||
[admin-group-management.md](admin-group-management.md).
|
||||
- `groupset` → calls `fis_admin_addGroupMember`/`remGroupMember` depending on the posted
|
||||
`member` flag — applied immediately per checkbox toggle, no separate save step.
|
||||
- `create` → `fis_admin_createUserAccount(@authaccount, @name, @firstname, @title, @gender,
|
||||
@@ -101,7 +109,11 @@ click "Benutzerverwaltung" → $ocms.init('usr')
|
||||
### Frontend (`fis.usr.js` + `fis.usr_txt_de.js` + `fis.usr.scss`)
|
||||
A self-contained list+ribbon module modeled on Banking (`fis.bam.js`)'s `eM`/`iMn` pattern
|
||||
rather than the Admin dashboard's card layout: `renderLst` builds a card-style row grid (avatar
|
||||
initials badge, name/email columns, a status pill) rather than a plain `<table>`; a row click
|
||||
initials badge, name/email/groups columns, a status pill) rather than a plain `<table>`, with
|
||||
each row produced by `buildRow(rw)`. Changes that only affect one account (`emailDlg`, every
|
||||
checkbox toggle in `groupsDlg`) call `refreshRow(rw)`, which re-posts `usr/list` with that
|
||||
`useraccount_id` and swaps the row in place — selection and the per-row ribbon survive, and the
|
||||
groups csv is always current. Create/delete/password-reset still do a full `renderLst`. A row click
|
||||
toggles `.selected` and swaps the `#topbar` ribbon between the base ribbon (`eM`: "Neuer
|
||||
Benutzer" always available, plus "Neu Laden" once a row was selected) and the four per-row
|
||||
actions (`iMn`: Passwort zurücksetzen / E-Mail ändern / Gruppenzugehörigkeit / Benutzer löschen).
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
---
|
||||
status: Active
|
||||
lastUpdated: 2026-09-16
|
||||
applyTo:
|
||||
- "Fuchs/Program.cs"
|
||||
- "Fuchs/Logging/**"
|
||||
- "Fuchs/code/FuchsIntranet.cs"
|
||||
- "Fuchs/Controllers/**"
|
||||
- "Fuchs/Observability/FuchsTelemetry.cs"
|
||||
relatedDecisions:
|
||||
- "0003-structured-diagnostic-logging.md"
|
||||
- "0003-interrupting-exceptions-notify-the-user.md"
|
||||
- "0004-opentelemetry-observability.md"
|
||||
- "0023-user-action-failures-persisted-to-db-log.md"
|
||||
- "0024-multilogger-sql-log-sink.md"
|
||||
---
|
||||
|
||||
# Error logging pipeline
|
||||
|
||||
## Summary
|
||||
A `_logger.LogWarning`/`-Error`/`-Critical` call anywhere in the app — or in OCORE code called
|
||||
from it — reaches three places automatically: the always-on file sink, a DB row in
|
||||
`[dbo].[fds__debuglog]` (what support actually queries), and, for a user-initiated action that
|
||||
threw unhandled, a SignalR toast to the user. One `ILogger` call is the single thing a call site
|
||||
writes; nothing calls a stored procedure directly for attribution logging.
|
||||
|
||||
## How it works
|
||||
|
||||
### The two `ILoggerProvider`s
|
||||
- **`FuchsLoggerProvider`** (`Fuchs/Logging/FuchsLoggerProvider.cs`, [0003](0003-structured-diagnostic-logging.md)):
|
||||
every level ≥ Debug → `Debug.WriteLine` + a rolling-free text file (`logs/AppLog.txt` /
|
||||
`logs/ErrorLog.txt`). Always on, no DB dependency.
|
||||
- **`OCORE.MultiLogger.SqlLoggerProvider`** ([0024](0024-multilogger-sql-log-sink.md)): registered
|
||||
in `Program.cs` via `builder.Logging.AddSqlLogger()`, gated by
|
||||
`Fuchs:Logging:SqlSink:Enabled` (default on), filtered to `LogLevel.Warning`+. Writes via
|
||||
`EXECUTE [dbo].[fds__admin_logdebug_ext]` into `[dbo].[fds__debuglog]` — the same table the
|
||||
legacy, now-unused `fds__admin_logdebug` proc wrote to.
|
||||
|
||||
### Attribution: EventId, category, scope
|
||||
- **`CodeReference`** (the row's short label) is `COALESCE(EventId.Name, ILogger category,
|
||||
'MultiLogger')`, computed in the stored procedure. Give a log call an explicit `EventId` when
|
||||
the call site should be individually greppable:
|
||||
```csharp
|
||||
_logger.LogError(new EventId(0, "SQL Error in getSQLDataSet"), ex, "SQL error in {Procedure}: ...", ...);
|
||||
```
|
||||
See `FIS_SQLOptions.OnError`, `IntranetController.Do`'s catch-all, `PdfPreviewAsync`, and the
|
||||
`bam/up` (banking upload) handlers for the pattern — each preserves the exact string the old,
|
||||
explicit `debug_log()` calls used, so existing saved queries over `CodeReference` still match.
|
||||
- **`RequestLogScopeMiddleware`** (`Fuchs/Logging/RequestLogScopeMiddleware.cs`, registered after
|
||||
`app.UseAuthentication()`) pushes `{requestId, path, authuser}` as an `ILogger` scope for the
|
||||
whole request. Every log line written while handling it — including ones written deep inside
|
||||
OCORE's SQL helpers — carries that automatically, without the call site doing anything.
|
||||
`authuser` specifically lands in the dedicated `fds__debuglog.AuthUser` column (OCORE's
|
||||
`MultiLogger_base.Log` picks the `"authuser"` scope key out into `LogEntry.AuthUser`); the rest
|
||||
of the scope + any `{Placeholder}` args + an explicit `data:` object are merged into one redacted
|
||||
JSON blob (`fds__debuglog.Data`).
|
||||
- **No request/service-scoped logger available** (`new FIS_SQLOptions()` — ~15 call sites in
|
||||
`Services/*.cs` construct it this way): `FIS_SQLOptions.OnError` falls back to the static
|
||||
`OCORE.Logging.Logger` (set once in `Program.cs` to `app.Logger`), so those SQL errors are still
|
||||
attributed, just under a less specific category.
|
||||
|
||||
### Placeholder naming
|
||||
Always `{Foo}`, never `{@Foo}`. Plain `Microsoft.Extensions.Logging` (not Serilog) does not treat
|
||||
`@` as a destructuring operator — it stays a literal leading character of the resulting property
|
||||
name (`"@Foo"`), which is confusing in the serialized `Data` column.
|
||||
|
||||
### Redaction and cost
|
||||
`OCORE.MultiLogger.LogRedaction.SerializeRedacted` masks property names matching known-sensitive
|
||||
fragments/segments (`password`, `secret`, `token`, `pin`, `otp` as a whole identifier segment —
|
||||
not substrings like `Mapping`/`HotPath`) at any nesting depth, degrades one unserializable context
|
||||
entry to its `ToString()` instead of losing the whole payload, and tolerates self-referencing
|
||||
graphs. `LogEntry.dataSerialized` is computed lazily — at most once, only if a sink actually reads
|
||||
it — so a Warning+ call that no sink is currently listening for costs nothing extra to serialize.
|
||||
|
||||
### User-visible failure surface (unrelated to the DB write, [0023](0023-user-action-failures-persisted-to-db-log.md))
|
||||
- `IntranetController.Do`'s catch-all publishes a SignalR toast (`IEventService.UserIssueAsync`)
|
||||
for any unmodeled exception, for authenticated users only.
|
||||
- A 500 response carries its message in the `x-ocms-desc` header (`ServerError(msg)` →
|
||||
`EncodeResponseDescription`), which the frontend's default failure handler
|
||||
(`$ocms.failure`/`oci_basic.js`) reads.
|
||||
- PDF-preview endpoints go through the shared `PdfPreviewAsync` guard: logs, counts
|
||||
`fuchs.pdf.previews{what,outcome}` (`success`/`gone`/`failure` — an expired-draft `410` is its
|
||||
own `gone` outcome, not a render failure), toasts an authenticated caller, and returns a
|
||||
message with the innermost exception text (omitted for the handful of preview endpoints
|
||||
reachable without authentication — see `_allowedGet` in `IntranetController.cs`).
|
||||
|
||||
## Configuration
|
||||
- `Fuchs:Logging:SqlSink:Enabled` (bool, default `true`) — off switch for the SQL sink without a
|
||||
redeploy.
|
||||
- SQL sink connects via `fuchs_fds_ConnectionString` (same connection as everything else), proc
|
||||
`dbo.fds__admin_logdebug_ext`, `TruncateTextAt = 4000`.
|
||||
|
||||
## Extending this
|
||||
- Adding a new sink (email/SMS on Critical, a rolling file replacing `FuchsLoggerProvider`) is a
|
||||
registration in `Program.cs` — `OCORE.MultiLogger` already ships `EmailLoggerOptions`/
|
||||
`SMSLoggerOptions`/`FileLoggerProvider`.
|
||||
- A new place that needs DB attribution just calls `_logger.LogWarning`/`-Error` with an
|
||||
`EventId` if it wants a specific `CodeReference` — never add a second, explicit stored-procedure
|
||||
call next to it; that duplicates the row once the SQL sink picks the `ILogger` call up.
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: Active
|
||||
lastUpdated: 2026-09-09
|
||||
lastUpdated: 2026-10-01
|
||||
applyTo:
|
||||
- "Fuchs/Services/InvoiceDraft*"
|
||||
- "Fuchs/Services/IInvoiceDraft*"
|
||||
@@ -99,6 +99,21 @@ Expiry: Server (timer) --SignalR draftExpiring{token,secondsLeft}--> warn "bit
|
||||
(`RecomputeLevels`), rendered as an indent class in the editor and a per-level indent in the
|
||||
PDF. `item.setprice` sums a header's **transitive** descendants. Structure deltas never
|
||||
change values — only order, membership, levels and therefore position numbers.
|
||||
- **Concurrency.** The editor fires patches, state fetches, previews and saves for one token
|
||||
concurrently, and the session payload (`JObject`/`JArray`, the history list) is not thread-safe.
|
||||
Every read and write of the payload runs under the session's `SyncRoot`, and everything the
|
||||
controller serialises afterwards (`BuildState`, `GetHistory`, the `FdsInvoiceData` a preview or
|
||||
flush uses) is a deep-cloned snapshot taken under that lock — never a live reference. Flushes are
|
||||
additionally serialised per session on `FlushGate` (a `SemaphoreSlim`, awaited — `SyncRoot`
|
||||
cannot be held across the registration's `await`): the create/update decision in
|
||||
`FlushToDbAsync` is read-then-write across that await, so two overlapping saves of a
|
||||
not-yet-registered draft (double-click on *Zwischenspeichern*, or *Zwischenspeichern* followed
|
||||
by preview → confirm) would otherwise both call `fds__createInvoice` and leave an orphaned
|
||||
invoice. The waiting save snapshots **after** it acquires the gate, so it persists the newer
|
||||
state as an update of the row the first save created. `FlushToDbAsync` reports that decision
|
||||
(`DraftFlushResult.Updated`) and the controller publishes `InvoiceDraftCreated`/`-Updated` from
|
||||
it rather than from the session state it saw before the flush. Patches are never blocked by a
|
||||
running save. `DraftSessionConcurrencyTests` pins both the lock and the gate.
|
||||
- **Cache** (`InvoiceDraftCache`, singleton) stores sessions by token with an idle sliding
|
||||
TTL; `InvoiceDraftExpiryService` (a `BackgroundService`) warns before, and evicts after,
|
||||
the TTL. TTL/warn-lead are configurable under `Fuchs:DraftEditing`.
|
||||
@@ -146,9 +161,12 @@ amount pair.
|
||||
- **Orchestration** (`ReminderDraftEditService`, scoped) flushes to the DB by reusing
|
||||
`IReminderService.RegisterReminderAsync`, and renders previews from a synthesised
|
||||
`ReminderRegistration` (including the single-invoice `invoices` row the reminder PDF table
|
||||
renders) so a preview needs no DB round-trip. **Note:** `RegisterReminderAsync` is create-only
|
||||
(there is no `fds__setReminder` update proc), so a re-saved reminder draft does not update the
|
||||
prior DB row — the primary flow (preview → confirm) flushes once immediately before finalising.
|
||||
renders) so a preview needs no DB round-trip. A first save runs `fds__createReminder`; every
|
||||
later save of the same session runs `fds__setReminder`, which updates the row in place (version
|
||||
bump, same result shape) and refuses — `@Id` NULL → the batch throws → "nicht gespeichert" — once
|
||||
the reminder is finalised or has a stored file. Flushes are serialised on the session's
|
||||
`FlushGate` exactly like invoice flushes (see *Concurrency* above), so overlapping saves cannot
|
||||
create two reminder rows either.
|
||||
- **Cache/expiry** (`ReminderDraftCache` singleton + `ReminderDraftExpiryService`) mirror the
|
||||
invoice ones and share the same `Fuchs:DraftEditing` TTL config.
|
||||
- **Signals** reuse the shared `DraftPreviewHub` + `IDraftNotifier` unchanged — the token-keyed
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
---
|
||||
status: Active
|
||||
lastUpdated: 2026-09-17
|
||||
applyTo:
|
||||
- "Fuchs/Services/IComService.cs"
|
||||
- "Fuchs/Services/ProcessWebComService.cs"
|
||||
- "Fuchs/Services/ProcessWebComSettings.cs"
|
||||
- "Fuchs/Services/FuchsMailerClient.cs"
|
||||
- "Fuchs/Services/MailerAttachmentPolicy.cs"
|
||||
- "Fuchs.Tests/ProcessWebComServiceTests.cs"
|
||||
- "Fuchs.Tests/FuchsMailerClientTests.cs"
|
||||
relatedDecisions:
|
||||
- "0028-mailer-push-com-multipart-and-attachment-confirmation.md"
|
||||
- "0029-outbound-mail-via-ocore-pwmailer-client.md"
|
||||
---
|
||||
|
||||
# ProcessWeb Mailer interface (outbound email/SMS)
|
||||
|
||||
## Summary
|
||||
|
||||
All outbound email and SMS leaves Fuchs through `IComService` →
|
||||
`ProcessWebComService`, which is a thin adapter over the OCORE_web
|
||||
**`IPWMailerClientService`** (`OCORE_web/comm/pwmailer/`). The client owns
|
||||
the HTTP contract of the ProcessWeb Mailer API; Fuchs owns what is specific
|
||||
to Fuchs — the recipient-override safety net, the signature, the attachment
|
||||
limits, the `fds__emaillog` audit row and telemetry.
|
||||
|
||||
The authoritative, server-side contract is the Mailer's own function catalog:
|
||||
|
||||
> **https://processweb.de/api/mailer/fc** — subject to change as features are
|
||||
> added, but kept backward compatible. The OCORE_web client is kept in sync
|
||||
> with it (its header says so and names the server-side files); read both
|
||||
> before changing anything here.
|
||||
|
||||
## How it works
|
||||
|
||||
### Layering
|
||||
|
||||
| Layer | File | Responsibility |
|
||||
|-------|------|----------------|
|
||||
| Adapter | `Fuchs/Services/ProcessWebComService.cs` | `IComService`: validate recipient, apply `Fuchs:Email:OverrideRecipient`, append signature, build a `PWMailerMessage`, interpret the `PWMailerResult`, write `fds__emaillog`, emit metrics |
|
||||
| Fuchs client | `Fuchs/Services/FuchsMailerClient.cs` | `PWMailerClientService` subclass; overrides `ValidateMessage` to add the attachment limits (`MailerAttachmentPolicy`) on top of the contract's base rules |
|
||||
| OCORE client | `OCORE_web/comm/pwmailer/PWMailerClientService*.cs` | endpoints, envelope, JSON vs multipart (unnamed envelope field + one file part per attachment), `attachmentCount`, Basic auth, token resolution, `x-pcw-excd` error headers, retries/timeouts, typed results |
|
||||
|
||||
### Configuration
|
||||
|
||||
`Fuchs:Mailer` (`ProcessWebComSettings`) stays the single config section. In
|
||||
`Program.cs` it is mapped onto `PWMailerClientOptions`:
|
||||
|
||||
- `BaseUrl` (`https://processweb.de`) + `/api/mailer` → `PWMailerClientOptions.BaseUrl`
|
||||
- `AccountId` → `Account`, `ServerId` → `ServerId`, `Token` → `Token`
|
||||
- `TokenSecretName = "Fuchs--Mailer--Token"` — the same Key Vault secret
|
||||
OCORE secret management already maps into `Fuchs:Mailer:Token`. In
|
||||
production the configuration value is therefore already the plain token;
|
||||
the name only matters when the `MANAGED_BY_KEYVAULT` placeholder is still
|
||||
in place (e.g. locally), where the client loads it on demand.
|
||||
- `Attachments` (`MaxFileBytes` 10 MB, `MaxMessageBytes` 20 MB, `MaxCount`
|
||||
10 — the contract defaults) feed `FuchsMailerClient`'s pre-validation.
|
||||
|
||||
Registration: `AddPWMailerClient<FuchsMailerClient>()` (singleton, named
|
||||
`HttpClient` `PWMailerClient`). `ProcessWebComService` stays scoped.
|
||||
|
||||
### Sending an email
|
||||
|
||||
1. `SendEmailAsync` validates the address, applies the override, appends the
|
||||
signature, and builds `PWMailerMessage { To, Cc, Subject, Body, Reference,
|
||||
ComType = "e", Attachments }`. `AttachmentCount` is left `null`, so the
|
||||
client sends the real count and the Mailer cross-checks it (400 on
|
||||
mismatch).
|
||||
2. `FuchsMailerClient.ValidateMessage` runs *inside* `PushAsync` before any
|
||||
bytes go out: base rules (server id, recipient, subject, body, empty file,
|
||||
missing extension) plus Fuchs limits (count, per-file size, total size,
|
||||
forbidden extensions — `MailerAttachmentPolicy.ForbiddenExtensions` is the
|
||||
contract's quoted subset; the server's list is longer and authoritative).
|
||||
A violation returns a result with `StatusCode 0`, `Attempts 0` and the
|
||||
`"; "`-joined rule messages as `Error`.
|
||||
3. The client posts JSON (no attachments) or multipart (with) to
|
||||
`POST /api/mailer/push_com`, never retrying a push.
|
||||
4. The adapter interprets the `PWMailerResult<PWMailerPushReceipt>`:
|
||||
|
||||
| Result | `SendEmailAsync` | audit `fds__emaillog` | `fuchs.emails.failed{reason}` |
|
||||
|--------|------------------|-----------------------|-------------------------------|
|
||||
| `Success` and (no attachments or `AttachmentsProcessed == sent`) | `true` | `guid = comStackID` | — (`fuchs.emails.sent`) |
|
||||
| `Success` but `AttachmentsProcessed != sent` | `false` | counts in log, `guid = comStackID` | `attachments-mismatch` |
|
||||
| `IsConflict` (409: identical message still pending at the Mailer — it will be sent, nothing new queued) | `true` | "Duplicate of a still-pending entry (409) …" | — (`fuchs.emails.sent`) |
|
||||
| `StatusCode 0`, `Attempts 0`, `Exception is ArgumentException` (refused by pre-validation) | `false` | one line per rule | `validation` |
|
||||
| `StatusCode 0`, `Attempts 0`, no exception (options unusable / token not resolvable, e.g. Key Vault outage) | `false` | `Mailer client not usable: …` (Error log) | `client-config` |
|
||||
| any other failure | `false` | `API error {status}: {error}` | `api-error` |
|
||||
| client threw | `false` | exception detail | `api-error` |
|
||||
|
||||
`reference` (`inv_<Id>` etc.) is the audit key and part of the Mailer's
|
||||
idempotency key (server + reference + content, while the first entry is still
|
||||
pending). A resend of the identical mail within that window answers 409 and
|
||||
is reported as **success** — the first entry is delivered; nothing is sent
|
||||
twice. A resend after delivery started is a new entry. Callers keep reusing
|
||||
`inv_<Id>` (e.g. `req/resend`); no per-attempt reference is needed.
|
||||
|
||||
### SMS and verification
|
||||
|
||||
- `SendSmsAsync` pushes `PWMailerMessage { ComType = "s", To = mobile, Body }`
|
||||
— the base validation requires no subject for `comType "s"`.
|
||||
- `VerifyCredentialsAsync` → `IPWMailerClientService.VerifyAsync(serverId)`
|
||||
(`GET /api/mailer/verify[?serverid=…]`), mapped to
|
||||
`MailerCredentialsCheckResult` for the Admin system-status view.
|
||||
|
||||
### Observability
|
||||
|
||||
`fuchs.emails.sent` / `fuchs.emails.failed{reason}` with reasons
|
||||
`invalid-email`, `disabled`, `validation`, `client-config`,
|
||||
`attachments-mismatch`, `api-error`; `fuchs.email.send.duration{success}`. The OCORE
|
||||
client additionally logs every failed call at Warning and exposes
|
||||
`RequestSending`/`ResponseReceived`/`CallCompleted`/`CallFailed` events (not
|
||||
subscribed by Fuchs today).
|
||||
|
||||
## Key files
|
||||
|
||||
- `Fuchs/Services/IComService.cs` — interface + `MailerCredentialsCheckResult`.
|
||||
- `Fuchs/Services/ProcessWebComService.cs` — the adapter.
|
||||
- `Fuchs/Services/FuchsMailerClient.cs` — `ValidateMessage` override.
|
||||
- `Fuchs/Services/MailerAttachmentPolicy.cs` — limit rules (pure).
|
||||
- `Fuchs/Services/ProcessWebComSettings.cs` — `Fuchs:Mailer` incl. `Attachments`.
|
||||
- `Fuchs/Program.cs` — options mapping + `AddPWMailerClient<FuchsMailerClient>()`.
|
||||
- `OCORE_web/OCORE_web/comm/pwmailer/` — the client (submodule).
|
||||
- `Fuchs.Tests/ProcessWebComServiceTests.cs`, `Fuchs.Tests/FuchsMailerClientTests.cs`;
|
||||
the wire format is tested in `OCORE_web/OCORE_webTests/comm/PWMailerClientServiceTests.cs`.
|
||||
|
||||
## Related decisions
|
||||
|
||||
- [0028 — push_com multipart attachments and attachment confirmation](../Decisions/0028-mailer-push-com-multipart-and-attachment-confirmation.md)
|
||||
- [0029 — Outbound mail via the OCORE_web PWMailer client](../Decisions/0029-outbound-mail-via-ocore-pwmailer-client.md)
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
status: Active
|
||||
lastUpdated: 2026-09-18
|
||||
applyTo:
|
||||
- "Fuchs/Program.cs"
|
||||
- "Fuchs/appsettings*.json"
|
||||
- "OCORE/OCORE/Secrets/**"
|
||||
- "OCORE_web/OCORE_web/Secrets/**"
|
||||
relatedDecisions:
|
||||
- "0031-shared-keyvault-dpapi-secret-management.md"
|
||||
---
|
||||
|
||||
# Secret management (Key Vault + DPAPI)
|
||||
|
||||
## Summary
|
||||
Fuchs loads its secrets (connection strings, API keys, mailer tokens, ...)
|
||||
from the shared Azure Key Vault `pcwkeys` at startup, with a DPAPI-encrypted
|
||||
local cache as an offline fallback. The mechanism is shared, host-type-agnostic
|
||||
infrastructure owned by the `OCORE`/`OCORE_web` submodules ([0031](../Decisions/0031-shared-keyvault-dpapi-secret-management.md));
|
||||
this doc covers how Fuchs specifically wires into it and where to make
|
||||
changes. The full mechanism reference (naming/encoding rules, DI wiring,
|
||||
cache format) is maintained independently in
|
||||
`OCORE_web/.github_instructions/secretsmanagement.instructions.md`.
|
||||
|
||||
## How it works
|
||||
1. `Fuchs/Program.cs` calls `builder.AddSecretManagement()`
|
||||
(`OCORE_web.Secrets.SecretManagementWebExtensions`) as the first step in
|
||||
`ConfigureServices`, before `FuchsOcmsIntranet.Initialize(configuration)`
|
||||
or anything else reads `IConfiguration`.
|
||||
2. That call, backed by `OCORE.Secrets.Extensions.SecretManagementExtensions`,
|
||||
registers two configuration sources in order: the local DPAPI cache
|
||||
(`secrets.cache`, offline fallback) first, then the live Azure Key Vault
|
||||
(overrides the cache when reachable) using `PrefixKeyVaultSecretManager`
|
||||
to filter to secrets named `fuchs--...` and map them onto
|
||||
`IConfiguration` keys.
|
||||
3. `KeyVaultSyncService` (a registered `BackgroundService`) periodically
|
||||
re-pulls every key listed in `Fuchs:SecretManagement:ManagedSecretKeys`
|
||||
from Key Vault, re-writes the DPAPI cache, and calls
|
||||
`IConfigurationRoot.Reload()` so a running process picks up rotated
|
||||
secrets without a restart.
|
||||
4. A secret whose name is only known at runtime (not a fixed
|
||||
`IConfiguration` key) — e.g. resolved from a DB job record — is instead
|
||||
fetched directly via `OCORE.Secrets.Extensions.SecretsOnDemandLoader`,
|
||||
bypassing `ManagedSecretKeys` and the periodic sync entirely.
|
||||
5. In `Development`, `appsettings.Development.json` (git-ignored) can
|
||||
override any of these values locally, bypassing Key Vault for that key;
|
||||
`AssembleConnectionStrings` (`Fuchs/Program.cs`) additionally prefers
|
||||
`_Dev`-suffixed credential keys so a reachable Key Vault can never hand a
|
||||
dev machine production DB credentials.
|
||||
|
||||
## Key files
|
||||
- `Fuchs/Program.cs` — `builder.AddSecretManagement()` call site and
|
||||
`AssembleConnectionStrings`.
|
||||
- `Fuchs/appsettings.json` — `SecretManagement` block (`VaultUri`,
|
||||
`AppName: "fuchs"`, `CacheFilePath`, `SyncIntervalHours`,
|
||||
`ManagedSecretKeys`) plus `"MANAGED_BY_KEYVAULT"` placeholders for every
|
||||
managed key.
|
||||
- `OCORE/Secrets/Extensions/SecretManagementExtensions.cs` — shared
|
||||
Key Vault + DPAPI wiring and DI registration, used by every host type.
|
||||
- `OCORE/Secrets/Extensions/SecretsOnDemandLoader.cs` — direct, cached,
|
||||
runtime-name secret lookup outside the `ManagedSecretKeys` sync path.
|
||||
- `OCORE/Secrets/Sync/KeyVaultSyncService.cs` — periodic re-sync +
|
||||
`IConfigurationRoot.Reload()`.
|
||||
- `OCORE/Secrets/Cache/DpapiSecretsCache.cs` — DPAPI encrypt/decrypt of the
|
||||
local cache file.
|
||||
- `OCORE_web/Secrets/SecretManagementWebExtensions.cs` — the
|
||||
`WebApplicationBuilder.AddSecretManagement()` entry point Fuchs calls.
|
||||
- `OCORE_web/.github_instructions/secretsmanagement.instructions.md` —
|
||||
canonical mechanism reference (naming/encoding rules, cache format,
|
||||
"adding a new secret" steps), kept current independently of this doc.
|
||||
- `.github/copilot-instructions.md` ("Azure Key Vault — Secret Naming") —
|
||||
the naming rules summarized for this repo, with the `fuchs--` prefix.
|
||||
|
||||
## Related decisions
|
||||
- [0031 — Shared Key Vault + DPAPI secret management via OCORE/OCORE_web](../Decisions/0031-shared-keyvault-dpapi-secret-management.md)
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-09-16
|
||||
applyTo:
|
||||
- "Fuchs/Controllers/IntranetController.AdminGroups.cs"
|
||||
- "Fuchs/js/intranet/modules/fis.usrgrp*.js"
|
||||
- "Fuchs/js/intranet/modules/fis.usrgrp.scss"
|
||||
- "Fuchs/js/intranet/fis_main_menu.js"
|
||||
- "Fuchs_Database/dbo/Functions/fis_admin_getUserGroupList.sql"
|
||||
- "Fuchs_Database/dbo/Stored Procedures/fis_admin_setUserGroup.sql"
|
||||
- "Fuchs_Database/dbo/Stored Procedures/fis_admin_delUserGroup.sql"
|
||||
- "Fuchs_Database/dbo/Stored Procedures/fis_admin_addGroupMember.sql"
|
||||
- "Fuchs_Database/dbo/Stored Procedures/fis_admin_remGroupMember.sql"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0021 — User group management: new `usrgrp` module, close the missing `intranet_admin` gate on group writes
|
||||
|
||||
## Context
|
||||
The per-user "Gruppen" dialog in the `usr` module ([0014](0014-admin-user-management.md),
|
||||
[0015](0015-split-admin-user-management-module.md)) lets an admin toggle an existing user's
|
||||
membership in each usergroup, but there was no UI anywhere to create, rename or delete a
|
||||
usergroup itself — `fis_usergroups` could only be populated by running a raw SQL script by hand
|
||||
(`hlp/db/create user group.sql`). The DB-layer proc `fis_admin_setUserGroup` (create/rename) and
|
||||
the read-side `fis_admin_getUserGroupList` already existed but were wired to no controller
|
||||
endpoint, mirroring the exact gap [0020](0020-user-management-create-and-delete.md) closed for
|
||||
user accounts.
|
||||
|
||||
While wiring this up, three of the four `fis_admin_*` group-write procedures were found missing
|
||||
the module's standard `intranet_admin` authorization check entirely (`fis_admin_addGroupMember`,
|
||||
`fis_admin_remGroupMember`, `fis_admin_delUserGroup`) — a caller with `fds_sys > 4` but no
|
||||
`intranet_admin` setting could add/remove a user's group membership or delete a group outright,
|
||||
the same real authorization hole 0020 fixed for `fis_admin_delUserAccount`. `fis_admin_setUserGroup`
|
||||
already had the check.
|
||||
|
||||
## Decision
|
||||
- **New standalone module, `fn=usrgrp`** (`IntranetController.AdminGroups.cs`,
|
||||
`fis.usrgrp.js`/`fis.usrgrp_txt_de.js`/`fis.usrgrp.scss`), added as a third entry in the existing
|
||||
"Administration" sidemenu dropdown alongside Benutzerverwaltung and Systemüberwachung — per
|
||||
[0015](0015-split-admin-user-management-module.md)'s own stated consequence that any future
|
||||
admin-only feature should default to its own module under that dropdown rather than be folded
|
||||
into an existing one. The dropdown itself already stays visible while any of its children is
|
||||
open, so switching between Gruppenverwaltung and Benutzerverwaltung needs no dedicated "back"
|
||||
control.
|
||||
- **Same dual-authorization gate as every other module under this dropdown**: `fds_sys > 4`
|
||||
server-side (`AdminMinAuthExclusive`/`GetSystemAdminAuthAsync`, shared from
|
||||
`IntranetController.Admin.cs`) gates whether the module is reachable/visible at all;
|
||||
`fis_admin_getUserAuth(@authaccount) > 1` (the `intranet_admin` setting) independently gates
|
||||
every read/write the `fis_admin_*` procs perform. Both together are what "only admins can see
|
||||
and change groups" means in this codebase — see the Consequences below for why this is enforced
|
||||
in four places, not one.
|
||||
- **New `id` cases in `Do_Process_AdminGroups`**: `list` → `fis_admin_getUserGroupList`, `create`/
|
||||
`rename` → `fis_admin_setUserGroup` (same proc; `rename` supplies an existing `@usergroup_id`),
|
||||
`delete` → `fis_admin_delUserGroup`. No membership editing here — that stays in the `usr`
|
||||
module's existing per-user checkbox dialog, unchanged.
|
||||
- **Closed the missing `intranet_admin` gate** on `fis_admin_addGroupMember`,
|
||||
`fis_admin_remGroupMember` and `fis_admin_delUserGroup` by adding the same
|
||||
`fis_admin_getUserAuth(@authaccount) > 1` guard `fis_admin_setUserGroup` already had, following
|
||||
the same "no-op instead of error" style 0020 used for `fis_admin_delUserAccount`: a caller
|
||||
without `intranet_admin >= 2` gets an unmodified result set / a `false` success bit, not a SQL
|
||||
error, consistent with every other `fis_admin_*` proc in this area.
|
||||
- **Frontend** mirrors `fis.usr.js`'s list+ribbon pattern (`renderLst`/`eM`/`iMn`): a card-style
|
||||
row list with a base ribbon button ("Anlegen") and a per-row ribbon ("Umbenennen"/"Löschen") on
|
||||
row selection, using `$ocms.dlgform` for create/rename and a `confirm(...)` guard (no dialog) for
|
||||
delete — the same choice 0020 made for deleting a user account.
|
||||
|
||||
## Consequences
|
||||
- Deleting a group does **not** guard against it still having members — its `fis_usergroups_members`
|
||||
rows simply stop being returned (the membership query already filters on the group's own
|
||||
`DateDeleted`). No UI currently surfaces "this group has N members" before deletion; acceptable
|
||||
since deletion is a soft-delete and the group row can be recreated with the same effective
|
||||
membership restored via `fis_admin_addGroupMember` if this turns out to matter in practice.
|
||||
- Four separate `fis_admin_*` procs/functions now each independently re-check
|
||||
`intranet_admin > 1` (`getUserGroupList`, `setUserGroup`, `delUserGroup`, `addGroupMember`,
|
||||
`remGroupMember` — five, in fact) rather than a single shared gate; this is the established
|
||||
style in this codebase (every `fis_admin_*` object re-checks for itself, see 0014) and not
|
||||
something this change revisits.
|
||||
- Same operational note as 0014/0015/0020: an account needs **both** `fds_sys > 4` and
|
||||
`intranet_admin >= 2` to actually manage groups, not obvious from the UI.
|
||||
|
||||
## Alternatives considered
|
||||
- **Extend the existing per-user "Gruppen" dialog** with an inline "new group" input and a
|
||||
delete icon per row, instead of a new module. Rejected per explicit user direction — the user
|
||||
asked for a dedicated sidemenu entry for group management, separate from the per-user dialog.
|
||||
- **Fold into the `usr` module** as a second ribbon button. Rejected for the same reason 0015
|
||||
split `usr` off `admin` in the first place: two unrelated concerns (managing individual user
|
||||
accounts vs. managing the group catalog) behind one button was already identified as a mistake
|
||||
once it happened once; the "own module under the dropdown" default from 0015 applies directly.
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-09-16
|
||||
applyTo:
|
||||
- "Fuchs_Database/dbo/Stored Procedures/mfr__updt__*.sql"
|
||||
- "Fuchs_DataService/FdsMfrClient.cs"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0022 — MFR sync transaction blocking during full resets is an accepted risk
|
||||
|
||||
## Context
|
||||
Every `mfr__updt__*` stored procedure (25 of them, one per synced mfr entity table) wraps its
|
||||
DELETE-stale-references + MERGE-upsert logic in a single explicit `BEGIN TRANSACTION` /
|
||||
`COMMIT TRANSACTION`, with `SET XACT_ABORT ON` and a `TRY/CATCH` that rolls back and logs to
|
||||
`fds__admin_dberrors` on failure. This gives all-or-nothing atomicity for one entity's sync
|
||||
batch: either every stale side-table row is cleaned up and the target table is upserted, or
|
||||
none of it is, so a mid-batch failure can never leave orphaned side-table rows alongside a
|
||||
stale/missing target row.
|
||||
|
||||
`FdsMfrClient.cs`'s `CommandAfter` (which invokes the `mfr__updt__*` proc) and `CommandBefore`/
|
||||
staging `SqlBulkCopy` both use a 600s command timeout (`MergeCommandTimeoutSeconds`), raised
|
||||
from `SqlCommand`'s 30s default specifically so a **full reset** of a large entity table (not
|
||||
the normal incremental sync, which only carries the rows that changed since the last run) has
|
||||
room to complete. Before that timeout was raised, a slow full-reset batch would self-abort at
|
||||
30s — the client cancellation would cancel the batch and roll back the open transaction —
|
||||
capping how long the transaction (and the locks it holds on the target table and the affected
|
||||
side tables) could stay open. With the timeout at 600s, a genuinely large full-reset batch can
|
||||
now hold that same transaction, and therefore those same locks, for up to ten minutes, during
|
||||
which other readers of the live `mfr__*` tables (e.g. intranet pages querying companies,
|
||||
contacts, invoices, …) can block waiting for the write transaction to release its locks —
|
||||
instead of blocking, at most, on one DELETE or one MERGE statement individually.
|
||||
|
||||
## Decision
|
||||
Accept this as a known, documented risk rather than changing behavior. Full resets are rare
|
||||
(a manual/administrative operation, not the routine incremental sync path) and are expected to
|
||||
run outside of business hours. The atomicity the transaction provides — no orphaned side-table
|
||||
rows left behind by a failed sync — is worth more than shortening the worst-case blocking
|
||||
window for an operation this infrequent.
|
||||
|
||||
## Consequences
|
||||
- A full reset of a large `mfr__*` entity table can block ordinary intranet reads of that
|
||||
table (and the side tables `mfr__*PartnerSet`, `mfr__#customvalues`, `mfr__#locations`,
|
||||
`mfr__comments`) for as long as that reset's DELETE+MERGE takes to execute, up to the 600s
|
||||
command timeout.
|
||||
- Anyone scheduling or manually triggering a full reset of a large entity table should do so
|
||||
during low-traffic hours, same as any other maintenance operation with table-level locking.
|
||||
- If full resets become frequent enough, or large enough, for this blocking window to matter in
|
||||
practice, revisit this decision — options considered and rejected here (see below) remain
|
||||
available.
|
||||
|
||||
## Alternatives considered
|
||||
- **Drop the explicit transaction**, letting each DELETE/MERGE statement auto-commit
|
||||
individually (the behavior before the transaction wrapping was added). Shortens the lock
|
||||
window back to per-statement, but reintroduces the exact orphaned-data risk on mid-batch
|
||||
failure that the transaction wrapping was added to prevent. Rejected — full-batch atomicity
|
||||
was a deliberate improvement and giving it up trades a rare, low-traffic-hours blocking issue
|
||||
for a correctness regression on every sync.
|
||||
- **Enable Read Committed Snapshot Isolation (RCSI)** (`ALTER DATABASE ... SET
|
||||
READ_COMMITTED_SNAPSHOT ON`) so readers use row versioning instead of blocking on the writer's
|
||||
locks, keeping the transaction's atomicity intact while removing reader blocking entirely.
|
||||
This is the architecturally cleanest fix, but it is a database-wide isolation behavior change
|
||||
(affects every table, every query, tempdb version-store load) that needs its own testing and
|
||||
sign-off rather than being folded into this change. Left as the preferred follow-up if the
|
||||
accepted risk above turns out not to be acceptable in practice.
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
status: Superseded
|
||||
date: 2026-09-16
|
||||
applyTo:
|
||||
- "Fuchs/Controllers/**"
|
||||
- "Fuchs/code/FuchsIntranet.cs"
|
||||
- "Fuchs/js/intranet/**"
|
||||
supersededBy: "0024-multilogger-sql-log-sink.md"
|
||||
---
|
||||
|
||||
> **Superseded by [0024](0024-multilogger-sql-log-sink.md).** The explicit `Fuchs_intranet.debug_log` /
|
||||
> `fds__admin_logdebug` DB write this decision introduced was retired the same day, once
|
||||
> [0024](0024-multilogger-sql-log-sink.md) made `ILogger` itself the single path to the DB log
|
||||
> (`fds__admin_logdebug_ext`, same `fds__debuglog` table). Keeping both would have double-written
|
||||
> every row. The frontend/`x-ocms-desc`/`EncodeResponseDescription`/preview-timeout decisions below
|
||||
> are unaffected and still in force — only the *DB write mechanism* changed.
|
||||
|
||||
# 0023 — Failures of user actions are persisted to `fds__admin_logdebug`, with the SQL and a readable description
|
||||
|
||||
## Context
|
||||
Support diagnoses production problems from a query over the `fds__admin_logdebug` table
|
||||
(`EXECUTE [dbo].[fds__admin_logdebug]` via `Fuchs_intranet.debug_log`) — not from the
|
||||
web server's `logs/ErrorLog.txt` ([0003](0003-structured-diagnostic-logging.md)), which is
|
||||
rarely reachable. Reviewing the 2026-09-16 production extract of that table showed three gaps:
|
||||
|
||||
- Every "SQL Error in getSQLDataSet" row (twelve execution timeouts in one morning) had
|
||||
`Data = NULL`. `FIS_SQLOptions.OnError` logged its constructor *context* (the fn/id/code,
|
||||
usually `null`) instead of the *data* OCORE hands over (`new { sql = … }`), so nobody could
|
||||
tell which statement timed out.
|
||||
- Unhandled exceptions from `IntranetController.Do` went to `ILogger` only. A customer's
|
||||
failing "PDF-Vorschau" left no trace in the table at all.
|
||||
- The browser's generic failure alert reads its description from the `x-ocms-desc` response
|
||||
header, which the backend never set — so users always saw an empty
|
||||
"Der Server hat einen Fehler gemeldet:".
|
||||
|
||||
## Decision
|
||||
- **The DB log row must carry what is needed to attribute the failure.** `FIS_SQLOptions`
|
||||
merges the OCORE payload (the SQL statement) with the caller context into the `Data`
|
||||
column (`{ sql, context }`). New `OnError` hooks must never drop the statement.
|
||||
- **Every failure that interrupts a user action is written to `fds__admin_logdebug` too**,
|
||||
in addition to `ILogger` and the notification required by
|
||||
[0003](0003-interrupting-exceptions-notify-the-user.md): the `Do` catch-all writes
|
||||
`Unhandled exception in Do {fn}/{id}` with route, user and the form *field names* (never
|
||||
values — they can carry tokens/PII); the PDF-preview endpoints go through a shared guard
|
||||
(`IntranetController.PdfPreviewAsync`) that logs `PDF preview failed (endpoint)` with
|
||||
ref/user/duration, counts `fuchs.pdf.previews{outcome}` (`success`/`gone`/`failure` — an
|
||||
expired-draft `410` from inside `render()` is its own `gone` outcome, not `success`, since
|
||||
it is not the render failure this guard exists to catch) and, for an authenticated caller,
|
||||
toasts the user.
|
||||
- **A 500 carries its message in the `x-ocms-desc` header** (`ServerError(msg)` →
|
||||
`EncodeResponseDescription`: percent-encoded ASCII, spaces as `%20` because the client
|
||||
maps `+` to space before `decodeURIComponent`). Handlers that return a 500 should pass a
|
||||
user-readable German message; the frontend shows it verbatim.
|
||||
- **Preview calls in the frontend never fail silently.** They use a 120 s timeout
|
||||
(`$inv.pdfPreviewTimeout`), one shared error handler (`$inv.pdfPreviewFailed`) and a
|
||||
`complete` handler that unfreezes the editor and reports a timeout — `postXT` returns early
|
||||
on a timeout without calling `error`, so `complete` is the only place it is observable.
|
||||
|
||||
## Consequences
|
||||
- Support can attribute a SQL timeout to its statement straight from the table; a failing
|
||||
preview is visible as one row with endpoint, reference id, user and duration.
|
||||
- `debug_log` is a synchronous DB round-trip and is only used on error paths; it must not be
|
||||
added to hot/success paths (see [0003](0003-structured-diagnostic-logging.md) on the cost
|
||||
of DB logging).
|
||||
- `x-ocms-desc` is user-facing: messages must be German, short, and free of secrets. The
|
||||
innermost exception text is acceptable for an authenticated intranet user, but some
|
||||
preview endpoints (`req/idoc`, `inv/rdoc`, `inv/rdocn`, `rem/idoc` — see `_allowedGet`
|
||||
in `IntranetController.cs`) are reachable without authentication; `PdfPreviewFailureMessage`
|
||||
omits the exception detail for those callers and falls back to the generic message.
|
||||
|
||||
## Alternatives considered
|
||||
- **Enable `FuchsLoggerProvider.DatabaseLoggingEnabled`** so every `ILogger` error lands in
|
||||
the DB. Rejected: it would write every `LogError` (including expected/handled ones and
|
||||
background-service noise) with a DB round-trip each, and the provider has no access to the
|
||||
request context (user, route) that makes a row actionable.
|
||||
- **Return the message only in the JSON body.** The generic client failure path never parses
|
||||
the body of a non-2xx response; the header is the existing contract in `oci_basic.js`.
|
||||
@@ -0,0 +1,114 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-09-16
|
||||
applyTo:
|
||||
- "Fuchs/Program.cs"
|
||||
- "Fuchs/Logging/**"
|
||||
- "Fuchs/code/FuchsIntranet.cs"
|
||||
- "Fuchs/Controllers/**"
|
||||
- "Fuchs_DataService/FdsMfr.cs"
|
||||
- "OCORE/OCORE/logging/**"
|
||||
- "Fuchs_Database/dbo/Tables/fds__debuglog.sql"
|
||||
- "Fuchs_Database/dbo/Stored Procedures/fds__admin_logdebug_ext.sql"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0024 — `ILogger` is the single path to the DB log, via the OCORE MultiLogger SQL sink
|
||||
|
||||
## Context
|
||||
[0023](0023-user-action-failures-persisted-to-db-log.md) fixed the immediate gaps (SQL statement
|
||||
missing from the log row, unhandled `Do()` exceptions and PDF-preview failures never reaching the
|
||||
DB) by having a handful of call sites call **both** `_logger.LogError(...)` **and** the explicit
|
||||
`Fuchs_intranet.debug_log(...)` (→ `EXECUTE [dbo].[fds__admin_logdebug]`) for the same failure.
|
||||
That works, but only for the call sites someone remembered to wire up by hand, it writes every
|
||||
attributed failure to `fds__debuglog` twice as soon as any generic DB sink is added on top, and it
|
||||
leaves the ~15 `new FIS_SQLOptions()` call sites (no logger, no context — see `Services/*.cs`)
|
||||
without DB attribution at all, since their `dbLog` closure bypassed `ILogger` entirely.
|
||||
|
||||
OCORE ships a generic `ILoggerProvider` pipeline for exactly this (`OCORE.MultiLogger`,
|
||||
`SqlLoggerProvider` + `FileLoggerProvider`) that other ProcessWeb projects (VM_Regatta) already
|
||||
use: every `_logger.LogX(...)` call, from anywhere in the app or its dependencies (OCORE's own
|
||||
SQL helpers included), can be mirrored to a SQL sink automatically, with per-provider level
|
||||
filtering, structured context merging (scopes + `{Placeholder}` args + an explicit `data:`
|
||||
object), and redaction. It was not previously wired into Fuchs.
|
||||
|
||||
## Decision
|
||||
- **`ILogger` is the only thing a call site writes.** No code calls `Fuchs_intranet.debug_log`
|
||||
(removed) or a stored procedure directly for attribution logging. `_logger.LogWarning`/`-Error`/
|
||||
`-Critical` is sufficient; the SQL sink below persists it.
|
||||
- **The OCORE MultiLogger SQL sink is registered at Warning+**, gated by
|
||||
`Fuchs:Logging:SqlSink:Enabled` (default `true`), writing through the new
|
||||
`[dbo].[fds__admin_logdebug_ext]` proc into the **same** `[dbo].[fds__debuglog]` table
|
||||
the retired `fds__admin_logdebug` wrote to — existing report queries over that table keep
|
||||
working, now enriched with `LogLevel`/`Machine`/`Category`/`AuthUser`/`IsDebug` columns.
|
||||
`fds__admin_logdebug` itself is left in place (unused by the app, but not dropped — a
|
||||
compatibility no-op for anything external still calling it).
|
||||
- **`FuchsLoggerProvider`'s file sink (0003) is unchanged** and stays the always-on baseline;
|
||||
the SQL sink is additive, not a replacement.
|
||||
- **A call site that wants its own CodeReference gives the log call an explicit `EventId`**
|
||||
(`new EventId(0, "SQL Error in getSQLDataSet")`, etc.) — its `Name` becomes the row's
|
||||
`CodeReference`, preserving the exact strings the retired `debug_log` calls used, so nothing
|
||||
downstream (dashboards, saved queries) had to change. Falls back to the `ILogger` category
|
||||
(new `LogEntry.Category`, OCORE-side), then the constant `'MultiLogger'`.
|
||||
- **`RequestLogScopeMiddleware`** (`Fuchs/Logging/`, registered after `UseAuthentication()`)
|
||||
pushes `{requestId, path, authuser}` as an `ILogger` scope for the whole request — every log
|
||||
line written while handling it, including ones written deep inside OCORE (SQL timeouts, PDF
|
||||
render failures), is attributed automatically without the call site doing anything. `authuser`
|
||||
specifically is picked up by OCORE's `MultiLogger_base.Log` into the dedicated
|
||||
`LogEntry.AuthUser` → `fds__debuglog.AuthUser` column, not just the serialized JSON blob.
|
||||
- **`FIS_SQLOptions.OnError` falls back to the static `OCORE.Logging.Logger`** (already set once
|
||||
in `Program.cs`) when no request/service-scoped `ILogger` was supplied, so the `new
|
||||
FIS_SQLOptions()` call sites keep working exactly as attributed as the ones that pass a logger.
|
||||
- **`Fuchs_DataService/FdsMfr.cs`'s `FdsSqlOptions.OnError`** — the MFR-sync worker's equivalent of
|
||||
`FIS_SQLOptions`, and the actual source of the deadlock/timeout rows the original log review
|
||||
found — had the exact same problem in reverse: it called *both* `logger?.LogError(...)` *and*
|
||||
`FdsDebug.DebugLog(...)` (a second, direct `EXECUTE [dbo].[fds__admin_logdebug]`) for every SQL
|
||||
error. Since the worker is hosted inside this same web app (ADR 0010) and therefore shares the
|
||||
SQL sink, that combination would double-write every row. Fixed the same way: `ILogger` only,
|
||||
with the same static-logger fallback, `EventId.Name = procedure` (unprefixed, matching what the
|
||||
direct `DebugLog` call used to pass).
|
||||
- **`FdsDebug.DebugLog`, used elsewhere in `Fuchs_DataService`** (`FdsMfr.cs`, `FdsShared.cs`) was
|
||||
deliberately **not** migrated — those call sites don't separately call `ILogger`, so there is no
|
||||
duplication there today, and moving nine call sites onto the generic pipeline was out of scope
|
||||
for closing the one hazard (`FdsSqlOptions.OnError`) this ADR set out to fix. It still writes
|
||||
directly to the legacy `fds__admin_logdebug` proc (same `fds__debuglog` table) plus a local
|
||||
`DebugLog.txt`. Revisit if a future change adds an `ILogger` call alongside one of those sites.
|
||||
- **OCORE-side hardening that made this safe to turn on** (see the code-review this ADR grew out
|
||||
of): `LogRedaction` masks sensitive keys by *segment*, not substring (`Pin`/`Otp` as a whole
|
||||
identifier segment, not `Mapping`/`HotPath`), degrades one unserializable context entry to its
|
||||
`ToString()` instead of dropping the whole payload, and tolerates a self-referencing graph.
|
||||
`LogEntry.dataSerialized` is computed lazily (at most once, only if a sink actually reads it) so
|
||||
the per-call context-merging cost is paid only when something is written. Scope/state collection
|
||||
never throws into the caller. `SqlLoggerProvider`'s new `IncludeCategory` option is **off by
|
||||
default** in OCORE (other consumers' stored procedures don't declare `@category`) — Fuchs turns
|
||||
it on explicitly since its proc does.
|
||||
- **Never use `{@Foo}` placeholders.** Plain `Microsoft.Extensions.Logging` (what OCORE.MultiLogger
|
||||
and Fuchs use — not Serilog) does not treat `@` as a destructuring operator; it stays a literal
|
||||
leading character of the property name (`"@Foo"`), which is confusing in the serialized JSON.
|
||||
Use `{Foo}`.
|
||||
|
||||
## Consequences
|
||||
- One `_logger.LogError(...)` call is enough; adding a second, explicit DB write next to it
|
||||
duplicates the row once the SQL sink is on. Code review should flag a reintroduced
|
||||
`debug_log`-style call.
|
||||
- A log call that should be individually attributable in `fds__debuglog.CodeReference` needs an
|
||||
explicit `EventId`; without one it falls back to the (often less specific, but still useful)
|
||||
`ILogger` category.
|
||||
- The SQL sink is a fire-and-forget async write with a 2-minute retry cooldown on failure
|
||||
(`SqlLoggerProvider`) — during a DB outage it goes quiet for that window, same as any other
|
||||
write to that DB. The file sink (0003) remains the log that is never gated on the DB being
|
||||
reachable at all.
|
||||
- `Fuchs:Logging:SqlSink:Enabled` is the escape hatch if the sink ever needs to be turned off
|
||||
without a deploy (e.g. investigating whether it is itself contributing to DB load).
|
||||
- OCORE's `MultiLoggerOptions.SQL.IncludeCategory` is a new, opt-in, backward-compatible knob;
|
||||
other `SqlLoggerProvider` consumers (VM_Regatta) are unaffected unless they opt in and add a
|
||||
matching `@category` parameter to their own stored procedure.
|
||||
|
||||
## Alternatives considered
|
||||
- **Keep the explicit `debug_log` write and just fix its parameter bug (0023's original scope).**
|
||||
Rejected once the generic SQL sink became the actual plan — keeping both would permanently
|
||||
double-write every attributed failure, and still leave the `new FIS_SQLOptions()` call sites
|
||||
unattributed.
|
||||
- **`FuchsLoggerProvider.DatabaseLoggingEnabled = true`** (0003's prepared-but-disabled DB path).
|
||||
Rejected again here for the same reason 0023 rejected it: no per-request context (user, route),
|
||||
and it would fire on every `LogError` including background-service noise with no Warning+ gate.
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-09-16
|
||||
applyTo:
|
||||
- "Fuchs/Controllers/IntranetController.Invoices*.cs"
|
||||
- "Fuchs/Controllers/IntranetController.Requests.cs"
|
||||
- "Fuchs/Services/InvoiceService.cs"
|
||||
- "Fuchs_Database/dbo/Stored Procedures/fds__getReportDocument.sql"
|
||||
- "Fuchs_Database/dbo/Stored Procedures/fds__setInvoiceFile.sql"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0025 — Invoice document endpoints regenerate a missing final PDF on read
|
||||
|
||||
## Context
|
||||
|
||||
A finalised invoice's PDF is rendered exactly once, at finalisation
|
||||
(`req/sconf` → `StoreInvoiceDocumentFileAsync` → `fds__setInvoiceFile`), and
|
||||
stored in `fds__invoices.[file]` (primary) plus blob storage (archive). If that
|
||||
single write fails — observed on 2026-09-16 for `W7VAL`/`R2026-0258`, where the
|
||||
`fds__setInvoiceFile` call timed out under DB load — the invoice is left
|
||||
finalised with `[file] = NULL` and nothing ever retries.
|
||||
|
||||
The read endpoints then disagreed: `req/idoc` regenerated a missing file
|
||||
(a fallback carried over from the legacy VB `fuchs_ocms_intranet_requests.vb`
|
||||
"idoc" case), while `inv/rdoc` — the endpoint the invoice list's preview icon
|
||||
actually calls — read `fds__getReportDocument` and returned a permanent 404.
|
||||
|
||||
## Decision
|
||||
|
||||
Both invoice document read endpoints (`req/idoc`, `inv/rdoc`) regenerate and
|
||||
persist the PDF via `StoreInvoiceDocumentFileAsync` when no stored bytes exist,
|
||||
**only** when the invoice is
|
||||
|
||||
- **finalised** (`IsFinal`, i.e. `!FdsInvoiceData.IsDraft`), and
|
||||
- **not external** (`IsExternal` false — an MFR-sourced document has no
|
||||
template to regenerate from; a missing MFR file stays a 404).
|
||||
|
||||
A read is therefore allowed to write `[file]`, upload the blob and emit
|
||||
`InvoiceFileCreated` — the same side effects finalisation would have had.
|
||||
|
||||
Every failure inside `StoreInvoiceDocumentFileAsync`/`StoreReminderDocumentFileAsync`
|
||||
(render threw, render produced no bytes, DB write threw, DB write matched 0
|
||||
rows) is logged at Error level individually, because the method signals all
|
||||
four to callers with the same empty array. The 0-rows case is detected via
|
||||
the procs' existing `OUTPUT inserted.[Id]` result set (read with
|
||||
`getSQLDatatable_async`; empty ⇒ the `UPDATE ... WHERE [file] IS NULL`
|
||||
matched nothing). Rows-affected is **not** usable for this: both procs run
|
||||
`SET NOCOUNT ON`, so `ExecuteNonQuery` returns -1 whether or not the
|
||||
`UPDATE` matched, which is why `setSQLValue_async` could never distinguish
|
||||
a no-op from a write and the original incident was invisible to every log.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Drafts are never persisted by a read.** `fds__setInvoiceFile` writes
|
||||
`WHERE [file] IS NULL` and `fds__setInvoiceFinal` never clears `[file]`; a
|
||||
draft render stored early would make the later final write a no-op and the
|
||||
invoice would carry the watermarked draft PDF forever. The `!IsDraft` gate is
|
||||
load-bearing — do not relax it.
|
||||
- The regenerated PDF is rendered from the invoice's *current* data. That is
|
||||
acceptable because a finalised invoice is immutable in the editor; it is not
|
||||
acceptable for anything mutable.
|
||||
- A read on a cold miss now costs a render plus a DB write, so it is exposed
|
||||
to the same `fds__setInvoiceFile` timeout that caused the miss. The client
|
||||
preview timeout (`$inv.pdfPreviewTimeout`) must stay generous (120 s).
|
||||
- `inv/rdoc` still serves external (MFR) documents purely from
|
||||
`fds__getReportDocument`; regeneration never applies to them.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Retry at finalisation instead of on read.** Would cover the store failure
|
||||
but not an already-damaged row; regeneration on read heals both and matches
|
||||
the behaviour `req/idoc` already had.
|
||||
- **Regenerate drafts too, to mirror `req/idoc`'s render path.** `req/idoc`
|
||||
*renders* a draft on the fly without storing it; persisting a draft through
|
||||
`fds__setInvoiceFile` is what causes the permanent-draft defect above.
|
||||
- **Return 404 and rely on the `_BackupAndClearInvoiceFile` recovery links.**
|
||||
That proc is a manual-only maintenance tool, and the `inv/rdoc?create=1`
|
||||
link it prints was never implemented on that endpoint.
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-09-16
|
||||
applyTo:
|
||||
- "Fuchs/Services/FuchsWidgetService.cs"
|
||||
- "Fuchs/Services/ProcessWebComService.cs"
|
||||
- "Fuchs_Database/dbo/Functions/fds__fn_unpaidInvoices.sql"
|
||||
- "Fuchs_Database/dbo/Functions/fds__fn_InvoicePaymentAmount*.sql"
|
||||
- "Fuchs_Database/dbo/Functions/fds_wgd_*.sql"
|
||||
- "Fuchs_Database/dbo/Functions/fds_wdg_*.sql"
|
||||
- "Fuchs_Database/dbo/Stored Procedures/fds__logEmail.sql"
|
||||
- "Fuchs_Database/Scripts/**"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0026 — Dashboard widget SQL is set-based (no per-row scalar UDFs); `fds__emaillog.config` is a JSON contract
|
||||
|
||||
## Context
|
||||
Two dashboard widgets were failing in the DB log (`fds__debuglog`, 2026-09-16):
|
||||
|
||||
1. **`wdg_invoices_overdue_sum`** timed out after the widget command timeout of 90 s. Its SQL called
|
||||
`[dbo].[fds__fn_InvoicePaymentAmount](Id)` once per row of `fds__fn_unpaidInvoices()`. That scalar
|
||||
UDF declares a table variable, so SQL Server 2019 cannot inline it (`sys.sql_modules.is_inlineable = 0`)
|
||||
and re-executes the multi-statement TVF `fds__fn_bankingtransactions` — a full pass over
|
||||
~11 k banking rows plus a `STRING_SPLIT` cross-apply over the settings heap — for **every** invoice:
|
||||
≈0.5 s × 187 unpaid invoices ≈ 100 s measured. `wdg_unbez_akonto` had the same pattern (≈5 s,
|
||||
hidden only by `daycache = 1`). The same total computed set-based (one TVF pass, `GROUP BY InvId`,
|
||||
joined to the invoices) takes < 0.5 s and returns the identical figure.
|
||||
2. **`wgd_lastEmails`** raised *JSON text is not properly formatted*. `fds_wgd_lastEmails` parses
|
||||
`fds__emaillog.config` with `OPENJSON`/`JSON_QUERY`, but `ProcessWebComService` — the replacement
|
||||
for the legacy mailer — wrote `config = ''` on every path. One such row was enough to error the
|
||||
whole widget, and the audit trail had lost recipient/subject/attachment names for every email sent
|
||||
through the new service.
|
||||
|
||||
Widget definitions are **data** (`fis_widgets.sql`), not schema, so there is no compile-time check
|
||||
that a widget's SQL is cheap or that the columns it parses exist.
|
||||
|
||||
## Decision
|
||||
- **Widget SQL and the functions backing it are set-based.** Per-invoice payment totals come from the
|
||||
inline TVF `[dbo].[fds__fn_InvoicePaymentAmounts]()` (one banking pass, grouped by `InvId`) or from
|
||||
the `[PaymentAmount]`/`[OpenAmount]` columns `fds__fn_unpaidInvoices()` now exposes. The scalar
|
||||
`fds__fn_InvoicePaymentAmount(_full)` remains for single-invoice callers (invoice/reminder
|
||||
preparation, per-row `paymentstatus = 'p'` cells in the invoice lists) but must not be called
|
||||
per row from a widget or any other query that iterates a whole invoice set.
|
||||
- **`fds__emaillog.config` is a JSON contract**, written by `ProcessWebComService.BuildAuditConfig`
|
||||
in the legacy mailer's shape — `{subject, from, to[], cc[], bcc[], attachments[]}` with addresses
|
||||
as `"Name" <email>` and attachments as file names only (never the body, never attachment bytes) —
|
||||
on **every** audit path (success, API error, exception, invalid address, service disabled). SQL
|
||||
consumers of the column must still tolerate non-JSON text (`IIF(ISJSON(x)=1, x, N'{}')` before
|
||||
parsing), because `config` is `NOT NULL` and older rows/other writers may hold `''`.
|
||||
- **Widget-data migrations live in `Fuchs_Database/Scripts/`** as idempotent, dated T-SQL files
|
||||
(`<None Include>` in the sqlproj, not part of the schema build) and are run once per environment
|
||||
after the schema deploy they depend on.
|
||||
|
||||
## Consequences
|
||||
- `wdg_invoices_overdue_sum` 101.9 s → 0.45 s, `wdg_unbez_akonto` 4.7 s → 0.4 s, `wgd_lastEmails`
|
||||
error → 15 ms (dev, 2026-09-16). The widget command timeout stays at 90 s; it is a guard, not a
|
||||
budget.
|
||||
- Adding a scalar UDF call inside a widget's `SELECT`/`WHERE` over `fds__invoices` or the
|
||||
`fds__fn_unpaidInvoices()` set is a regression of this decision. Prefer exposing a set-based column
|
||||
on the TVF, as done for `PaymentAmount`.
|
||||
- `ProcessWebComServiceTests` assert the config contract on every audit path; changing the shape
|
||||
requires updating `fds_wgd_lastEmails` in the same change.
|
||||
- The `Scripts/2026-09-16_fis_widgets_set_based_payment_amounts.sql` migration must be run on
|
||||
Production after the schema deploy (it is a no-op once applied).
|
||||
|
||||
## Alternatives considered
|
||||
- **Raise the widget `CommandTimeout`** — masks an O(N) re-scan; the cost grows with every unpaid
|
||||
invoice and every banking upload, and it would still block a dashboard load for minutes.
|
||||
- **Convert `fds__fn_InvoicePaymentAmount` into an inlineable scalar UDF** — impossible while it
|
||||
needs a TVP (`fds__tt__idlist_vchar`) to call the banking TVF; rewriting the banking TVF as an
|
||||
inline TVF is a larger change to a function shared with the banking-assignment UI.
|
||||
- **Fix the bad `fds__emaillog` row and leave the widget function strict** — the next `''` row (any
|
||||
writer, any environment) would take the widget down again; tolerance belongs in the reader.
|
||||
@@ -0,0 +1,114 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-09-17
|
||||
applyTo:
|
||||
- "Fuchs/Controllers/IntranetController.Admin.cs"
|
||||
- "Fuchs/Services/IInvoiceFileBackupService.cs"
|
||||
- "Fuchs/Services/InvoiceFileBackupService.cs"
|
||||
- "Fuchs/Services/IBlobStorageService.cs"
|
||||
- "Fuchs/Services/AzureBlobStorageService.cs"
|
||||
- "Fuchs_Database/dbo/Stored Procedures/fds__admin_backupAndClearInvoiceFile.sql"
|
||||
- "Fuchs_Database/dbo/Tables/fds__invoice_file_backups.sql"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0027 — Admin invoice-file backup-and-clear action
|
||||
|
||||
## Context
|
||||
|
||||
Support occasionally needs to force a finalised invoice's PDF to be regenerated
|
||||
(e.g. it was rendered with wrong data before a fix, or is otherwise corrupt).
|
||||
Since [0025](0025-invoice-document-regenerate-on-miss.md), clearing
|
||||
`fds__invoices.[file]` on a finalised, non-external invoice is enough to make
|
||||
the next `req/idoc`/`inv/rdoc` read regenerate and re-persist it.
|
||||
|
||||
A manual maintenance proc for this already exists,
|
||||
`[dbo].[_BackupAndClearInvoiceFile]`, but its own header comment states it is
|
||||
"Manual-only maintenance utility. Never called from application code", and
|
||||
0025's "Alternatives considered" explicitly rejected relying on it from the
|
||||
app, partly because it leaves no application log entry. We still want this
|
||||
capability reachable from the app (a hidden, admin-gated action) rather than
|
||||
requiring direct SSMS access, without contradicting that standing decision.
|
||||
|
||||
Additionally, `IBlobStorageService` archives invoice PDFs to Azure Blob
|
||||
Storage under a fixed per-invoice blob name (`{invoiceId}/{fileName}`) and
|
||||
always uploads with `overwrite: true`. If a cleared invoice's document is
|
||||
later regenerated (0025), `StoreInvoiceDocumentFileAsync` re-uploads under
|
||||
that same blob name — silently overwriting the very copy this action is
|
||||
meant to preserve as a backup, unless it is moved first.
|
||||
|
||||
## Decision
|
||||
|
||||
- A **new** proc, `[dbo].[fds__admin_backupAndClearInvoiceFile]`, is added
|
||||
rather than calling `_BackupAndClearInvoiceFile`. It copies the invoice's
|
||||
current `[file]` bytes into a new table, `[dbo].[fds__invoice_file_backups]`
|
||||
(one row per backup, not a timestamp-named ad-hoc table per call), then
|
||||
clears `[file]` in the same transaction (`XACT_ABORT` + TRY/CATCH with
|
||||
rollback and re-raise, so a clear can never commit without its backup row).
|
||||
It logs through the normal
|
||||
`fds__admin_activity` insert (matching `fds__setInvoiceFile`'s pattern) and
|
||||
every call is also logged through the app's `ILogger`/DB-log-sink pipeline
|
||||
(ADR 0024) — unlike the manual proc, this path is fully auditable.
|
||||
`_BackupAndClearInvoiceFile` is left completely untouched as the manual
|
||||
break-glass tool for cases the app can't reach (e.g. a corrupt row the
|
||||
admin UI can't load).
|
||||
- The action applies 0025's regeneration precondition **before** touching
|
||||
anything: only a **finalised, non-external** invoice qualifies. Both the
|
||||
service (`IsDraft`/`IsExternal`) and the proc (`[DateFinalized] IS NOT NULL
|
||||
AND ISNULL([IsExternal],0) = 0`) enforce it. Clearing a draft's `[file]`
|
||||
would let the next read persist a draft render through
|
||||
`fds__setInvoiceFile` (`WHERE [file] IS NULL`), which then blocks the real
|
||||
final PDF forever — the exact defect 0025 calls load-bearing; an external
|
||||
(MFR) document has no template and would just become a permanent 404.
|
||||
- `IBlobStorageService` gains `CopyInvoicePdfToBackupAsync`: a
|
||||
**server-side copy** (`StartCopyFromUriAsync`, bytes never pass through
|
||||
the app) of the existing blob to a timestamped backup name in the same
|
||||
container — the original blob is **kept**, not renamed. A private source in
|
||||
the same storage account is authorised by the request's own Shared Key
|
||||
credential, so no SAS is generated. The copy runs *before* the DB clear,
|
||||
so the archived original is safe before any regenerate-on-read (ADR 0025)
|
||||
could overwrite the original name; because the original stays in place, a
|
||||
DB error or no-op afterwards leaves the canonical archive exactly as it
|
||||
was (with a harmless spare copy). Keeping the original also removes the
|
||||
half-state a copy-then-delete would have ("copied but couldn't delete").
|
||||
Best-effort: a copy failure is logged and counted
|
||||
(`fuchs.blobstorage.backupcopies.failed`), the DB backup row stays the
|
||||
authoritative copy, and the DB step still runs.
|
||||
- The action is exposed as a `Do_Process_Admin` case,
|
||||
`admin/backupclearinvoicefile/<invoiceId>`, gated by the existing
|
||||
`fds_sys > 4` check and restricted to `POST` (a GET must never trigger it,
|
||||
since GETs can be issued by crawlers/prefetchers without user intent). It
|
||||
is not part of any regular workflow UI; its only frontend is a card on the
|
||||
admin-only "Specialties" page (`/specialties`, see
|
||||
`Docs/Concepts/admin-specialties.md`).
|
||||
|
||||
## Consequences
|
||||
|
||||
- Two invoice-file "backup and clear" code paths now exist for different
|
||||
audiences: `_BackupAndClearInvoiceFile` (manual, SSMS, no app log) and
|
||||
`fds__admin_backupAndClearInvoiceFile` (app-triggered, fully logged). Keep
|
||||
them separate — do not merge them, and do not point the manual proc's
|
||||
printed recovery links at the new admin action.
|
||||
- `fds__invoice_file_backups` accumulates one row per backup with no
|
||||
automatic retention/cleanup; if this ever becomes a storage concern, that
|
||||
is a separate decision.
|
||||
- Blob backup-copy is best-effort: if it fails (or storage is
|
||||
disabled/unconfigured), the invoice's DB row is still backed up and
|
||||
cleared — the archived blob is simply left as-is (and will be overwritten
|
||||
by the next regeneration). The DB backup table is the only *guaranteed*
|
||||
copy this action makes.
|
||||
- The blob container accumulates one `backup_<timestamp>_…` copy per action
|
||||
next to the current blob; like `fds__invoice_file_backups`, no retention
|
||||
is applied.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Call `_BackupAndClearInvoiceFile` directly from the app.** Rejected: its
|
||||
own header comment and ADR 0025 both state it must never be
|
||||
application-triggered, and it produces no application log entry — using it
|
||||
here would need updating that comment and (since decisions are immutable)
|
||||
superseding 0025's "Alternatives considered" note, for no real benefit over
|
||||
a small dedicated proc.
|
||||
- **Skip the blob rename and let the next regeneration overwrite the archived
|
||||
blob.** Rejected: the user asked for the blob copy to also survive as a
|
||||
backup, and a silent overwrite on next read would defeat that.
|
||||
@@ -0,0 +1,91 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-09-17
|
||||
applyTo:
|
||||
- "Fuchs/Services/IComService.cs"
|
||||
- "Fuchs/Services/ProcessWebComService.cs"
|
||||
- "Fuchs/Services/ProcessWebComSettings.cs"
|
||||
- "Fuchs/Services/MailerAttachmentPolicy.cs"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0028 — Mailer push_com: multipart attachments, count confirmation, contract pre-validation
|
||||
|
||||
## Context
|
||||
|
||||
The ProcessWeb Mailer API (`push_com`) was extended to accept attachments.
|
||||
Its contract — https://processweb.de/api/mailer/fc — specifies
|
||||
`multipart/form-data` with the JSON envelope in an unnamed form field and one
|
||||
file part per attachment, an optional `attachmentCount` safety check, and a
|
||||
200 response reporting `attachmentsReceived`/`attachmentsProcessed`.
|
||||
|
||||
`ProcessWebComService` predated that: it base64-inlined attachments as
|
||||
`communication.attachments[]` in a JSON body. That property is not part of
|
||||
the contract, so the Mailer accepted the message and dropped the files — a
|
||||
200 for an invoice mail whose PDF never arrived, with no error anywhere. The
|
||||
client also fabricated the audit `guid` (`Guid.NewGuid()`), treated a 409
|
||||
(idempotency duplicate) as a generic API error, and called the legacy
|
||||
`/mailer/fc?fn=verify` and `api.processweb.de` paths.
|
||||
|
||||
## Decision
|
||||
|
||||
1. **Request shape follows the contract exactly.** No attachments →
|
||||
`application/json` envelope. With attachments → `multipart/form-data`:
|
||||
envelope in an unnamed field (`name=""`, set via an explicit
|
||||
`Content-Disposition` because `MultipartFormDataContent.Add` rejects an
|
||||
empty name), then one file part per attachment with real bytes. Field
|
||||
names as in the contract (`comType`, not `comtype`). Base URL
|
||||
`https://processweb.de`, paths `/api/mailer/push_com` and
|
||||
`/api/mailer/verify`.
|
||||
2. **`attachmentCount` is always sent** so the Mailer itself rejects a
|
||||
request whose file parts don't match.
|
||||
3. **A 200 is not success when attachments were sent unless
|
||||
`attachmentsProcessed` equals the number sent.** Anything else — fewer
|
||||
processed, or a body that isn't the contract JSON — is reported to the
|
||||
caller as a failed send (`false`, reason `attachments-mismatch`, counts
|
||||
in the audit log). Callers gate "mark as sent" on that boolean, so an
|
||||
invoice whose PDF was not processed is never flagged as sent.
|
||||
4. **Contract limits are pre-validated client-side** (`MailerAttachmentPolicy`:
|
||||
count, per-file and per-message size, empty file, missing/forbidden
|
||||
extension) with the contract's defaults configurable under
|
||||
`Fuchs:Mailer:Attachments`. A violation fails before any bytes are sent,
|
||||
with the exact rule in the audit log. Empty files are a violation, not
|
||||
silently skipped as before.
|
||||
5. **`comStackID` is the audit key**, falling back to the `Message-Id`
|
||||
header; a local id only when the Mailer returned neither.
|
||||
6. **409 is a distinct failure** (`duplicate`): the Mailer already holds a
|
||||
message under this `reference` and did not queue this one.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `IComService.SendEmailAsync` returning `true` now means "accepted *and*
|
||||
every attachment confirmed processed" — strictly stronger than before.
|
||||
Existing callers (`req/sconf`, reminders, admin test mail) need no change;
|
||||
they already treat `false` as not-sent.
|
||||
- A caller passing a 0-byte attachment now gets `false` instead of a mail
|
||||
without the file. That is intended: an empty PDF is an upstream failure
|
||||
that should surface, not be hidden by a successful-looking send.
|
||||
- The forbidden-extension list in the client is the contract's quoted subset;
|
||||
the server's longer list stays authoritative and still answers 400 for the
|
||||
rest.
|
||||
- The concept doc `Docs/Concepts/processweb-mailer-interface.md` describes
|
||||
the current client behaviour and must be kept in sync when the contract
|
||||
evolves (it is declared backward compatible, so additions rather than
|
||||
breaks are expected).
|
||||
- The verify response is still parsed as `{ ok, account, serverid }`; if the
|
||||
`/api/mailer/verify` endpoint's body differs from the legacy `fc?fn=verify`
|
||||
one, only `VerifyCredentialsAsync`'s DTO needs adjusting.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Keep inline base64 and ask the Mailer to accept it too.** Rejected: the
|
||||
contract is the Mailer's; two accepted shapes would just be a second
|
||||
thing to keep compatible, and base64 inflates every PDF by a third.
|
||||
- **Treat 200 as success regardless of `attachmentsProcessed`.** Rejected —
|
||||
that is exactly the silent-drop failure mode this change exists to close.
|
||||
- **Treat 409 as success ("already sent").** Rejected: a resend under the
|
||||
same reference would then report success without anything being queued;
|
||||
the caller must see it and decide (e.g. use a new reference).
|
||||
- **Skip client-side validation and rely on the server's 400.** Rejected:
|
||||
the 400 body is an opaque string in the audit log; a named rule is what
|
||||
support needs, and it costs nothing.
|
||||
@@ -0,0 +1,101 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-09-17
|
||||
applyTo:
|
||||
- "Fuchs/Services/ProcessWebComService.cs"
|
||||
- "Fuchs/Services/FuchsMailerClient.cs"
|
||||
- "Fuchs/Services/MailerAttachmentPolicy.cs"
|
||||
- "Fuchs/Services/ProcessWebComSettings.cs"
|
||||
- "Fuchs/Program.cs"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0029 — Outbound mail goes through the OCORE_web PWMailer client
|
||||
|
||||
## Context
|
||||
|
||||
Right after [0028](0028-mailer-push-com-multipart-and-attachment-confirmation.md)
|
||||
re-implemented the Mailer API's `push_com` contract by hand in
|
||||
`ProcessWebComService`, OCORE_web gained a complete typed client for that
|
||||
API (`OCORE_web/comm/pwmailer/`, `IPWMailerClientService`): every operation,
|
||||
the JSON/multipart request shapes, `attachmentCount`, typed receipts,
|
||||
Basic auth with Key Vault/encrypted token resolution, `x-pcw-excd` error
|
||||
headers, retries and timeouts — declared to be kept in sync with the
|
||||
server-side controllers. Keeping a second implementation of the same
|
||||
contract in Fuchs would mean two things to update on every API change.
|
||||
|
||||
The client validated a message's required fields and empty/extension-less
|
||||
attachments, but had no hook for application rules (size limits, a
|
||||
file-type deny-list) — `PWMailerMessage` is sealed and `PushAsync` called
|
||||
`Validate` directly.
|
||||
|
||||
## Decision
|
||||
|
||||
1. **`ProcessWebComService` is an adapter over `IPWMailerClientService`.**
|
||||
It keeps only what is Fuchs-specific: recipient validation and the
|
||||
`Fuchs:Email:OverrideRecipient` safety net, the signature, building the
|
||||
`PWMailerMessage`, interpreting the `PWMailerResult`, the `fds__emaillog`
|
||||
audit row and telemetry. All HTTP transport lives in the OCORE client;
|
||||
the hand-rolled `PostPushComAsync`/MIME guessing from 0028 is deleted.
|
||||
2. **Pre-validation is an overridable hook in the client.** OCORE_web's
|
||||
`PWMailerClientService` gains `protected virtual ValidateMessage(PWMailerMessage)`
|
||||
(default: `PWMailerMessage.Validate`), called by `PushAsync` and
|
||||
`PushBulkAsync` before anything is sent. Fuchs overrides it in
|
||||
`FuchsMailerClient` to add the contract's attachment limits
|
||||
(`MailerAttachmentPolicy`: count, per-file/per-message size, forbidden
|
||||
extensions, configurable under `Fuchs:Mailer:Attachments`). A refusal is a
|
||||
`PWMailerResult` with `StatusCode 0` / `Attempts 0`, which the adapter
|
||||
reports as reason `validation` with one audit line per rule.
|
||||
3. **One configuration section.** `Fuchs:Mailer` (`ProcessWebComSettings`)
|
||||
remains the source; `Program.cs` maps it onto `PWMailerClientOptions`
|
||||
(`BaseUrl + /api/mailer`, `AccountId → Account`, `Token`, `ServerId`) and
|
||||
sets `TokenSecretName = "Fuchs--Mailer--Token"`, i.e. the Key Vault secret
|
||||
OCORE secret management already maps into `Fuchs:Mailer:Token`. No second
|
||||
`PWMailer` section, no second secret.
|
||||
4. **0028's behavioural rules stand**, with one correction: multipart with
|
||||
`attachmentCount`, a 200 counts as success only when
|
||||
`attachmentsProcessed` equals the number sent, `comStackID` is the audit
|
||||
key. **409 is now a success, not a `duplicate` failure** (this replaces
|
||||
0028's rule 6): the client documents 409 as "duplicate of a still-pending
|
||||
entry with the same server/reference/content — nothing was enqueued, the
|
||||
earlier entry will be sent". The mail is therefore on its way; the resend
|
||||
paths (`req/resend`, reminders) reuse `inv_<Id>` as the reference, and a
|
||||
double click must not surface as a send error. The deduplication is
|
||||
recorded in the audit row. They are enforced partly by the client (shape,
|
||||
count) and partly by the adapter (processed check, audit, 409 mapping).
|
||||
5. Two small OCORE_web fixes made in the same change: `PWMailerMessage.Validate`
|
||||
no longer demands a subject for `comType "s"` (SMS), and a call refused
|
||||
by validation reports `Attempts = 0` as its documentation already stated.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Fuchs no longer asserts the wire format in its own tests; that lives in
|
||||
`OCORE_web/OCORE_webTests/comm/PWMailerClientServiceTests.cs`. Fuchs tests
|
||||
assert the `PWMailerMessage` handed to the client and the interpretation of
|
||||
each `PWMailerResult` shape, plus `FuchsMailerClient`'s refusals.
|
||||
- OCORE_web is a submodule owned by another repository: the hook, the SMS
|
||||
rule and the `Attempts` fix are a commit there and a pointer bump here.
|
||||
- The client is a singleton with a cached token; after a token rotation
|
||||
`IPWMailerClientService.ResetToken()` (on the concrete type) is the way to
|
||||
pick it up without a restart — not wired to any admin action yet.
|
||||
- Empty attachments are still refused (now by the client's base rule), so
|
||||
an upstream render failure never produces a mail without its PDF. The
|
||||
flip side: a caller attaching an *optional* file must skip an empty byte
|
||||
array itself — the reminder's invoice copy (`IntranetController.Reminder.cs`)
|
||||
does, otherwise a never-stored invoice file would fail the whole reminder
|
||||
instead of sending it without the copy.
|
||||
- The client's `getCom`/`getComStatus`/`abortCom` and inbox operations are
|
||||
available to Fuchs for free (e.g. a delivery-status column keyed by the
|
||||
audited `comStackID`) — not used yet.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Keep the 0028 hand-rolled transport.** Rejected: duplicate contract
|
||||
implementation, and the OCORE client is the one declared to track the
|
||||
server.
|
||||
- **Pre-validate in the adapter only (before `PushAsync`).** Rejected: the
|
||||
rules belong with the client's own validation so every OCORE consumer can
|
||||
apply them the same way, and the user asked for an overridable hook.
|
||||
- **Bind a separate `PWMailer` config section.** Rejected: `Fuchs:Mailer` is
|
||||
already Key Vault-managed and read by the Admin status view; mapping in
|
||||
code avoids a second token secret and drift between two sections.
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-09-17
|
||||
applyTo:
|
||||
- "Fuchs_Database/dbo/Stored Procedures/fds__admin_backupAndClearInvoiceFile.sql"
|
||||
- "Fuchs_Database/dbo/Tables/fds__invoice_file_backups.sql"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0030 — Invoice-file backup carries a JSON snapshot of the invoice data
|
||||
|
||||
## Context
|
||||
|
||||
ADR 0027 introduced `[dbo].[fds__invoice_file_backups]`: one row per
|
||||
admin backup-and-clear action holding the invoice's `[file]` bytes before
|
||||
they are cleared for regeneration (ADR 0025). The backup held only the
|
||||
rendered PDF. The data it was rendered from — the `fds__invoices` row, its
|
||||
`fds__invoice_items` rows, its `fds__invoice_details` row and its
|
||||
`fds__invoice_servicerequests` link rows — lives on in
|
||||
the live tables and keeps changing (payment status, modification stamps,
|
||||
later edits). A backed-up file could therefore no longer be matched against
|
||||
the data state it was produced from, which is exactly the question the
|
||||
backup exists to answer when a regenerated document differs from the
|
||||
original.
|
||||
|
||||
## Decision
|
||||
|
||||
- Each backup row additionally stores four JSON snapshots, written in the
|
||||
same statement and transaction as the file copy:
|
||||
- `InvoiceJson` — the `fds__invoices` row (`FOR JSON PATH,
|
||||
WITHOUT_ARRAY_WRAPPER`), **without** `[file]`, which is already stored as
|
||||
bytes in the row and would otherwise be base64-duplicated;
|
||||
- `InvoiceItemsJson` — all `fds__invoice_items` rows for the invoice as a
|
||||
JSON array, ordered by `SortOrder`, `Position`, `Id`;
|
||||
- `InvoiceDetailsJson` — the `fds__invoice_details` row, or `NULL` if the
|
||||
invoice has none;
|
||||
- `InvoiceServiceRequestsJson` — all `fds__invoice_servicerequests` rows
|
||||
for the invoice as a JSON array, ordered by `SortOrder`, `Id`. Not part
|
||||
of the rendered document, but part of the invoice's data set.
|
||||
- The snapshots are JSON columns on the **same** backup row rather than
|
||||
four mirror tables: one row is one consistent set (file + data), the
|
||||
atomicity guarantee of ADR 0027 covers it without extra keys, and adding a
|
||||
column to an invoice table does not require touching the backup schema.
|
||||
The item/detail/service-request snapshots use `SELECT *`, so new columns appear
|
||||
automatically; the invoice snapshot lists its columns explicitly only to
|
||||
exclude `[file]` — a new `fds__invoices` column must be added to that
|
||||
list.
|
||||
- Restore is manual (`OPENJSON` in SSMS); no app-side reader is added. The
|
||||
snapshots are forensic evidence, not a rollback mechanism.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Backup rows grow by the size of the invoice's data (small next to the
|
||||
PDF). No retention policy is added — same standing as ADR 0027.
|
||||
- `INCLUDE_NULL_VALUES` is used so a `NULL` in the snapshot is
|
||||
distinguishable from a column that did not exist at backup time.
|
||||
- Adding a column to `fds__invoices` requires updating the explicit column
|
||||
list in the proc; `fds__invoice_items` / `fds__invoice_details` /
|
||||
`fds__invoice_servicerequests` need no change.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Mirror tables** (`fds__invoice_backups`, `fds__invoice_items_backups`,
|
||||
…) keyed by backup `uid`: typed and queryable, but four schemas that
|
||||
drift with every invoice-table change and four inserts to keep atomic.
|
||||
Rejected for maintenance cost; the snapshots are read rarely and by hand.
|
||||
- **Snapshot only the invoice header**: the items are what the PDF body is
|
||||
rendered from; leaving them out would not answer "what did this file
|
||||
contain and why".
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-09-09
|
||||
applyTo:
|
||||
- "OCORE/OCORE/Secrets/**"
|
||||
- "OCORE_web/OCORE_web/Secrets/**"
|
||||
- "Fuchs/Program.cs"
|
||||
- "Fuchs/appsettings*.json"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0031 — Shared Key Vault + DPAPI secret management via OCORE/OCORE_web
|
||||
|
||||
## Context
|
||||
Fuchs is one of several applications (web apps, console apps, Windows
|
||||
services, worker services) running on the same Windows server. Secrets
|
||||
(connection strings, API keys, mailer tokens, etc.) previously lived in
|
||||
plain text in `appsettings.json`/`web.config`/environment variables. A
|
||||
single design needed to work identically across all host types — not just
|
||||
ASP.NET Core — while keeping secrets out of source control and available
|
||||
even when the Key Vault is briefly unreachable.
|
||||
|
||||
## Decision
|
||||
- **One shared Azure Key Vault** (`pcwkeys`) for all applications on the
|
||||
server, rather than a per-app vault. Access control happens at the
|
||||
secret level (RBAC), not at the vault level.
|
||||
- **`{appname}--` prefix namespacing** on every secret name (e.g.
|
||||
`fuchs--ConnectionStrings--ocms-ConnectionString`) prevents collisions
|
||||
between apps sharing the vault. Naming/encoding rules are recorded in
|
||||
`.github/copilot-instructions.md` under "Azure Key Vault — Secret
|
||||
Naming".
|
||||
- **Core logic lives in `OCORE`** (`OCORE/Secrets/Models/AppSecretsOptions.cs`,
|
||||
`Cache/DpapiSecretsCache.cs`, `Cache/DpapiCacheConfigurationSource.cs`,
|
||||
`Sync/KeyVaultSyncService.cs`, `Extensions/SecretManagementExtensions.cs`,
|
||||
`Extensions/PrefixKeyVaultSecretManager.cs`, `Extensions/SecretsOnDemandLoader.cs`)
|
||||
because it has no web dependency and must also work for console apps,
|
||||
Windows services, worker services, and WinForms/WPF. `OCORE_web` adds a
|
||||
single thin file (`Secrets/SecretManagementWebExtensions.cs`) exposing
|
||||
`WebApplicationBuilder.AddSecretManagement()` for ASP.NET Core hosts;
|
||||
it calls back into `OCORE`'s shared logic rather than duplicating it.
|
||||
- **Local DPAPI cache as offline fallback**: secrets synced from Key Vault
|
||||
are also encrypted to `secrets.cache` using Windows DPAPI with
|
||||
`DataProtectionScope.LocalMachine` plus app-specific entropy derived
|
||||
from `AppName`, so one app on the server cannot decrypt another app's
|
||||
cache file, and the app can still start if Key Vault is briefly
|
||||
unreachable.
|
||||
- **One shared server-level Managed Identity** (via `DefaultAzureCredential`)
|
||||
for all apps, rather than 15 separate identities — access is scoped by
|
||||
the `{appname}--` secret prefix via Key Vault RBAC, not by identity.
|
||||
|
||||
## Consequences
|
||||
- Fuchs calls `builder.AddSecretManagement()` (`Fuchs/Program.cs`) before
|
||||
`FuchsOcmsIntranet.Initialize(configuration)` — secrets must be resolved
|
||||
before any singleton reads `IConfiguration`.
|
||||
- New secrets must be added to Key Vault under the `fuchs--` prefix *and*
|
||||
registered in `ManagedSecretKeys` in `Fuchs/appsettings.json` (see
|
||||
`.github/copilot-instructions.md`), or `KeyVaultSyncService` will never
|
||||
pick them up.
|
||||
- A secret whose name is only known at runtime (not a fixed config key)
|
||||
bypasses `ManagedSecretKeys`/`KeyVaultSyncService` entirely and is
|
||||
fetched directly via `OCORE.Secrets.Extensions.SecretsOnDemandLoader`
|
||||
instead.
|
||||
- Any other application added to the shared server follows the same
|
||||
pattern: reference `OCORE_web` (web) or `OCORE` (non-web), set a unique
|
||||
`AppName`, and grant its prefix `Get`/`List` access in the shared vault.
|
||||
- Detailed wiring/encoding reference (kept current independently of this
|
||||
ADR) lives in `OCORE_web/.github_instructions/secretsmanagement.instructions.md`.
|
||||
|
||||
## Alternatives considered
|
||||
- **Per-app Key Vaults** — rejected: 15x the management overhead for no
|
||||
isolation benefit over prefix namespacing + secret-level RBAC.
|
||||
- **Per-app Managed Identities** — rejected: requires provisioning and
|
||||
maintaining 15 separate identities; a shared identity with per-secret
|
||||
RBAC achieves the same isolation.
|
||||
- **Environment variables only, no local cache** — rejected: no
|
||||
offline/startup resilience if Key Vault is briefly unreachable.
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-09-30
|
||||
applyTo:
|
||||
- "Fuchs_Database/dbo/Functions/fds__fn_invoiceBackdate.sql"
|
||||
- "Fuchs_Database/dbo/Stored Procedures/fds__setInvoiceFinal.sql"
|
||||
- "Fuchs/code/InvoiceDates.cs"
|
||||
- "Fuchs/Services/ERechnungMapper.cs"
|
||||
- "Fuchs/Services/InvoiceService.cs"
|
||||
- "Fuchs/Controllers/IntranetController.InvoiceDraft.cs"
|
||||
- "Fuchs/Controllers/IntranetController.Requests.cs"
|
||||
- "Fuchs/js/intranet/modules/fis.inv_shared.js"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0032 — Invoices may be backdated to the last working day of the previous month (admins only)
|
||||
|
||||
## Context
|
||||
|
||||
Work done at the end of a month is often invoiced in the first days of the next month. The customer
|
||||
wants those invoices dated to the previous month. The invoice date was always the moment of
|
||||
finalisation (`fds__setInvoiceFinal`: `DateFinalized = GETUTCDATE()`), and the invoice number is
|
||||
drawn from the year of that date (`fds__newInvoiceId(Year(...))`).
|
||||
|
||||
Two invoice dates also existed side by side: the PDF printed `DateCreated` (draft creation) as
|
||||
"Rechnungsdatum", while the embedded ZUGFeRD/XRechnung issue date (BT-2) used `DateFinalized` — a
|
||||
visible date and an e-invoice date that could disagree.
|
||||
|
||||
## Decision
|
||||
|
||||
- **One invoice date.** PDF "Rechnungsdatum" and e-invoice issue date both come from
|
||||
`InvoiceDates.IssueDate`: `DateFinalized` (converted from the stored UTC to the German calendar
|
||||
day), falling back to `DateCreated` for drafts.
|
||||
- **Backdating option.** When finalising, a user with `fds_inv > 2` may date the invoice to the
|
||||
**last working day (Mon–Fri) of the previous month** — a fixed date, not a free choice. Public
|
||||
holidays are not excluded (possible later extension).
|
||||
- **Only while no invoice exists after that date.** Invoice numbers must ascend with invoice dates,
|
||||
so the option is offered and accepted only while no invoice has been finalized after the backdate
|
||||
day (a 2 h margin covers the UTC storage).
|
||||
- **Single source of truth:** `[dbo].[fds__fn_invoiceBackdate](@authuser)` returns the allowed date or
|
||||
NULL (authorisation + eligibility). `inv/dpreview` returns it so the preview dialog shows the
|
||||
per-invoice checkbox; `req/sconf` passes `@backdate = 1`, and `fds__setInvoiceFinal` re-evaluates
|
||||
the function and `THROW`s (60001) when it is no longer allowed — nothing is finalised then.
|
||||
- **Serialized finalisation.** `fds__setInvoiceFinal` runs check + numbering `UPDATE` in one
|
||||
transaction under an exclusive, transaction-owned app lock (`sp_getapplock`, resource
|
||||
`fds__setInvoiceFinal`) taken by every finalisation, so a concurrent normal finalisation cannot
|
||||
slip between the eligibility check and the backdated write and break the date/number order.
|
||||
- `req/sconf` maps only SQL error 60001 to the "no longer possible, create without backdating"
|
||||
message. A 0-row result (invoice already final/sent, e.g. a double submit) is reported as
|
||||
"already finalised" — never as a backdate rejection, which would invite a duplicate invoice.
|
||||
- A backdated invoice is stored with `DateFinalized = <day> 12:00` (UTC, stays on the same German
|
||||
day), its number comes from that day's year, and `DueDate` is recomputed from the payment term
|
||||
anchored on that day.
|
||||
## Consequences
|
||||
|
||||
- The PDF of every newly finalised invoice now shows the finalisation date instead of the draft
|
||||
creation date (intended: it now matches the e-invoice and the number sequence).
|
||||
- In January the option yields a December date and therefore a number from the previous year's range.
|
||||
- Lists, reports, DATEV and reminders follow the same invoice date — see ADR 0035.
|
||||
- Telemetry: `fuchs.invoices.backdated` (outcome ok / rejected / already_final / error); successful
|
||||
backdating logs EventId `InvoiceBackdated`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Free date picker for admins** — rejected: breaks the date/number order too easily; the need is
|
||||
exactly "previous month's last working day".
|
||||
- **Global admin switch "backdate everything until the first invoice of the month"** — rejected by
|
||||
the customer in favour of an explicit per-invoice checkbox.
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-09-30
|
||||
applyTo:
|
||||
- "Fuchs_Database/dbo/Tables/mfr__*.sql"
|
||||
- "Fuchs_Database/dbo/User Defined Types/mfr__tt__*.sql"
|
||||
- "Fuchs_Database/dbo/Stored Procedures/mfr__updt__*.sql"
|
||||
- "Fuchs_DataService/FdsMfrClient.cs"
|
||||
- "Fuchs_DataService/FdsColumnWidths.cs"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0033 — mfr free-text columns are NVARCHAR(MAX); table, table type and copies stay aligned
|
||||
|
||||
## Context
|
||||
mfr publishes no length limits for its string properties (`MFR_RESTClient/Docs/mfr_interface_description.md`).
|
||||
`[mfr__serviceobjects].[Note]` and `[mfr__companies].[Note]` were `NVARCHAR(255)`, as were their
|
||||
merge types `mfr__tt__serviceobjects` / `mfr__tt__companies`. From 2026-09-25, users typed longer
|
||||
notes in mfr (ServiceObject up to 753 chars, Company up to 723). The `mfr__updt__*` merge proc copies
|
||||
the staging table into the table type, so SQL Server rejected every batch holding such a row
|
||||
(msg 8152, which names no column). That included every Company/Contact/ServiceRequest sync that
|
||||
`$expand`s the row. The result was about 1000 errors in five days, plus rows the short sync never
|
||||
re-fetched, because it only reads rows newer than the local `DateModified` watermark. Merge failures
|
||||
were silent until 8bcec40 began logging them, so earlier occurrences went unnoticed.
|
||||
|
||||
## Decision
|
||||
- Free-text mfr properties (`Note`, `Description`, `Comment`, …) are stored as `NVARCHAR(MAX)`.
|
||||
Bounded widths are kept only for values mfr structurally keeps short (ids, codes, names), and
|
||||
those are revisited when data proves otherwise.
|
||||
- A width change is made in the target table, its `mfr__tt__*` type **and** every table variable
|
||||
or type that copies the row with `SELECT *` (e.g. `@company` in `fds__getRequest_details`,
|
||||
`fds__prepInvoice`, `fds__prepStorno_recreate`), all in the same change. Otherwise the truncation
|
||||
just moves to the next copy.
|
||||
- Truncating at the mapping layer was rejected. The data service does **not** truncate
|
||||
silently. Instead, when a merge fails with 8152/2628, `FdsColumnWidths` compares the staged
|
||||
batch against the narrower of the table and type widths. The error log then names column, longest
|
||||
length, limit and an entity Id (`over-long columns: Note: 753 > 255 chars (1 row, e.g. Id=…)`).
|
||||
This lookup runs only on the failure path.
|
||||
|
||||
## Consequences
|
||||
- Changing a `mfr__tt__*` type means DROP + CREATE (types cannot be ALTERed). The procs that
|
||||
DECLARE the type must be dropped first and recreated. See
|
||||
`Fuchs_Database/Scripts/2026-09-30_mfr_truncation_fix.sql` for the pattern, including grants.
|
||||
- After fixing a rejected merge, queue the affected Ids in `fds__mfr_updaterequests`. The short
|
||||
sync will not revisit them, and the nightly full reset is disabled in `fds__getUpdateableTables`.
|
||||
- `NVARCHAR(MAX)` columns cannot be index key columns. None of the widened columns is indexed.
|
||||
|
||||
## Alternatives considered
|
||||
- **Truncate to the column width in `SetRowValue`**: this would keep syncing, but it loses user text
|
||||
silently. For keys such as `ExternalId` (DATEV customer number) it would corrupt data. Rejected.
|
||||
- **Widen to a larger fixed size (e.g. 1000, like `mfr__contacts.Note`)**: this just moves the
|
||||
cliff. mfr enforces no limit. Rejected for free text.
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-09-30
|
||||
applyTo:
|
||||
- "Fuchs_Database/dbo/Functions/fds__fn_IntermediateIsAllocatedToOther.sql"
|
||||
- "Fuchs_Database/dbo/Stored Procedures/fds__setInvoiceFinal.sql"
|
||||
- "Fuchs_Database/dbo/Stored Procedures/fds__prepInvoice.sql"
|
||||
- "Fuchs_Database/dbo/Stored Procedures/fds__prepStorno_recreate.sql"
|
||||
- "Fuchs_Database/dbo/Stored Procedures/fds__createStorno_*.sql"
|
||||
- "Fuchs_Database/Scripts/2026-09-30_backfill_intermediate_invoice_allocations.sql"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0034 — An Abschlagsrechnung credit follows the live invoice
|
||||
|
||||
## Context
|
||||
|
||||
A Schlussrechnung credits the Abschlagsrechnungen (intermediate invoices, `InvoiceType = 'i'`)
|
||||
already paid for the same service requests via `IntInvoiceAllocation` lines. Finalising it records
|
||||
the claim in `fds__invoice_details.AllocatedTo_InvId` of each credited intermediate so that
|
||||
`fds__prepInvoice` does not offer the same Abschlagsrechnung again.
|
||||
|
||||
Until 2026-09-30 that claim was never actually written (the editor dropped `reference_InvId`, the
|
||||
MERGE aborted on the NULL). The moment it is written, the question "what happens to the claim when
|
||||
the crediting invoice is cancelled?" becomes real: a Storno (simple/copy) or a Storno + recreate
|
||||
leaves `AllocatedTo_InvId` pointing at the cancelled invoice, and the next invoice for the same
|
||||
service requests would silently bill the customer again for money already paid.
|
||||
|
||||
## Decision
|
||||
|
||||
- **A claim counts only while the crediting invoice is alive.** An `AllocatedTo_InvId` that points
|
||||
at a cancelled invoice (`IsCanceled = 1`), at a non-existent invoice, or at the invoice that the
|
||||
one being finalised replaces (`CanceledInvoice` line → `@Replaces`) is void: the Abschlagsrechnung
|
||||
is offered again by `fds__prepInvoice` / `fds__prepStorno_recreate` and may be taken over by the
|
||||
next finalisation. The single source of truth for "already credited elsewhere" is
|
||||
`[dbo].[fds__fn_IntermediateIsAllocatedToOther]`; `fds__setInvoiceFinal`'s `existing` CTE is its
|
||||
set-based twin and must apply the same rule.
|
||||
- **The link is not cleared on cancellation.** Cancellation has several paths (storno simple/copy,
|
||||
storno + recreate, the retired `fds__setInvoiceCancelled`); rather than releasing the link in each,
|
||||
the readers ignore dead links. The last claimant therefore stays visible for auditing.
|
||||
- **A Stornorechnung (`InvoiceType = 'c'`) never claims.** `fds__createStorno_copy` mirrors the
|
||||
cancelled invoice's `IntInvoiceAllocation` lines (with their references) so the storno document
|
||||
shows them, but finalising the storno must not move the allocation onto it.
|
||||
- **Storno + recreate carries the references.** `fds__prepStorno_recreate` emits its credit lines
|
||||
with the same type as `fds__prepInvoice` (`IntInvoiceAllocation`, not `PartialInvoice`) and the
|
||||
credited invoice in `reference_InvId`, and its `CanceledInvoice` line references the replaced
|
||||
invoice — otherwise the replacement neither takes over the credit nor records what it replaces.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The recreate flow now produces `Replaces_InvId` / `DateCancelled` on the replaced invoice via the
|
||||
regular `fds__setInvoiceFinal` bookkeeping (it did not before: no reference reached the proc).
|
||||
- A second recreate (cancel the replacement, recreate again) keeps the credit because the claim on
|
||||
the replaced invoice is treated as void during the replacement's finalisation.
|
||||
- The one-off backfill script records the *earliest* live claimant; it is consistent with this rule.
|
||||
- An intermediate that is itself cancelled is not offered (unchanged).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Release `AllocatedTo_InvId` when the crediting invoice is cancelled** — rejected: four write
|
||||
paths to keep in sync, and the audit trail of who last credited the Abschlagsrechnung is lost.
|
||||
- **Keep `PartialInvoice` for recreated credit lines and teach `fds__setInvoiceFinal` both types** —
|
||||
rejected: one line type per meaning; `PartialInvoice` remains the Abschlagsbetrag charge line of an
|
||||
intermediate invoice.
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-09-30
|
||||
applyTo:
|
||||
- "Fuchs_Database/dbo/Functions/fds__fn_invoiceDate.sql"
|
||||
- "Fuchs_Database/dbo/Functions/fds__r_getBalanceThisMonth.sql"
|
||||
- "Fuchs_Database/dbo/Stored Procedures/fds__getDatevExports.sql"
|
||||
- "Fuchs_Database/dbo/Stored Procedures/fds__getInvoices_list*.sql"
|
||||
- "Fuchs_Database/dbo/Stored Procedures/fds__getFDSDocument.sql"
|
||||
- "Fuchs_Database/dbo/Stored Procedures/fds__getReportDocument.sql"
|
||||
- "Fuchs_Database/dbo/Stored Procedures/fds__getRequest_details.sql"
|
||||
- "Fuchs_Database/dbo/Stored Procedures/fds__r_*.sql"
|
||||
- "Fuchs_Database/dbo/Stored Procedures/fds__*Reminder*.sql"
|
||||
- "Fuchs/code/InvoiceDates.cs"
|
||||
- "Fuchs/Services/ReminderService.cs"
|
||||
- "Fuchs/Services/*DraftEditService.cs"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0035 — The invoice date is `DateFinalized` everywhere
|
||||
|
||||
## Context
|
||||
|
||||
ADR 0032 made `DateFinalized` the invoice date for the PDF, the e-invoice (BT-2) and the number
|
||||
sequence, and introduced backdating (which only moves `DateFinalized`). Everything else — invoice
|
||||
lists, revenue reports and charts, the DATEV export, document lookups, the request details — still
|
||||
dated and grouped invoices by `DateCreated`, the moment the *draft* was created. A backdated invoice
|
||||
therefore showed September on the document and October in every list, report and in DATEV, and any
|
||||
invoice drafted in one month and finalised in the next had the same split.
|
||||
|
||||
## Decision
|
||||
|
||||
- **One invoice date, everywhere:** the German calendar day of `DateFinalized`, falling back to
|
||||
`DateCreated` only for drafts. Both columns are stored in UTC.
|
||||
- **One implementation per side:** `[dbo].[fds__fn_invoiceDate](DateFinalized, DateCreated)` in SQL and
|
||||
`InvoiceDates.IssueDate` in C#. No proc converts or picks the date itself.
|
||||
- Applies to: the DATEV export (period filter, `Belegdatum`, `Belegfeld 2`), the invoice lists
|
||||
(`fds__getInvoices_list`, `list2`, `list_vario`: period filter, `Belegdatum`, `DateOfCreation`,
|
||||
ordering, overdue fallback), the revenue reports (`fds__r_*`: year/month filters and grouping,
|
||||
`fds__r_getBalanceThisMonth`), document lookups (`fds__getFDSDocument`, `fds__getReportDocument`),
|
||||
`fds__getRequest_details` (new column `InvoiceDate`, shown in the request's invoice list), and the
|
||||
invoice date shown in reminders (`fds__prepReminder`, `fds__createReminder`, `fds__getReminder`).
|
||||
- **Reminders use the same rule for their own date:** the reminder PDF's date is the reminder's
|
||||
`DateFinalized` (German day), draft: `DateCreated`.
|
||||
- **No transition safeguard.** The switch applies to all periods, past and future; already exported
|
||||
DATEV periods are not reconciled (confirmed by the business owner, 2026-09-30).
|
||||
- `DateCreated` keeps its meaning — the creation timestamp of the row — and is still returned where
|
||||
callers read it as such. Draft previews stamp it with `DateTime.UtcNow` to match the stored column.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Reports and exports for a past period can differ from what they showed before this change: an
|
||||
invoice drafted in month M and finalised in M+1 now counts in M+1.
|
||||
- Filters on the invoice date are not SARGable (they were not before either: `CAST(DateCreated as date)`).
|
||||
Acceptable at the current table size; an indexed computed column is the follow-up if it matters.
|
||||
- Any new list, report or export that shows or groups invoices by date must use
|
||||
`fds__fn_invoiceDate` — never `DateCreated` or a raw `DateFinalized`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Keep `DateCreated` in lists/reports, `DateFinalized` only on documents** — rejected: the same
|
||||
invoice would carry two dates, and backdating would never reach the books.
|
||||
- **Transition safeguard for already exported DATEV periods** — rejected by the business owner.
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-09-30
|
||||
applyTo:
|
||||
- "Fuchs_Database/**"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0036 — The database project build is the T-SQL gate
|
||||
|
||||
## Context
|
||||
|
||||
A rewrite of `fds__getDatevExports` added a `JOIN @inv as i` next to `fds__invoices as iv`
|
||||
and left an unqualified `[file_guid]` in the select list. SQL Server rejects that statement
|
||||
with Msg 209 (ambiguous column name) — but only when it runs, and the working-tree SQL runs
|
||||
nowhere: the schema lives in the `Fuchs_Database` SSDT project, the dev database only gets
|
||||
what is explicitly published to it, and the xUnit suite cannot bind T-SQL. The defect reached
|
||||
code review as the only check.
|
||||
|
||||
The SSDT build would have caught it: it reported the line as `SQL71502` ("unresolved reference
|
||||
… or ambiguous"). Nobody saw that because
|
||||
|
||||
- the project did not build at all — the two `CREATE USER … FOR LOGIN` files referenced logins
|
||||
that were not in the model, which SSDT treats as error `SQL71501`;
|
||||
- the genuine warning sat among ~160 identical `SQL71502` warnings caused by references to
|
||||
`sys.*`, `INFORMATION_SCHEMA`, `sp_executesql`, `sp_getapplock` (no `master` reference) and
|
||||
to `ocms_fn_generatePassword`, an OCMS helper the project never contained;
|
||||
- a warning does not fail anything.
|
||||
|
||||
The same sweep found that `ocms_fn_generatePassword` (and its dependency `ocms_fn_randbetween`)
|
||||
is missing on the dev database as well, so every id-generating function (`fds__fn_invoice_id`,
|
||||
`fds__fn_reminder_id`, `fis_fn_id`, …) fails there with Msg 4121.
|
||||
|
||||
## Decision
|
||||
|
||||
1. `FuchsDatabase.sqlproj` references the `master` system dacpac
|
||||
(`$(DacPacRootPath)\…\SqlServer\170\SQLSchemas\master.dacpac`), so system objects bind.
|
||||
2. Everything the SQL references is in the project. The OCMS helpers `ocms_fn_generatePassword`
|
||||
and `ocms_fn_randbetween` are vendored copies of the OCMSsharp `ocms_mt_database` files.
|
||||
`Security/Logins.sql` scripts the two application logins with a placeholder,
|
||||
`MUST_CHANGE` password so the users bind; real passwords stay in Key Vault and SSDT never
|
||||
compares passwords, so existing logins are untouched on publish.
|
||||
3. `TreatTSqlWarningsAsErrors` is on. Suppressed: `71501` (dynamic SQL / cross-database
|
||||
references) and `71558` (identifier differs only by case — harmless under the `CI` model
|
||||
collation). Every other T-SQL warning, in particular `SQL71502`, fails the build.
|
||||
4. Building the database project is part of the workflow whenever `Fuchs_Database/**` changes
|
||||
(`msbuild Fuchs_Database/FuchsDatabase.sqlproj -p:Configuration=Debug`, from the Visual
|
||||
Studio MSBuild — `dotnet build` cannot build the legacy SSDT project format). A red database
|
||||
build is a failure like a red C# build.
|
||||
5. The C# side of the contract is a test: `Fuchs.Tests/SqlContractTests` scans every
|
||||
`[dbo].[…]` named in C# strings (`Fuchs`, `Fuchs_DataService`) and checks it against the
|
||||
SSDT files — the object exists, every `EXECUTE` binds (no unknown names, no surplus
|
||||
positional arguments, every parameter without a default supplied), every function call
|
||||
passes the declared argument count, and every `mfr__updt__*` proc declares the
|
||||
`@tblname, @referencetable, @tgtid` signature `FdsMfrClient` calls dynamically.
|
||||
|
||||
## Consequences
|
||||
|
||||
- An unqualified column that becomes ambiguous, a typo in a column name, or a call to a
|
||||
function that is not in the project is a build error before review, not a Msg 209/4121 at
|
||||
a customer.
|
||||
- `SELECT *` inside `EXISTS (…)` over joined tables trips `SQL71502` in SSDT even though SQL
|
||||
Server accepts it; write `SELECT 1` there (done for `fds__fn_IntermediateIsAllocatedToOther`
|
||||
and `fis_admin_testGroupMembership`).
|
||||
- New procedures that use `sys.*`/`sp_*` need no suppression any more. A new reference that is
|
||||
genuinely outside the model (another database, dynamic SQL) is suppressed per statement or
|
||||
by adding the object to the project — never by widening `SuppressTSqlWarnings` for `71502`.
|
||||
- A proc signature change that breaks a C# caller fails `dotnet test`, not the first request.
|
||||
Only literal SQL is covered: dynamically built names (`FuchsVisualization`'s report procs,
|
||||
resolved from `sys.parameters` at runtime) and result-set column names are not.
|
||||
- The build proves binding, not behaviour: publishing to the dev database (`SqlPackage
|
||||
/Action:Publish` with `ExcludeObjectTypes=Logins;Users;Permissions;RoleMembership`, or the
|
||||
schema compare) and exercising the code path remains the runtime check.
|
||||
- The dev database needs the two `ocms_*` helpers published before any id-generating path
|
||||
works there.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Run the changed procedures against the dev database before review.** Needs a publish of
|
||||
every working-tree change first, and does not cover code paths the reviewer does not think
|
||||
to exercise; the compile-time check is free and total.
|
||||
- **Suppress `SQL71502` and keep the build warning-free that way.** Removes exactly the signal
|
||||
that catches this class of bug.
|
||||
- **A C#-side test that parses the SQL for unqualified columns.** A parser-level heuristic
|
||||
without a schema cannot decide ambiguity; the SSDT model can.
|
||||
@@ -97,7 +97,9 @@ payload; see `EVAL_live_invoice_editing.md` for the rationale.
|
||||
it drives the EN 16931 eRechnung and composes the free-text `SendToAddress` for the
|
||||
PDF (see [`Concepts/erechnung-output.md`](Concepts/erechnung-output.md)). The **service
|
||||
date/period** (Leistungsdatum/-zeitraum) is likewise a structured German-date dialog
|
||||
(`$inv.eProvisionPeriod`) — a single date or a from/to range — mapped to BT-72 / BG-14.
|
||||
(`$inv.eProvisionPeriod`) — a single date or a from/to range — mapped to BT-72 / BG-14
|
||||
and printed in the PDF's admin block (`InvoiceService.ApplyProvisionPeriod`; legacy ISO
|
||||
values are shown as German dates).
|
||||
- **§13b reverse-charge** toggle (`$inv.sp13b`) — suppresses VAT lines/columns.
|
||||
- **Set-pricing display mode** (`$inv.ssetmode` / `setSetmode`) — `SetPrice`
|
||||
(default) / `SetOnly`; see `INVOICE_SET_PRICING.md`. Purely presentational
|
||||
@@ -181,6 +183,36 @@ Confirming the preview modal posts to **`req/sconf`**
|
||||
`[InvoiceId] = fds__newInvoiceId(YEAR())` → format `R<year>-<0000>`
|
||||
(the draft only ever had the internal numeric `Id`). `Version` is bumped
|
||||
and any linked "replaces" bookkeeping (storno/credit chains) is updated.
|
||||
- **Invoice date** = `DateFinalized` — printed on the PDF and used as the
|
||||
e-invoice issue date (`InvoiceDates.IssueDate`, ADR 0032).
|
||||
- **Backdating (ADR 0032):** users with `fds_inv > 2` see a checkbox in the
|
||||
preview dialog (`inv/dpreview` returns `backdate`) to date the invoice to
|
||||
the last working day (Mon–Fri) of the previous month, as long as no
|
||||
invoice has been finalised after that day. `req/sconf` then passes
|
||||
`@backdate = 1`; `fds__fn_invoiceBackdate` is re-checked and the proc
|
||||
throws 60001 (nothing finalised) if it is no longer allowed. Number year and
|
||||
`DueDate` follow the backdated day. The proc serializes all finalisations
|
||||
(transaction + `sp_getapplock`) so the check and the numbering cannot
|
||||
interleave with a concurrent finalisation. The controller maps 60001 to
|
||||
the "create again without backdating" message; a 0-row result (already
|
||||
final/sent, e.g. double submit) is reported as "already finalised".
|
||||
- **Same date everywhere (ADR 0035):** invoice lists, revenue reports,
|
||||
the DATEV export (period + `Belegdatum`), request details and reminders
|
||||
all use `[dbo].[fds__fn_invoiceDate]` — `DateFinalized` as German
|
||||
calendar day, drafts fall back to `DateCreated` — so a backdated invoice
|
||||
appears in the previous month everywhere, including DATEV. The reminder
|
||||
PDF's own date follows the same rule (reminder `DateFinalized`).
|
||||
- **Abschlagsrechnungen (ADR 0034):** `IntInvoiceAllocation` lines carry the
|
||||
credited intermediate invoice in `reference_InvId` (seeded by
|
||||
`fds__prepInvoice` and `fds__prepStorno_recreate`, round-tripped by the
|
||||
editor as item `ref`); finalisation records it in
|
||||
`fds__invoice_details.AllocatedTo_InvId` so the same Abschlagsrechnung is
|
||||
not offered again. The claim only counts while the crediting invoice is
|
||||
alive: once it is cancelled (storno, or replaced via storno + recreate)
|
||||
`fds__fn_IntermediateIsAllocatedToOther` treats the Abschlagsrechnung as
|
||||
free again and the next finalisation takes the allocation over. A
|
||||
Stornorechnung (`'c'`) never claims an allocation, even though a storno
|
||||
copy mirrors the credit lines.
|
||||
2. On success (`IsFinal == true` returned), the controller:
|
||||
- Reloads the invoice via `LoadInvoiceAsync`.
|
||||
- Calls `StoreInvoiceDocumentFileAsync` → renders the **final** PDF
|
||||
@@ -216,7 +248,9 @@ flowchart TD
|
||||
## 6. Sending the invoice by email
|
||||
|
||||
Email delivery is handled by `IComService` / `ProcessWebComService`, which
|
||||
talks to the **ProcessWeb Mailer API** (`push_com`, `comType=email`).
|
||||
talks to the **ProcessWeb Mailer API** (`POST /api/mailer/push_com`,
|
||||
`comType="e"`; contract: https://processweb.de/api/mailer/fc, client side
|
||||
described in `Docs/Concepts/processweb-mailer-interface.md`).
|
||||
|
||||
- `HandleRequestSconf` builds the HTML body via `BuildInvoiceBody(balance,
|
||||
paymentTerms)` — a fixed German thank-you text, the amount (if non-zero),
|
||||
@@ -234,8 +268,12 @@ talks to the **ProcessWeb Mailer API** (`push_com`, `comType=email`).
|
||||
- A signature is appended to the HTML body.
|
||||
- If the mailer is disabled (`_settings.Enabled == false`), sending is a
|
||||
documented no-op.
|
||||
- Attachments are base64-encoded inline (`{ filename, mimeType,
|
||||
contentBase64 }`) and POSTed to the Mailer API.
|
||||
- Attachments are pre-validated against the Mailer's limits (size, count,
|
||||
no empty file, extension rules) and POSTed as `multipart/form-data` —
|
||||
envelope JSON in an unnamed field plus one file part per attachment, with
|
||||
`attachmentCount` in the envelope.
|
||||
- The Mailer's 200 response must confirm `attachmentsProcessed` equal to
|
||||
the number sent; otherwise the send is reported as failed (ADR 0028).
|
||||
- Only on a **successful** send does the controller call
|
||||
`fds__setInvoiceSent @auto=true`, which sets `IsSent = 1` — so a failed
|
||||
mailer call leaves the invoice finalised but *not marked sent* (visible via
|
||||
@@ -252,8 +290,10 @@ sequenceDiagram
|
||||
C->>Com: SendEmailAsync("inv_<Id>", subject, body, email, "", {pdf})
|
||||
Com->>Com: validate email, apply OverrideRecipient (dev), append signature
|
||||
alt mailer enabled
|
||||
Com->>API: POST push_com {comType:"email", attachments:[base64 pdf]}
|
||||
API-->>Com: success/failure
|
||||
Com->>Com: MailerAttachmentPolicy.Validate(pdf)
|
||||
Com->>API: POST /api/mailer/push_com (multipart: envelope{comType:"e", attachmentCount:1} + pdf part)
|
||||
API-->>Com: 200 {comStackID, attachmentsProcessed} / 4xx/5xx
|
||||
Com->>Com: attachmentsProcessed == 1 ? success : failure
|
||||
else mailer disabled
|
||||
Com-->>Com: no-op (logged)
|
||||
end
|
||||
@@ -272,10 +312,10 @@ Once an invoice is `IsFinal`, the request/invoice list menus
|
||||
|---|---|---|---|
|
||||
| **Resend email** | `$inv.ccInv`/context menu → *(re-)send* → `HandleRequestResend` | `req/resend` | Re-renders the stored/registered PDF bytes (`RenderInvoicePdfBytesAsync`, no re-registration) and re-sends via `IComService`, **without** re-touching `IsSent` |
|
||||
| **Mark as sent manually** | `$inv.sis(id)` | `inv/sis` | `fds__setInvoiceSent @auto=false` — for invoices sent outside the system (e.g. printed/posted) |
|
||||
| **View / display PDF** | `$inv.disp(id, 'inv')` | `inv/rdoc` | Renders page images for on-screen display only |
|
||||
| **View / display PDF** | `$inv.disp(id, 'inv')` | `inv/rdoc` | Renders page images for on-screen display only. If a finalised, non-external invoice has no stored PDF, it is regenerated and persisted first (ADR 0025) |
|
||||
| **Download / open PDF** | `req/idoc` or `inv/pget`-derived | `req/idoc` | Serves the stored (or freshly rendered) PDF inline |
|
||||
| **Mark paid / unpaid** | `$inv.setPyd`/`setUpd` | `inv/setpyd` / `inv/setupd` | `fds__setInvoicePayed` / `fds__setInvoiceUNPayed` |
|
||||
| **Storno (cancel)** | `$inv.storno` → `$inv.cSt` | `inv/storno` (mode `simple`/`copy`) | `fds__createStorno_simple` / `fds__createStorno_copy`; the replaced invoice is cross-linked (`Replaces_InvId`) and cancelled once the storno itself is finalised |
|
||||
| **Storno (cancel)** | `$inv.storno` → `$inv.cSt` (first `inv/pget`: refreshes mfr entities only for numeric mfr ids, no-op for Intranet ids) | `inv/storno` (mode `simple`/`copy`) | `fds__createStorno_simple` / `fds__createStorno_copy`; the replaced invoice is cross-linked (`Replaces_InvId`) and cancelled once the storno itself is finalised |
|
||||
| **Credit note** | `$inv.credit` → `$inv.cSt` | `inv/credit` (mode `credit`) | `fds__createCredit_simple` — same preview/finalise/email loop as a normal invoice |
|
||||
| **Continue editing a draft** | `$inv.cntInv` / `clCntInv` | `inv/get` | Only available while `isFinal == false`; re-opens the same stateless editor |
|
||||
|
||||
|
||||
@@ -49,6 +49,11 @@ Serviceaufträge kommen aus dem MFR-ERP-System und werden regelmäßig automatis
|
||||
### Erstellen / Bearbeiten
|
||||
1. Aus einem Serviceauftrag eine Rechnung **vorbereiten** → Vorschau prüfen.
|
||||
2. **Speichern** (Entwurf) oder weiter **bearbeiten**. Entwürfe tragen ein Wasserzeichen.
|
||||
3. **Rechnung erstellen** in der Vorschau vergibt Rechnungsnummer und Rechnungsdatum (= Tag der Erstellung).
|
||||
- **Rechnungsdatum Vormonat (nur Admins):** Solange im neuen Monat noch keine Rechnung erstellt wurde,
|
||||
erscheint in der Vorschau das Häkchen „Rechnungsdatum auf den letzten Werktag des Vormonats setzen
|
||||
(TT.MM.JJJJ)“. Gesetzt, erhält die Rechnung dieses Datum; Rechnungsnummer und Fälligkeit richten sich
|
||||
danach. Werktage sind Mo–Fr (Feiertage werden nicht berücksichtigt).
|
||||
|
||||
### Sets (Positionen zusammenfassen)
|
||||
- Ein **Set** aus mfr fasst die darauf folgenden Positionen zusammen; mit **„Auf Setpreis
|
||||
|
||||
@@ -1,933 +0,0 @@
|
||||
# Agent Instructions: Key Vault + DPAPI — Multi-App Architektur (15 Projekte, 1 Server)
|
||||
|
||||
## Ausgangslage
|
||||
|
||||
- 15 Anwendungen (Web Apps, Console Apps, Windows Services, Worker Services)
|
||||
- Alle laufen auf demselben Windows-Server
|
||||
- Ein gemeinsamer Azure Key Vault
|
||||
- Secrets sind aktuell in `appsettings.json`, `web.config` oder Umgebungsvariablen
|
||||
- Zwei bestehende Shared Projects: **OCORE** und **OCORE_web**
|
||||
|
||||
---
|
||||
|
||||
## Aufteilung zwischen OCORE und OCORE_web
|
||||
|
||||
```
|
||||
OCORE OCORE_web
|
||||
────────────────────────────── ──────────────────────────────
|
||||
Alles was NICHT Web-spezifisch Nur was WebApplicationBuilder
|
||||
ist. Funktioniert für: benötigt. Funktioniert für:
|
||||
|
||||
- Console Apps - ASP.NET Core Web Apps
|
||||
- Windows Services - ASP.NET Core Web APIs
|
||||
- Worker Services - Razor Pages / MVC
|
||||
- WinForms / WPF
|
||||
- Web Apps (via OCORE_web)
|
||||
|
||||
Enthält: Enthält:
|
||||
AppSecretsOptions SecretManagementWebExtensions
|
||||
DpapiSecretsCache (eine einzige Datei)
|
||||
DpapiCacheConfigurationSource
|
||||
KeyVaultSyncService
|
||||
PrefixKeyVaultSecretManager
|
||||
SecretManagementExtensions
|
||||
(IHostBuilder)
|
||||
```
|
||||
|
||||
> OCORE_web referenziert OCORE — d.h. Web Apps brauchen nur OCORE_web
|
||||
> zu referenzieren, OCORE kommt transitiv mit.
|
||||
|
||||
---
|
||||
|
||||
## Architektur-Übersicht
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Azure Key Vault │
|
||||
│ │
|
||||
│ crm--ConnectionStrings--Database → CRM Web App │
|
||||
│ crm--ExternalApi--ApiKey → CRM Web App │
|
||||
│ erp--ConnectionStrings--Database → ERP Web App │
|
||||
│ erp--Smtp--Password → ERP Web App │
|
||||
│ importer--ConnectionStrings--Db → Importer Console App │
|
||||
│ scheduler--ConnectionStrings--Db → Scheduler Windows Svc │
|
||||
│ ... │
|
||||
└──────────────────────────┬──────────────────────────────────────┘
|
||||
│ Managed Identity (Server-Level)
|
||||
┌──────────────────────────▼──────────────────────────────────────┐
|
||||
│ Windows Server │
|
||||
│ │
|
||||
│ C:\inetpub\crm\ secrets.cache (DPAPI, crm-Entropy) │
|
||||
│ C:\inetpub\erp\ secrets.cache (DPAPI, erp-Entropy) │
|
||||
│ C:\services\importer\ secrets.cache (DPAPI, importer-Entropy) │
|
||||
│ C:\services\scheduler\secrets.cache (DPAPI, scheduler-Entropy)│
|
||||
│ ... │
|
||||
│ │
|
||||
│ OCORE.dll ← alle 15 Apps referenzieren dies │
|
||||
│ OCORE_web.dll ← nur Web Apps referenzieren dies zusätzlich │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Wichtige Designentscheidungen
|
||||
|
||||
| Thema | Entscheidung | Begründung |
|
||||
|---|---|---|
|
||||
| Key Vault | Ein gemeinsamer | Einfacher zu verwalten, RBAC auf Secret-Ebene möglich |
|
||||
| Namespacing | `{appname}--{key}` Präfix | Verhindert Kollisionen zwischen Apps |
|
||||
| DPAPI Scope | `LocalMachine` + App-spezifische Entropy | Isolation: App A kann Cache von App B nicht lesen |
|
||||
| Basiscode | OCORE | Kein Web-Framework nötig, funktioniert überall |
|
||||
| Web-Extension | OCORE_web | Nur eine Datei, dünne Schicht über OCORE |
|
||||
| App-Identity | Eine Server-Managed-Identity | Alle Apps teilen dieselbe Identity, RBAC per Secret |
|
||||
|
||||
---
|
||||
|
||||
## Teil 1: OCORE — NuGet-Pakete hinzufügen
|
||||
|
||||
```bash
|
||||
cd OCORE
|
||||
dotnet add package Azure.Extensions.AspNetCore.Configuration.Secrets
|
||||
dotnet add package Azure.Identity
|
||||
dotnet add package Azure.Security.KeyVault.Secrets
|
||||
dotnet add package Microsoft.Extensions.Hosting.Abstractions
|
||||
dotnet add package Microsoft.Extensions.Configuration.Abstractions
|
||||
dotnet add package Microsoft.Extensions.Logging.Abstractions
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Teil 2: OCORE — Dateien anlegen
|
||||
|
||||
Neue Ordnerstruktur **innerhalb von OCORE** (bestehende Struktur bleibt unverändert):
|
||||
|
||||
```
|
||||
OCORE/
|
||||
... (bestehende Dateien)
|
||||
Secrets/
|
||||
Models/
|
||||
AppSecretsOptions.cs
|
||||
Cache/
|
||||
DpapiSecretsCache.cs
|
||||
DpapiCacheConfigurationSource.cs
|
||||
Sync/
|
||||
KeyVaultSyncService.cs
|
||||
Extensions/
|
||||
SecretManagementExtensions.cs ← IHostBuilder Extension + gemeinsame Logik
|
||||
PrefixKeyVaultSecretManager.cs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.1 OCORE/Secrets/Models/AppSecretsOptions.cs
|
||||
|
||||
```csharp
|
||||
namespace OCORE.Secrets.Models;
|
||||
|
||||
public class AppSecretsOptions
|
||||
{
|
||||
public const string SectionName = "SecretManagement";
|
||||
|
||||
/// <summary>
|
||||
/// Vollständige URI des Azure Key Vault.
|
||||
/// Beispiel: https://company-vault.vault.azure.net/
|
||||
/// </summary>
|
||||
public string VaultUri { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// App-Name als Präfix für alle Secrets in Key Vault.
|
||||
/// Beispiel: "crm" → Secret "crm--ConnectionStrings--Database"
|
||||
/// Nur Kleinbuchstaben und Bindestriche (Key Vault Konvention).
|
||||
/// </summary>
|
||||
public string AppName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Pfad zur DPAPI-Cache-Datei (relativ zum App-Verzeichnis).
|
||||
/// </summary>
|
||||
public string CacheFilePath { get; set; } = "secrets.cache";
|
||||
|
||||
/// <summary>
|
||||
/// Sync-Intervall in Stunden.
|
||||
/// </summary>
|
||||
public int SyncIntervalHours { get; set; } = 6;
|
||||
|
||||
/// <summary>
|
||||
/// Secret-Namen ohne App-Präfix.
|
||||
/// Beispiel: ["ConnectionStrings--Database", "ExternalApi--ApiKey"]
|
||||
/// </summary>
|
||||
public List<string> ManagedSecretKeys { get; set; } = [];
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.2 OCORE/Secrets/Cache/DpapiSecretsCache.cs
|
||||
|
||||
```csharp
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace OCORE.Secrets.Cache;
|
||||
|
||||
public interface IDpapiSecretsCache
|
||||
{
|
||||
Task WriteAsync(Dictionary<string, string> secrets);
|
||||
Task<Dictionary<string, string>> ReadAsync();
|
||||
bool Exists();
|
||||
}
|
||||
|
||||
public class DpapiSecretsCache : IDpapiSecretsCache
|
||||
{
|
||||
private readonly string _filePath;
|
||||
private readonly byte[] _entropy;
|
||||
private readonly ILogger<DpapiSecretsCache> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Entropy wird aus AppName abgeleitet — verhindert dass eine andere App
|
||||
/// auf demselben Server den Cache dieser App lesen kann.
|
||||
/// </summary>
|
||||
public DpapiSecretsCache(string filePath, string appName, ILogger<DpapiSecretsCache> logger)
|
||||
{
|
||||
_filePath = filePath;
|
||||
_entropy = Encoding.UTF8.GetBytes($"dpapi-entropy-{appName}");
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task WriteAsync(Dictionary<string, string> secrets)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.SerializeToUtf8Bytes(secrets);
|
||||
var encrypted = ProtectedData.Protect(json, _entropy, DataProtectionScope.LocalMachine);
|
||||
await File.WriteAllBytesAsync(_filePath, encrypted);
|
||||
_logger.LogInformation("[OCORE.Secrets] DPAPI Cache geschrieben: {Count} Secrets → {Path}",
|
||||
secrets.Count, _filePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[OCORE.Secrets] DPAPI Cache konnte nicht geschrieben werden: {Path}", _filePath);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, string>> ReadAsync()
|
||||
{
|
||||
if (!Exists()) return [];
|
||||
|
||||
try
|
||||
{
|
||||
var encrypted = await File.ReadAllBytesAsync(_filePath);
|
||||
var decrypted = ProtectedData.Unprotect(encrypted, _entropy, DataProtectionScope.LocalMachine);
|
||||
return JsonSerializer.Deserialize<Dictionary<string, string>>(decrypted) ?? [];
|
||||
}
|
||||
catch (CryptographicException ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"[OCORE.Secrets] DPAPI Entschlüsselung fehlgeschlagen — AppName korrekt? {Path}", _filePath);
|
||||
return [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[OCORE.Secrets] DPAPI Cache Lesefehler: {Path}", _filePath);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public bool Exists() => File.Exists(_filePath);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.3 OCORE/Secrets/Cache/DpapiCacheConfigurationSource.cs
|
||||
|
||||
```csharp
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace OCORE.Secrets.Cache;
|
||||
|
||||
public class DpapiCacheConfigurationSource : IConfigurationSource
|
||||
{
|
||||
private readonly string _filePath;
|
||||
private readonly string _appName;
|
||||
|
||||
public DpapiCacheConfigurationSource(string filePath, string appName)
|
||||
{
|
||||
_filePath = filePath;
|
||||
_appName = appName;
|
||||
}
|
||||
|
||||
public IConfigurationProvider Build(IConfigurationBuilder builder)
|
||||
=> new DpapiCacheConfigurationProvider(_filePath, _appName);
|
||||
}
|
||||
|
||||
public class DpapiCacheConfigurationProvider : ConfigurationProvider
|
||||
{
|
||||
private readonly string _filePath;
|
||||
private readonly string _appName;
|
||||
|
||||
public DpapiCacheConfigurationProvider(string filePath, string appName)
|
||||
{
|
||||
_filePath = filePath;
|
||||
_appName = appName;
|
||||
}
|
||||
|
||||
public override void Load()
|
||||
{
|
||||
if (!File.Exists(_filePath)) return;
|
||||
|
||||
try
|
||||
{
|
||||
using var loggerFactory = LoggerFactory.Create(b => b.AddConsole());
|
||||
var logger = loggerFactory.CreateLogger<DpapiSecretsCache>();
|
||||
var cache = new DpapiSecretsCache(_filePath, _appName, logger);
|
||||
var secrets = cache.ReadAsync().GetAwaiter().GetResult();
|
||||
|
||||
// "crm--ConnectionStrings--Database" → "ConnectionStrings:Database"
|
||||
var prefix = $"{_appName}--";
|
||||
Data = secrets
|
||||
.Where(kvp => kvp.Key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
.ToDictionary(
|
||||
kvp => kvp.Key[prefix.Length..].Replace("--", ":"),
|
||||
kvp => (string?)kvp.Value);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[OCORE.Secrets] DPAPI Cache Ladefehler: {ex.Message}");
|
||||
Data = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.4 OCORE/Secrets/Sync/KeyVaultSyncService.cs
|
||||
|
||||
```csharp
|
||||
using Azure.Security.KeyVault.Secrets;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OCORE.Secrets.Cache;
|
||||
using OCORE.Secrets.Models;
|
||||
|
||||
namespace OCORE.Secrets.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// Läuft als BackgroundService in jedem Host-Typ:
|
||||
/// Web Apps, Console Apps, Windows Services, Worker Services.
|
||||
/// Synct Secrets aus Key Vault → DPAPI-Cache, periodisch.
|
||||
/// </summary>
|
||||
public class KeyVaultSyncService : BackgroundService
|
||||
{
|
||||
private readonly SecretClient _secretClient;
|
||||
private readonly IDpapiSecretsCache _cache;
|
||||
private readonly IConfigurationRoot _configRoot;
|
||||
private readonly AppSecretsOptions _options;
|
||||
private readonly ILogger<KeyVaultSyncService> _logger;
|
||||
|
||||
public KeyVaultSyncService(
|
||||
SecretClient secretClient,
|
||||
IDpapiSecretsCache cache,
|
||||
IConfiguration configuration,
|
||||
IOptions<AppSecretsOptions> options,
|
||||
ILogger<KeyVaultSyncService> logger)
|
||||
{
|
||||
_secretClient = secretClient;
|
||||
_cache = cache;
|
||||
_configRoot = (IConfigurationRoot)configuration;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await SyncAsync(stoppingToken);
|
||||
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromHours(_options.SyncIntervalHours));
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken))
|
||||
{
|
||||
await SyncAsync(stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SyncAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("[OCORE.Secrets:{App}] Key Vault Sync gestartet", _options.AppName);
|
||||
var synced = new Dictionary<string, string>();
|
||||
|
||||
foreach (var key in _options.ManagedSecretKeys)
|
||||
{
|
||||
var fullName = $"{_options.AppName}--{key}";
|
||||
try
|
||||
{
|
||||
var secret = await _secretClient.GetSecretAsync(fullName, version: null, cancellationToken);
|
||||
synced[fullName] = secret.Value.Value;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning("[OCORE.Secrets:{App}] Secret '{Name}' nicht geladen: {Error}",
|
||||
_options.AppName, fullName, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
if (synced.Count > 0)
|
||||
{
|
||||
await _cache.WriteAsync(synced);
|
||||
_configRoot.Reload();
|
||||
_logger.LogInformation("[OCORE.Secrets:{App}] Sync OK: {Count}/{Total}",
|
||||
_options.AppName, synced.Count, _options.ManagedSecretKeys.Count);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("[OCORE.Secrets:{App}] Sync: Keine Secrets geladen — Cache unverändert",
|
||||
_options.AppName);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.5 OCORE/Secrets/Extensions/PrefixKeyVaultSecretManager.cs
|
||||
|
||||
```csharp
|
||||
using Azure.Extensions.AspNetCore.Configuration.Secrets;
|
||||
using Azure.Security.KeyVault.Secrets;
|
||||
|
||||
namespace OCORE.Secrets.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Filtert beim Key Vault Laden nur Secrets mit dem App-Präfix
|
||||
/// und konvertiert den Namen in einen IConfiguration-Key.
|
||||
/// "crm--ConnectionStrings--Database" → "ConnectionStrings:Database"
|
||||
/// </summary>
|
||||
public class PrefixKeyVaultSecretManager : KeyVaultSecretManager
|
||||
{
|
||||
private readonly string _prefix;
|
||||
|
||||
public PrefixKeyVaultSecretManager(string appName)
|
||||
=> _prefix = $"{appName}--";
|
||||
|
||||
public override bool Load(SecretProperties secret)
|
||||
=> secret.Name.StartsWith(_prefix, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public override string GetKey(KeyVaultSecret secret)
|
||||
=> secret.Name[_prefix.Length..].Replace("--", ":");
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.6 OCORE/Secrets/Extensions/SecretManagementExtensions.cs
|
||||
|
||||
```csharp
|
||||
using Azure.Identity;
|
||||
using Azure.Security.KeyVault.Secrets;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using OCORE.Secrets.Cache;
|
||||
using OCORE.Secrets.Models;
|
||||
using OCORE.Secrets.Sync;
|
||||
|
||||
namespace OCORE.Secrets.Extensions;
|
||||
|
||||
public static class SecretManagementExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Für Console Apps, Worker Services, Windows Services, WinForms/WPF.
|
||||
/// Verwendung: Host.CreateDefaultBuilder(args).AddSecretManagement()
|
||||
/// </summary>
|
||||
public static IHostBuilder AddSecretManagement(this IHostBuilder hostBuilder)
|
||||
{
|
||||
hostBuilder.ConfigureAppConfiguration((context, config) =>
|
||||
{
|
||||
var built = config.Build();
|
||||
var options = built.GetSection(AppSecretsOptions.SectionName).Get<AppSecretsOptions>()
|
||||
?? new AppSecretsOptions();
|
||||
|
||||
if (!IsConfigured(options)) return;
|
||||
|
||||
var cacheFilePath = ResolveCachePath(
|
||||
context.HostingEnvironment.ContentRootPath, options.CacheFilePath);
|
||||
|
||||
// 1. DPAPI-Cache (Fallback)
|
||||
config.Add(new DpapiCacheConfigurationSource(cacheFilePath, options.AppName));
|
||||
|
||||
// 2. Key Vault (primär — überschreibt Cache wenn erreichbar)
|
||||
TryAddKeyVault(config, options);
|
||||
});
|
||||
|
||||
hostBuilder.ConfigureServices((context, services) =>
|
||||
{
|
||||
RegisterCoreServices(services, context.Configuration);
|
||||
});
|
||||
|
||||
return hostBuilder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gemeinsame Service-Registrierung — wird von OCORE und OCORE_web verwendet.
|
||||
/// </summary>
|
||||
public static void RegisterCoreServices(IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
var options = configuration
|
||||
.GetSection(AppSecretsOptions.SectionName)
|
||||
.Get<AppSecretsOptions>() ?? new AppSecretsOptions();
|
||||
|
||||
if (!IsConfigured(options)) return;
|
||||
|
||||
services.Configure<AppSecretsOptions>(
|
||||
configuration.GetSection(AppSecretsOptions.SectionName));
|
||||
|
||||
services.AddSingleton(new SecretClient(
|
||||
new Uri(options.VaultUri),
|
||||
new DefaultAzureCredential()));
|
||||
|
||||
services.AddSingleton<IDpapiSecretsCache>(sp =>
|
||||
new DpapiSecretsCache(
|
||||
ResolveCachePath(AppContext.BaseDirectory, options.CacheFilePath),
|
||||
options.AppName,
|
||||
sp.GetRequiredService<ILogger<DpapiSecretsCache>>()));
|
||||
|
||||
services.AddHostedService<KeyVaultSyncService>();
|
||||
}
|
||||
|
||||
public static void TryAddKeyVault(IConfigurationBuilder config, AppSecretsOptions options)
|
||||
{
|
||||
try
|
||||
{
|
||||
config.AddAzureKeyVault(
|
||||
new Uri(options.VaultUri),
|
||||
new DefaultAzureCredential(),
|
||||
new PrefixKeyVaultSecretManager(options.AppName));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[OCORE.Secrets] Key Vault nicht erreichbar: {ex.Message}");
|
||||
Console.WriteLine("[OCORE.Secrets] Starte mit DPAPI-Cache");
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsConfigured(AppSecretsOptions options)
|
||||
=> !string.IsNullOrEmpty(options.VaultUri) && !string.IsNullOrEmpty(options.AppName);
|
||||
|
||||
public static string ResolveCachePath(string basePath, string cacheFilePath)
|
||||
=> Path.IsPathRooted(cacheFilePath)
|
||||
? cacheFilePath
|
||||
: Path.Combine(basePath, cacheFilePath);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Teil 3: OCORE_web — Einzige neue Datei
|
||||
|
||||
OCORE_web referenziert OCORE bereits. Nur eine neue Datei hinzufügen:
|
||||
|
||||
```
|
||||
OCORE_web/
|
||||
... (bestehende Dateien)
|
||||
Secrets/
|
||||
SecretManagementWebExtensions.cs ← einzige neue Datei in OCORE_web
|
||||
```
|
||||
|
||||
### 3.1 OCORE_web/Secrets/SecretManagementWebExtensions.cs
|
||||
|
||||
```csharp
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using OCORE.Secrets.Extensions;
|
||||
using OCORE.Secrets.Models;
|
||||
|
||||
namespace OCORE_web.Secrets;
|
||||
|
||||
public static class SecretManagementWebExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Für ASP.NET Core Web Apps und Web APIs.
|
||||
/// Verwendung: builder.AddSecretManagement()
|
||||
/// </summary>
|
||||
public static WebApplicationBuilder AddSecretManagement(
|
||||
this WebApplicationBuilder builder)
|
||||
{
|
||||
var options = builder.Configuration
|
||||
.GetSection(AppSecretsOptions.SectionName)
|
||||
.Get<AppSecretsOptions>() ?? new AppSecretsOptions();
|
||||
|
||||
if (!SecretManagementExtensions.IsConfigured(options)) return builder;
|
||||
|
||||
var cacheFilePath = SecretManagementExtensions.ResolveCachePath(
|
||||
builder.Environment.ContentRootPath, options.CacheFilePath);
|
||||
|
||||
// 1. DPAPI-Cache (Fallback)
|
||||
builder.Configuration.Add(
|
||||
new OCORE.Secrets.Cache.DpapiCacheConfigurationSource(cacheFilePath, options.AppName));
|
||||
|
||||
// 2. Key Vault (primär)
|
||||
SecretManagementExtensions.TryAddKeyVault(builder.Configuration, options);
|
||||
|
||||
// 3. Services — aus OCORE (kein duplizierter Code)
|
||||
SecretManagementExtensions.RegisterCoreServices(builder.Services, builder.Configuration);
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Teil 4: Verwendung je App-Typ
|
||||
|
||||
### Web App (referenziert OCORE_web)
|
||||
|
||||
```csharp
|
||||
// Program.cs
|
||||
using OCORE_web.Secrets;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.AddSecretManagement(); // ← aus OCORE_web
|
||||
|
||||
var app = builder.Build();
|
||||
app.Run();
|
||||
```
|
||||
|
||||
### Console App / Worker Service / Windows Service (referenziert OCORE)
|
||||
|
||||
```csharp
|
||||
// Program.cs
|
||||
using OCORE.Secrets.Extensions;
|
||||
|
||||
// Console / Worker
|
||||
var host = Host.CreateDefaultBuilder(args)
|
||||
.AddSecretManagement() // ← aus OCORE
|
||||
.ConfigureServices(services =>
|
||||
{
|
||||
services.AddHostedService<MyWorker>();
|
||||
})
|
||||
.Build();
|
||||
|
||||
await host.RunAsync();
|
||||
```
|
||||
|
||||
```csharp
|
||||
// Windows Service — identisch, nur UseWindowsService() zusätzlich
|
||||
var host = Host.CreateDefaultBuilder(args)
|
||||
.UseWindowsService()
|
||||
.AddSecretManagement() // ← aus OCORE
|
||||
.ConfigureServices(services =>
|
||||
{
|
||||
services.AddHostedService<MyWindowsService>();
|
||||
})
|
||||
.Build();
|
||||
|
||||
await host.RunAsync();
|
||||
```
|
||||
|
||||
### WinForms / WPF (referenziert OCORE)
|
||||
|
||||
```csharp
|
||||
// Program.cs — Generic Host nachrüsten
|
||||
using OCORE.Secrets.Extensions;
|
||||
|
||||
var host = Host.CreateDefaultBuilder()
|
||||
.AddSecretManagement() // ← aus OCORE
|
||||
.ConfigureServices(services =>
|
||||
{
|
||||
services.AddSingleton<MainForm>();
|
||||
})
|
||||
.Build();
|
||||
|
||||
Application.Run(host.Services.GetRequiredService<MainForm>());
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Teil 5: appsettings.json (identisch für alle App-Typen)
|
||||
|
||||
```json
|
||||
{
|
||||
"SecretManagement": {
|
||||
"VaultUri": "https://company-vault.vault.azure.net/",
|
||||
"AppName": "crm",
|
||||
"CacheFilePath": "secrets.cache",
|
||||
"SyncIntervalHours": 6,
|
||||
"ManagedSecretKeys": [
|
||||
"ConnectionStrings--Database",
|
||||
"ExternalApi--ApiKey"
|
||||
]
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"Database": "MANAGED_BY_KEYVAULT"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> `AppName` ist der einzige Wert der sich zwischen den 15 Projekten unterscheidet.
|
||||
|
||||
---
|
||||
|
||||
## Teil 6: Key Vault Naming-Konvention
|
||||
|
||||
```
|
||||
Format: {appname}--{ConfigSection}--{ConfigKey}
|
||||
|
||||
Web Apps:
|
||||
crm--ConnectionStrings--Database
|
||||
crm--ExternalApi--ApiKey
|
||||
erp--ConnectionStrings--Database
|
||||
erp--Smtp--Password
|
||||
portal--Auth--JwtSecret
|
||||
|
||||
Non-Web Apps:
|
||||
importer--ConnectionStrings--Database
|
||||
scheduler--ConnectionStrings--Database
|
||||
scheduler--Smtp--Password
|
||||
```
|
||||
|
||||
### Naming-Regeln
|
||||
|
||||
- Nur alphanumerische Zeichen und einfache Bindestriche `-`
|
||||
- Hierarchie-Trenner: `--` (doppelter Bindestrich)
|
||||
- Kein Unterstrich, kein Punkt, kein Leerzeichen
|
||||
- Maximal 127 Zeichen
|
||||
|
||||
---
|
||||
|
||||
## Teil 7: Einmalige Migration — Alle Apps
|
||||
|
||||
### 7.1 Inventur-Script
|
||||
|
||||
```powershell
|
||||
# inventarisierung.ps1
|
||||
param([string]$SolutionRoot = "C:\Projects\MySolution")
|
||||
|
||||
$patterns = @(
|
||||
'password\s*=\s*["\'][^"\']+["\']',
|
||||
'connectionstring\s*=\s*["\'][^"\']+["\']',
|
||||
'apikey\s*=\s*["\'][^"\']+["\']',
|
||||
'secret\s*=\s*["\'][^"\']+["\']',
|
||||
'pwd\s*=\s*[^;]+'
|
||||
)
|
||||
|
||||
Get-ChildItem -Path $SolutionRoot -Recurse -Include "*.json","*.config","*.cs" |
|
||||
Where-Object { $_.FullName -notmatch '\\(bin|obj|\.git)\\' } |
|
||||
ForEach-Object {
|
||||
$file = $_
|
||||
$content = Get-Content $file.FullName -Raw -ErrorAction SilentlyContinue
|
||||
foreach ($pattern in $patterns) {
|
||||
if ($content -match $pattern) {
|
||||
Write-Host "⚠️ $($file.FullName)" -ForegroundColor Yellow
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 Migration Script
|
||||
|
||||
```powershell
|
||||
# migrate-all-to-keyvault.ps1
|
||||
param([Parameter(Mandatory)][string]$VaultName)
|
||||
|
||||
Connect-AzAccount
|
||||
|
||||
$allSecrets = @{
|
||||
# Web Apps
|
||||
"crm--ConnectionStrings--Database" = "Server=PROD-SQL;Database=CrmDb;..."
|
||||
"crm--ExternalApi--ApiKey" = "crm-api-key"
|
||||
"erp--ConnectionStrings--Database" = "Server=PROD-SQL;Database=ErpDb;..."
|
||||
"erp--Smtp--Password" = "smtp-passwort"
|
||||
"portal--ConnectionStrings--Database" = "Server=PROD-SQL;Database=PortalDb;..."
|
||||
"portal--Auth--JwtSecret" = "jwt-secret"
|
||||
|
||||
# Non-Web Apps (OCORE)
|
||||
"importer--ConnectionStrings--Database" = "Server=PROD-SQL;Database=ImportDb;..."
|
||||
"scheduler--ConnectionStrings--Database" = "Server=PROD-SQL;Database=SchedDb;..."
|
||||
"scheduler--Smtp--Password" = "smtp-passwort"
|
||||
|
||||
# Weitere Apps ergänzen...
|
||||
}
|
||||
|
||||
$total = $allSecrets.Count
|
||||
$done = 0
|
||||
|
||||
foreach ($name in $allSecrets.Keys) {
|
||||
$value = ConvertTo-SecureString $allSecrets[$name] -AsPlainText -Force
|
||||
Set-AzKeyVaultSecret -VaultName $VaultName -Name $name -SecretValue $value
|
||||
$done++
|
||||
Write-Progress -Activity "Migriere Secrets" -Status "$name" -PercentComplete (($done/$total)*100)
|
||||
Write-Host "✅ [$done/$total] $name"
|
||||
}
|
||||
|
||||
Write-Host "`nMigration abgeschlossen." -ForegroundColor Green
|
||||
```
|
||||
|
||||
### 7.3 Bereinigung
|
||||
|
||||
```powershell
|
||||
# bereinigung.ps1
|
||||
$projektOrdner = @(
|
||||
"C:\Projects\CRM",
|
||||
"C:\Projects\ERP",
|
||||
"C:\Projects\Portal",
|
||||
"C:\Projects\Importer",
|
||||
"C:\Projects\Scheduler"
|
||||
# Alle Pfade eintragen
|
||||
)
|
||||
|
||||
foreach ($ordner in $projektOrdner) {
|
||||
$file = Join-Path $ordner "appsettings.json"
|
||||
if (Test-Path $file) {
|
||||
$json = Get-Content $file -Raw | ConvertFrom-Json
|
||||
if ($json.ConnectionStrings) {
|
||||
$json.ConnectionStrings.PSObject.Properties |
|
||||
ForEach-Object { $_.Value = "MANAGED_BY_KEYVAULT" }
|
||||
}
|
||||
$json | ConvertTo-Json -Depth 10 | Set-Content $file
|
||||
Write-Host "✅ Bereinigt: $file"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Teil 8: Server-Setup (einmalig)
|
||||
|
||||
### 8.1 Managed Identity + Key Vault Zugriff
|
||||
|
||||
```powershell
|
||||
# server-setup.ps1 — einmalig als Admin
|
||||
|
||||
# Managed Identity der VM abrufen
|
||||
$identity = Get-AzVM -ResourceGroupName "MyRG" -Name "PROD-SERVER-01"
|
||||
$objectId = $identity.Identity.PrincipalId
|
||||
|
||||
# Key Vault: Server darf lesen, nicht schreiben
|
||||
Set-AzKeyVaultAccessPolicy `
|
||||
-VaultName "company-vault" `
|
||||
-ObjectId $objectId `
|
||||
-PermissionsToSecrets Get, List
|
||||
|
||||
Write-Host "✅ Key Vault Zugriff konfiguriert für Object ID: $objectId"
|
||||
```
|
||||
|
||||
### 8.2 Schreibrechte für Cache-Dateien
|
||||
|
||||
```powershell
|
||||
# Für IIS Web Apps (App Pool User)
|
||||
$webApps = @("CRM", "ERP", "Portal")
|
||||
foreach ($pool in $webApps) {
|
||||
$appPath = "C:\inetpub\$pool"
|
||||
$user = "IIS AppPool\$pool"
|
||||
$acl = Get-Acl $appPath
|
||||
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule(
|
||||
$user, "Modify", "ContainerInherit,ObjectInherit", "None", "Allow")
|
||||
$acl.SetAccessRule($rule)
|
||||
Set-Acl -Path $appPath -AclObject $acl
|
||||
Write-Host "✅ Web App Schreibrecht: $user → $appPath"
|
||||
}
|
||||
|
||||
# Für Windows Services / Console Apps (LocalSystem oder dedizierter Service-User)
|
||||
$serviceApps = @("Importer", "Scheduler")
|
||||
foreach ($svc in $serviceApps) {
|
||||
$appPath = "C:\services\$svc"
|
||||
# LocalSystem hat standardmäßig Schreibrecht — nur prüfen ob Ordner existiert
|
||||
if (-not (Test-Path $appPath)) {
|
||||
New-Item -ItemType Directory -Path $appPath
|
||||
Write-Host "✅ Service-Ordner erstellt: $appPath"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Teil 9: .gitignore (Solution-Level)
|
||||
|
||||
```gitignore
|
||||
# Secret Management
|
||||
secrets.cache
|
||||
**/secrets.cache
|
||||
|
||||
# Konfigurationsdateien mit echten Werten
|
||||
appsettings.Production.json
|
||||
appsettings.Staging.json
|
||||
|
||||
# Zertifikate
|
||||
*.pfx
|
||||
*.p12
|
||||
*.key
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Teil 10: Pro-Projekt Checkliste
|
||||
|
||||
### Web Apps (OCORE_web)
|
||||
|
||||
```
|
||||
[ ] 1. AppName festlegen (kurz, eindeutig, Kleinbuchstaben)
|
||||
[ ] 2. OCORE_web bereits referenziert (transitiv: OCORE kommt mit)
|
||||
[ ] 3. Program.cs:
|
||||
using OCORE_web.Secrets;
|
||||
builder.AddSecretManagement();
|
||||
[ ] 4. appsettings.json: SecretManagement-Block mit korrektem AppName
|
||||
[ ] 5. ManagedSecretKeys vollständig auflisten
|
||||
[ ] 6. Secrets in Key Vault mit {appname}-- Präfix vorhanden
|
||||
[ ] 7. Klartext-Secrets aus appsettings.json / web.config entfernt
|
||||
[ ] 8. secrets.cache in .gitignore
|
||||
[ ] 9. Logs nach Deploy prüfen:
|
||||
"[OCORE.Secrets:crm] Sync OK" → ✅
|
||||
"[OCORE.Secrets:crm] Key Vault nicht erreichbar" → ❌
|
||||
```
|
||||
|
||||
### Non-Web Apps (OCORE)
|
||||
|
||||
```
|
||||
[ ] 1. AppName festlegen (kurz, eindeutig, Kleinbuchstaben)
|
||||
[ ] 2. OCORE bereits referenziert (vorhanden)
|
||||
[ ] 3. Program.cs:
|
||||
using OCORE.Secrets.Extensions;
|
||||
Host.CreateDefaultBuilder(args).AddSecretManagement()
|
||||
(Windows Service: zusätzlich .UseWindowsService())
|
||||
[ ] 4. appsettings.json: SecretManagement-Block mit korrektem AppName
|
||||
[ ] 5. ManagedSecretKeys vollständig auflisten
|
||||
[ ] 6. Secrets in Key Vault mit {appname}-- Präfix vorhanden
|
||||
[ ] 7. Klartext-Secrets aus Konfigurationsdateien entfernt
|
||||
[ ] 8. secrets.cache in .gitignore
|
||||
[ ] 9. Logs prüfen nach erstem Start
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Zusammenfassung der Artefakte
|
||||
|
||||
```
|
||||
OCORE/Secrets/ ← Einmalig anlegen
|
||||
Models/AppSecretsOptions.cs
|
||||
Cache/DpapiSecretsCache.cs
|
||||
Cache/DpapiCacheConfigurationSource.cs
|
||||
Sync/KeyVaultSyncService.cs
|
||||
Extensions/PrefixKeyVaultSecretManager.cs
|
||||
Extensions/SecretManagementExtensions.cs ← IHostBuilder + shared helpers
|
||||
|
||||
OCORE_web/Secrets/ ← Einmalig anlegen (1 Datei)
|
||||
SecretManagementWebExtensions.cs ← WebApplicationBuilder Extension
|
||||
|
||||
Pro Web-Projekt (15x oder subset):
|
||||
Program.cs → +2 Zeilen (using + builder.AddSecretManagement())
|
||||
appsettings.json → SecretManagement-Block, keine Secrets mehr
|
||||
|
||||
Pro Non-Web-Projekt:
|
||||
Program.cs → +2 Zeilen (using + .AddSecretManagement())
|
||||
appsettings.json → SecretManagement-Block, keine Secrets mehr
|
||||
|
||||
Scripts/
|
||||
inventarisierung.ps1 ← Wo liegen aktuell Secrets?
|
||||
migrate-all-to-keyvault.ps1 ← Einmalige Migration aller Apps
|
||||
bereinigung.ps1 ← Platzhalter setzen nach Migration
|
||||
server-setup.ps1 ← Managed Identity + ACLs (einmalig)
|
||||
```
|
||||
+11
-11
@@ -32,14 +32,14 @@
|
||||
<!-- Compatible packages (kept) -->
|
||||
<PackageReference Include="BouncyCastle" Version="1.8.9" />
|
||||
<PackageReference Include="HtmlAgilityPack" Version="1.13.0" />
|
||||
<PackageReference Include="MailKit" Version="4.18.0" />
|
||||
<PackageReference Include="MailKit" Version="4.18.1" />
|
||||
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.18.0" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.18.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.18.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.18.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.18.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.SqlClient" Version="1.18.0" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.19.1" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.19.1" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.19.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.19.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.19.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.SqlClient" Version="1.19.0" />
|
||||
<PackageReference Include="Portable.BouncyCastle" Version="1.9.0" />
|
||||
<PackageReference Include="QRCoder" Version="1.8.0" />
|
||||
<PackageReference Include="PDFsharp" Version="6.2.4" />
|
||||
@@ -47,14 +47,14 @@
|
||||
<PackageReference Include="Spire.PDF" Version="[8.10.5,8.10.5]" allowedVersions="[8.10.5,8.10.5]" />
|
||||
<!-- Updated packages -->
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.7.0" />
|
||||
<PackageReference Include="MimeKit" Version="4.18.0" />
|
||||
<PackageReference Include="MimeKit" Version="4.18.1" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<!-- New packages (needed for .NET 10) -->
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.3" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="4.1.2" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.1.1" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="4.1.3" />
|
||||
<PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.12" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="10.0.12" />
|
||||
<PackageReference Include="Azure.Storage.Blobs" Version="12.29.2" />
|
||||
<PackageReference Include="Azure.Storage.Blobs" Version="12.30.1" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Folder Include="App_Data\cache\" />
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using Fuchs.intranet;
|
||||
|
||||
namespace Fuchs.Logging;
|
||||
|
||||
/// <summary>
|
||||
/// Pushes a per-request logging scope (request id, path, and — once cookie auth has populated
|
||||
/// <see cref="HttpContext.User"/> — the authenticated user id) so every log line written while
|
||||
/// handling this request, including ones written deep inside OCORE (SQL errors, PDF render
|
||||
/// failures, ...), carries that attribution automatically. See ADR 0024: the scope flows into
|
||||
/// every sink's serialized context (<c>LogEntry.dataSerialized</c>, via
|
||||
/// <see cref="OCORE.MultiLogger.MultiLogger_base"/>'s context merging) and — for "authuser"
|
||||
/// specifically — into the dedicated <see cref="OCORE.MultiLogger.LogEntry.AuthUser"/> column the
|
||||
/// SQL sink writes to <c>fds__debuglog.AuthUser</c>.
|
||||
/// Registered after <c>UseAuthentication()</c> so the user claim is already populated.
|
||||
/// </summary>
|
||||
public sealed class RequestLogScopeMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
|
||||
public RequestLogScopeMiddleware(RequestDelegate next) => _next = next;
|
||||
|
||||
public async Task InvokeAsync(HttpContext context, ILogger<RequestLogScopeMiddleware> logger)
|
||||
{
|
||||
using (logger.BeginScope(BuildScope(context)))
|
||||
await _next(context);
|
||||
}
|
||||
|
||||
/// <summary>Pure so the scope shape is unit-testable without a live request pipeline.</summary>
|
||||
internal static Dictionary<string, object> BuildScope(HttpContext context)
|
||||
{
|
||||
var scope = new Dictionary<string, object>
|
||||
{
|
||||
["requestId"] = context.TraceIdentifier,
|
||||
["path"] = context.Request.Path.Value ?? "",
|
||||
};
|
||||
string? authUser = context.User?.FindFirst(FuchsUserIdentity.ClaimUserAccountId)?.Value;
|
||||
if (!string.IsNullOrEmpty(authUser))
|
||||
scope["authuser"] = authUser;
|
||||
return scope;
|
||||
}
|
||||
}
|
||||
@@ -51,12 +51,27 @@ public static class FuchsTelemetry
|
||||
Meter.CreateCounter<long>("fuchs.blobstorage.uploads", "{upload}", "Number of documents successfully archived to Azure Blob Storage.");
|
||||
public static readonly Counter<long> BlobUploadsFailed =
|
||||
Meter.CreateCounter<long>("fuchs.blobstorage.uploads.failed", "{upload}", "Number of documents that failed to archive to Azure Blob Storage.");
|
||||
public static readonly Counter<long> BlobBackupCopiesSucceeded =
|
||||
Meter.CreateCounter<long>("fuchs.blobstorage.backupcopies", "{copy}",
|
||||
"Number of archived blobs successfully copied to a backup name (admin invoice-file backup/clear).");
|
||||
public static readonly Counter<long> BlobBackupCopiesFailed =
|
||||
Meter.CreateCounter<long>("fuchs.blobstorage.backupcopies.failed", "{copy}",
|
||||
"Number of blob backup-copy attempts that failed.");
|
||||
public static readonly Counter<long> InvoicesBackdated =
|
||||
Meter.CreateCounter<long>("fuchs.invoices.backdated", "{invoice}",
|
||||
"Invoice finalisations with the admin backdate option (ADR 0032), tagged by outcome.");
|
||||
public static readonly Counter<long> InvoiceFileBackups =
|
||||
Meter.CreateCounter<long>("fuchs.invoice.filebackups", "{backup}",
|
||||
"Admin-triggered invoice file backup+clear actions (Do_Process_Admin backupclearinvoicefile), tagged by outcome.");
|
||||
public static readonly Counter<long> SystemProbes =
|
||||
Meter.CreateCounter<long>("fuchs.systemstatus.probes", "{probe}",
|
||||
"Admin system-status connectivity probes executed, tagged by component and outcome.");
|
||||
public static readonly Counter<long> InvoiceInlineImageFetches =
|
||||
Meter.CreateCounter<long>("fuchs.invoice.inlineimages.fetches", "{fetch}",
|
||||
"Invoice-text image fetches for inline PDF rendering (ADR 0018), tagged by outcome.");
|
||||
public static readonly Counter<long> PdfPreviews =
|
||||
Meter.CreateCounter<long>("fuchs.pdf.previews", "{preview}",
|
||||
"PDF preview requests (draft/invoice/reminder/report image previews), tagged by endpoint and outcome.");
|
||||
|
||||
// ── Performance histograms (durations in milliseconds) ───────────────────
|
||||
public static readonly Histogram<double> PdfRenderDuration =
|
||||
|
||||
+74
-2
@@ -8,8 +8,11 @@ using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.StaticFiles;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OCORE.MultiLogger;
|
||||
using OCORE.security;
|
||||
using OCORE.web;
|
||||
using OCORE.web.pwmailer;
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
@@ -53,6 +56,26 @@ public class Program
|
||||
// Initialize the Fuchs intranet singleton with configuration
|
||||
FuchsOcmsIntranet.Initialize(builder.Configuration);
|
||||
|
||||
// SQL log sink (Warning+, ADR 0024): every _logger.LogWarning/-Error/-Critical call — not
|
||||
// just the explicit ones that used to call Fuchs_intranet.debug_log — additionally lands in
|
||||
// [dbo].[fds__debuglog] via [dbo].[fds__admin_logdebug_ext], with the failing SQL/context
|
||||
// (redacted), the ILogger category, and — via RequestLogScopeMiddleware's scope — the
|
||||
// request id and authenticated user, all in dedicated/serialized columns. The file sink
|
||||
// above (AddFuchsLogging) stays the always-on baseline; this is the durable, queryable copy
|
||||
// support actually reads from. Config switch (default on) allows disabling per environment.
|
||||
if (builder.Configuration.GetValue("Fuchs:Logging:SqlSink:Enabled", true))
|
||||
{
|
||||
builder.Logging.AddSqlLogger();
|
||||
builder.Services.Configure<MultiLoggerOptions>(o =>
|
||||
{
|
||||
o.SQL.ConnectionString = FuchsOcmsIntranet.Instance.Intranet__SQLConnectionString;
|
||||
o.SQL.StoredProcedure = "dbo.fds__admin_logdebug_ext";
|
||||
o.SQL.LogLevel = LogLevel.Warning;
|
||||
o.SQL.TruncateTextAt = 4000;
|
||||
o.SQL.IncludeCategory = true; // fds__admin_logdebug_ext declares @category
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize FdsConfig so FdsMfr / FdsMfrClient can resolve connection strings
|
||||
fds.FdsConfig.Initialize(builder.Configuration);
|
||||
|
||||
@@ -115,8 +138,22 @@ public class Program
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
|
||||
// Communication service (email + SMS via ProcessWeb Mailer API)
|
||||
// Communication service (email + SMS via ProcessWeb Mailer API). Transport is the OCORE_web
|
||||
// PWMailer client (ADR 0029); its options are mapped from the existing Fuchs:Mailer section
|
||||
// rather than a second config section, so the Key Vault-managed Fuchs:Mailer:Token keeps working
|
||||
// (secret Fuchs--Mailer--Token also named as the on-demand fallback for the placeholder case).
|
||||
builder.Services.Configure<ProcessWebComSettings>(builder.Configuration.GetSection("Fuchs:Mailer"));
|
||||
builder.Services.AddOptions<PWMailerClientOptions>()
|
||||
.Configure<IOptions<ProcessWebComSettings>>((o, s) =>
|
||||
{
|
||||
o.BaseUrl = s.Value.BaseUrl.TrimEnd('/') + "/api/mailer";
|
||||
o.Account = s.Value.AccountId;
|
||||
o.Token = s.Value.Token;
|
||||
o.TokenSecretName = "Fuchs--Mailer--Token";
|
||||
o.ServerId = s.Value.ServerId;
|
||||
o.UserAgent = "Fuchs Intranet";
|
||||
});
|
||||
builder.Services.AddPWMailerClient<FuchsMailerClient>();
|
||||
// Dev/test safety net: Fuchs:Email:OverrideRecipient redirects every outbound email
|
||||
// (see appsettings.Development.json) so real tenant-owners/end-customers are never emailed.
|
||||
builder.Services.Configure<FuchsEmailSettings>(builder.Configuration.GetSection("Fuchs:Email"));
|
||||
@@ -127,7 +164,6 @@ public class Program
|
||||
builder.Services.AddSingleton<IERechnungValidator, ProcessWebERechnungValidator>(); // external EN16931 + PDF/A-3 check
|
||||
builder.Services.AddHttpClient(ProcessWebERechnungValidator.HttpClientName,
|
||||
c => c.Timeout = TimeSpan.FromSeconds(90));
|
||||
builder.Services.AddHttpClient("ProcessWebMailer");
|
||||
// Fetches invoice-editor images (mfr:// picture links) for inline PDF rendering; whitelist-
|
||||
// restricted to the mfr portal (see InvoiceService.AllowedInlineImagePrefix / ADR 0018).
|
||||
builder.Services.AddHttpClient(InvoiceService.InlineImageHttpClientName,
|
||||
@@ -145,6 +181,7 @@ public class Program
|
||||
builder.Services.AddScoped<IWidgetService, FuchsWidgetService>();
|
||||
builder.Services.AddScoped<IReportService, FuchsReportService>();
|
||||
builder.Services.AddScoped<IInvoiceService, InvoiceService>();
|
||||
builder.Services.AddScoped<IInvoiceFileBackupService, InvoiceFileBackupService>();
|
||||
builder.Services.AddScoped<IReminderService, ReminderService>();
|
||||
builder.Services.AddScoped<IEventService, EventService>();
|
||||
|
||||
@@ -215,6 +252,38 @@ public class Program
|
||||
// calls silently no-op instead of reaching Debug output / ErrorLog.txt.
|
||||
OCORE.Logging.Logger = app.Logger;
|
||||
|
||||
// Exceptions that never pass through a request (Do()'s catch-all, PdfPreviewAsync,
|
||||
// UseExceptionHandler) or a hosted service's own try/catch — a faulted fire-and-forget
|
||||
// Task nobody awaited, or a crash on a non-request thread — are otherwise visible only
|
||||
// as a debugger notification or a process exit: they never reach ILogger and therefore
|
||||
// never reach fds__debuglog (ADR 0024). Route them through the app logger so the SQL
|
||||
// sink and ErrorLog.txt both see them. On a terminating crash the fire-and-forget SQL
|
||||
// write may not complete; the synchronous file sink (0003) is the record then.
|
||||
TaskScheduler.UnobservedTaskException += (_, e) =>
|
||||
{
|
||||
app.Logger.LogError(new EventId(0, "Unobserved task exception"), e.Exception,
|
||||
"Unobserved task exception: {Message}", e.Exception.GetBaseException().Message);
|
||||
e.SetObserved();
|
||||
};
|
||||
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
|
||||
{
|
||||
var ex = e.ExceptionObject as Exception;
|
||||
app.Logger.LogCritical(new EventId(0, "Unhandled exception"), ex,
|
||||
"Unhandled exception on a non-request thread (terminating={IsTerminating}): {Message}",
|
||||
e.IsTerminating, ex?.Message ?? e.ExceptionObject?.ToString());
|
||||
};
|
||||
|
||||
// One deliberate Critical entry per application start. Critical is the one level no
|
||||
// provider filters (file sink, console, SQL sink at Warning+), so a fds__debuglog row
|
||||
// with CodeReference 'Application started' proves on every restart that the whole
|
||||
// log pipeline (ADR 0024) is working end to end — and its absence is itself the alarm.
|
||||
// Registered on ApplicationStarted so it is written once the host and every sink are up.
|
||||
app.Lifetime.ApplicationStarted.Register(() =>
|
||||
app.Logger.LogCritical(new EventId(0, "Application started"),
|
||||
"Fuchs started: version={Version} environment={Environment} machine={Machine} pid={Pid}",
|
||||
typeof(Program).Assembly.GetName().Version?.ToString() ?? "?",
|
||||
app.Environment.EnvironmentName, Environment.MachineName, Environment.ProcessId));
|
||||
|
||||
if (!app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseExceptionHandler("/error");
|
||||
@@ -238,6 +307,9 @@ public class Program
|
||||
|
||||
app.UseRouting();
|
||||
app.UseAuthentication();
|
||||
// After authentication (needs the user claim) and before authorization/dispatch — see
|
||||
// RequestLogScopeMiddleware for what this attributes every log line in the request to.
|
||||
app.UseMiddleware<RequestLogScopeMiddleware>();
|
||||
app.UseAuthorization();
|
||||
app.MapHub<NotificationHub>("/notifications");
|
||||
app.MapHub<DraftPreviewHub>("/draftpreview");
|
||||
|
||||
@@ -68,6 +68,60 @@ public class AzureBlobStorageService : IBlobStorageService
|
||||
byte[] content, IReadOnlyDictionary<string, object?>? sourceRow = null, CancellationToken cancellationToken = default)
|
||||
=> UploadAsync(category, containerName, documentId, fileName, content, sourceRow, cancellationToken);
|
||||
|
||||
public Task<Uri?> CopyInvoicePdfToBackupAsync(string invoiceId, string fileName,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> CopyToBackupAsync("invoice", _settings.InvoiceContainer, invoiceId, fileName, cancellationToken);
|
||||
|
||||
private async Task<Uri?> CopyToBackupAsync(string category, string containerName, string documentId,
|
||||
string fileName, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_client == null) return null;
|
||||
|
||||
string blobName = BuildBlobName(documentId, fileName);
|
||||
using var act = FuchsTelemetry.StartActivity("blobstorage.copytobackup");
|
||||
act?.SetTag("fuchs.blobstorage.category", category);
|
||||
act?.SetTag("fuchs.blobstorage.id", documentId);
|
||||
|
||||
try
|
||||
{
|
||||
var containerClient = _client.GetBlobContainerClient(containerName);
|
||||
var sourceBlob = containerClient.GetBlobClient(blobName);
|
||||
Response<bool> exists = await sourceBlob.ExistsAsync(cancellationToken);
|
||||
if (!exists.Value)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Blob backup-copy skipped for {Category} {Id} — no existing blob at {Blob}.",
|
||||
category, documentId, blobName);
|
||||
return null;
|
||||
}
|
||||
|
||||
string backupBlobFileName = string.IsNullOrWhiteSpace(fileName) ? $"{documentId}.pdf" : fileName;
|
||||
string backupName = $"{documentId}/backup_{DateTime.UtcNow:yyyyMMdd_HHmmss}_{backupBlobFileName}";
|
||||
var backupBlob = containerClient.GetBlobClient(backupName);
|
||||
// Server-side copy: the bytes never pass through this process. A private source in the
|
||||
// same storage account is authorised by the request's own Shared Key credential, so no
|
||||
// SAS is needed. WaitForCompletionAsync throws if the copy ends in a failed/aborted state.
|
||||
// The original is deliberately left in place — it stays the "current" archive until the
|
||||
// next regeneration overwrites it, and nothing can be half-renamed.
|
||||
CopyFromUriOperation copy = await backupBlob.StartCopyFromUriAsync(
|
||||
sourceBlob.Uri, (BlobCopyFromUriOptions?)null, cancellationToken);
|
||||
await copy.WaitForCompletionAsync(cancellationToken);
|
||||
|
||||
FuchsTelemetry.BlobBackupCopiesSucceeded.Add(1, new KeyValuePair<string, object?>("category", category));
|
||||
_logger.LogInformation("Copied {Category} {Id} blob {Old} to backup {New}.",
|
||||
category, documentId, blobName, backupName);
|
||||
return backupBlob.Uri;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
FuchsTelemetry.BlobBackupCopiesFailed.Add(1, new KeyValuePair<string, object?>("category", category));
|
||||
act?.SetStatus(ActivityStatusCode.Error, ex.Message);
|
||||
_logger.LogError(ex, "Blob backup-copy failed for {Category} {Id} ({Blob}).",
|
||||
category, documentId, blobName);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> ExistsAsync(string containerName, string documentId, string fileName,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -81,6 +135,12 @@ public class AzureBlobStorageService : IBlobStorageService
|
||||
Response<bool> response = await blobClient.ExistsAsync(cancellationToken);
|
||||
return response.Value;
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Caller cancelled (e.g. app shutdown during the startup backfill) — not a storage failure;
|
||||
// reporting "not archived" here made the backfill go on to a doomed upload logged as an error.
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Blob existence check failed for {Container}/{Blob} — treating as not archived.",
|
||||
@@ -216,6 +276,11 @@ public class AzureBlobStorageService : IBlobStorageService
|
||||
category, documentId, containerName, blobName);
|
||||
return blobClient.Uri;
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogDebug("Blob upload cancelled for {Category} {Id} (caller cancelled).", category, documentId);
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
FuchsTelemetry.BlobUploadsFailed.Add(1, new KeyValuePair<string, object?>("category", category));
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of a draft flush ("Zwischenspeichern", <see cref="IInvoiceDraftService.FlushToDbAsync"/> /
|
||||
/// <see cref="IReminderDraftService.FlushToDbAsync"/>). The draft service alone decides whether a
|
||||
/// flush creates a DB row or updates the one an earlier flush created — flushes of one session are
|
||||
/// serialised, so a save that overlaps the first registration of a new draft becomes an update — and
|
||||
/// reports that decision here so the caller publishes the matching domain event instead of guessing
|
||||
/// from the session state it saw before the flush.
|
||||
/// </summary>
|
||||
/// <param name="Data">The registered data; its <c>Id</c> is empty when the registration failed.</param>
|
||||
/// <param name="Updated"><c>true</c> when the registration ran as an update of an existing DB draft, <c>false</c> for a create.</param>
|
||||
public sealed record DraftFlushResult<T>(T Data, bool Updated) where T : class;
|
||||
@@ -259,13 +259,7 @@ public static class ERechnungMapper
|
||||
}
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────
|
||||
private static DateOnly IssueDate(GenericObjectDictionary? reg)
|
||||
{
|
||||
foreach (var key in new[] { "DateFinalized", "DateCreated" })
|
||||
if (reg?.getString(key) is { Length: > 0 } s && DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dt))
|
||||
return DateOnly.FromDateTime(dt);
|
||||
return DateOnly.FromDateTime(DateTime.Today);
|
||||
}
|
||||
private static DateOnly IssueDate(GenericObjectDictionary? reg) => InvoiceDates.IssueDate(reg);
|
||||
|
||||
/// <summary>Normalises a raw country value (ISO alpha-2, or a German/English name) to a country code.</summary>
|
||||
internal static CountryCode NormalizeCountry(string? raw)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OCORE.web;
|
||||
using OCORE.web.pwmailer;
|
||||
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The OCORE_web <see cref="PWMailerClientService"/> with Fuchs' pre-validation: on top of the
|
||||
/// contract rules the base applies, every outbound message is checked against the Mailer's
|
||||
/// attachment limits (<see cref="MailerAttachmentPolicy"/>, configured under
|
||||
/// <c>Fuchs:Mailer:Attachments</c>) before any bytes are sent. Registered as the application's
|
||||
/// <see cref="IPWMailerClientService"/> in <c>Program.cs</c>; options are mapped from
|
||||
/// <c>Fuchs:Mailer</c> (<see cref="ProcessWebComSettings"/>) there.
|
||||
/// </summary>
|
||||
public class FuchsMailerClient : PWMailerClientService
|
||||
{
|
||||
private readonly MailerAttachmentLimits _limits;
|
||||
|
||||
public FuchsMailerClient(IOptions<PWMailerClientOptions> options, IOptions<ProcessWebComSettings> settings,
|
||||
IHttpClientFactory httpClientFactory, ILogger<PWMailerClientService> logger,
|
||||
IOCORE_web_securityservice? securityService = null)
|
||||
: base(options, httpClientFactory, logger, securityService)
|
||||
{
|
||||
_limits = settings.Value.Attachments;
|
||||
}
|
||||
|
||||
protected override IReadOnlyList<string> ValidateMessage(PWMailerMessage message)
|
||||
{
|
||||
var problems = new List<string>(base.ValidateMessage(message));
|
||||
problems.AddRange(MailerAttachmentPolicy.Validate(message.Attachments, _limits));
|
||||
return problems;
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,8 @@ public interface IBlobStorageService
|
||||
/// supplied (typically <c>FdsInvoiceData.InvoiceRegistration</c>), blob metadata is projected from
|
||||
/// it using <see cref="AzureBlobStorageSettings.MetadataFields"/> — see <see cref="DocumentMetadataBuilder"/>.
|
||||
/// Returns the blob URI, or <c>null</c> when storage is disabled/unconfigured
|
||||
/// or the upload failed — failures never break the primary DB-storage flow.
|
||||
/// or the upload failed — failures never break the primary DB-storage flow. Only a cancelled
|
||||
/// <paramref name="cancellationToken"/> surfaces, as <see cref="OperationCanceledException"/>.
|
||||
/// </summary>
|
||||
Task<Uri?> UploadInvoicePdfAsync(string invoiceId, string fileName, byte[] content,
|
||||
IReadOnlyDictionary<string, object?>? sourceRow = null, CancellationToken cancellationToken = default);
|
||||
@@ -37,10 +38,21 @@ public interface IBlobStorageService
|
||||
Task<Uri?> UploadDocumentAsync(string category, string containerName, string documentId, string fileName,
|
||||
byte[] content, IReadOnlyDictionary<string, object?>? sourceRow = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Server-side copies an existing invoice PDF blob to a timestamped backup name in the same
|
||||
/// container; the original blob is left in place. Called before an invoice's stored file is
|
||||
/// cleared: the next regenerate-on-read (ADR 0025) uploads under the original blob name with
|
||||
/// <c>overwrite: true</c>, and the backup copy is what survives that. Returns the backup
|
||||
/// blob's URI, or <c>null</c> when there was nothing to back up, storage is disabled/unconfigured,
|
||||
/// or the copy failed — logged, never throws.
|
||||
/// </summary>
|
||||
Task<Uri?> CopyInvoicePdfToBackupAsync(string invoiceId, string fileName, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns <c>true</c> if a blob already exists for the given container/document/filename
|
||||
/// combination. Used by the startup backfill to skip documents that were already archived.
|
||||
/// Returns <c>false</c> (never throws) when storage is disabled/unconfigured or the check fails.
|
||||
/// Returns <c>false</c> when storage is disabled/unconfigured or the check fails; throws only
|
||||
/// <see cref="OperationCanceledException"/> when <paramref name="cancellationToken"/> is cancelled.
|
||||
/// </summary>
|
||||
Task<bool> ExistsAsync(string containerName, string documentId, string fileName,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction for outbound communication: email and SMS.
|
||||
/// Backed by the ProcessWeb Mailer API (POST {BaseUrl}/mailer/push_com).
|
||||
/// Backed by the ProcessWeb Mailer API (POST {BaseUrl}/api/mailer/push_com); contract at
|
||||
/// https://processweb.de/api/mailer/fc, client-side description in
|
||||
/// Docs/Concepts/processweb-mailer-interface.md.
|
||||
/// </summary>
|
||||
public interface IComService
|
||||
{
|
||||
@@ -14,9 +16,15 @@ public interface IComService
|
||||
/// <param name="html">HTML body content (signature is appended automatically).</param>
|
||||
/// <param name="email">Recipient email address.</param>
|
||||
/// <param name="name">Recipient display name.</param>
|
||||
/// <param name="attachments">Optional file attachments (filename → content).</param>
|
||||
/// <param name="attachments">
|
||||
/// Optional file attachments (filename → content). Validated against the Mailer's limits
|
||||
/// (<see cref="MailerAttachmentPolicy"/>) before sending; any violation fails the send.
|
||||
/// </param>
|
||||
/// <param name="cc">Optional Cc recipient (e.g. a customer's "Zusatzempfänger").</param>
|
||||
/// <returns><c>true</c> when the email was accepted successfully.</returns>
|
||||
/// <returns>
|
||||
/// <c>true</c> only when the Mailer accepted the message <em>and</em> confirmed that every
|
||||
/// attachment was processed (<c>attachmentsProcessed</c> equals the number sent).
|
||||
/// </returns>
|
||||
Task<bool> SendEmailAsync(string reference, string subject, string html,
|
||||
string email, string name, Dictionary<string, byte[]>? attachments = null, string? cc = null);
|
||||
|
||||
@@ -27,4 +35,30 @@ public interface IComService
|
||||
/// <param name="message">Text message body.</param>
|
||||
/// <returns><c>true</c> when the SMS was accepted successfully.</returns>
|
||||
Task<bool> SendSmsAsync(string mobile, string message);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the configured account/token against the Mailer API's credential-check
|
||||
/// endpoint (GET {BaseUrl}/api/mailer/verify) without sending anything.
|
||||
/// </summary>
|
||||
Task<MailerCredentialsCheckResult> VerifyCredentialsAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>Outcome of a Mailer API credential verification (see <see cref="IComService.VerifyCredentialsAsync"/>).</summary>
|
||||
public sealed class MailerCredentialsCheckResult
|
||||
{
|
||||
/// <summary><c>true</c> only when the Mailer API confirmed the credentials (HTTP 200, <c>ok:true</c>).</summary>
|
||||
public bool Ok { get; init; }
|
||||
|
||||
/// <summary>Account name the Mailer API echoed back, when verified.</summary>
|
||||
public string? Account { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Server ID the Mailer API echoed back. Only present when a <c>serverid</c> was sent with
|
||||
/// the request (i.e. <see cref="ProcessWebComSettings.ServerId"/> is configured); in that
|
||||
/// case <see cref="Ok"/> is only <c>true</c> when this server belongs to the account too.
|
||||
/// </summary>
|
||||
public string? ServerId { get; init; }
|
||||
|
||||
/// <summary>Human-readable failure detail (status code / body / exception message), never the token.</summary>
|
||||
public string? Detail { get; init; }
|
||||
}
|
||||
|
||||
@@ -45,10 +45,13 @@ public interface IInvoiceDraftService
|
||||
|
||||
/// <summary>
|
||||
/// Persists the cached session to the DB via the existing invoice registration path
|
||||
/// ("Zwischenspeichern"). Sets <see cref="InvoiceDraftSession.InvId"/> on success.
|
||||
/// Returns the registered invoice data (for the success event), or null if the token is unknown.
|
||||
/// ("Zwischenspeichern"). Sets <see cref="InvoiceDraftSession.InvId"/> on success. Flushes of one
|
||||
/// session are serialised (<see cref="InvoiceDraftSession.FlushGate"/>): an overlapping save of a
|
||||
/// not-yet-registered draft waits for the first registration and then runs as an update, so a
|
||||
/// double-click never creates two DB rows. Returns the registered data plus whether the flush
|
||||
/// ran as an update (for the success event), or null if the token is unknown.
|
||||
/// </summary>
|
||||
Task<FdsInvoiceData?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec);
|
||||
Task<DraftFlushResult<FdsInvoiceData>?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec);
|
||||
|
||||
/// <summary>Renders a draft PDF straight from the cached session (no client upload). Null if token unknown.</summary>
|
||||
Task<Document?> RenderPreviewAsync(string token);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using OCORE.security;
|
||||
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Admin-only: backs up an invoice's stored PDF and clears it so the document regenerates on the
|
||||
/// next read (ADR 0025). See Docs/Decisions/0027-admin-invoice-file-backup-and-clear.md for why
|
||||
/// this is a dedicated service/proc rather than reusing the manual-only
|
||||
/// <c>_BackupAndClearInvoiceFile</c> maintenance tool.
|
||||
/// </summary>
|
||||
public interface IInvoiceFileBackupService
|
||||
{
|
||||
/// <summary>
|
||||
/// Backs up the invoice's current stored file (DB row via
|
||||
/// <c>fds__admin_backupAndClearInvoiceFile</c>, plus a best-effort rename-to-backup of the
|
||||
/// archived blob) and clears the stored file.
|
||||
/// </summary>
|
||||
Task<InvoiceFileBackupResult> BackupAndClearAsync(string id, string userAccountId, DatabaseSecurity dbSec,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>Outcome of <see cref="IInvoiceFileBackupService.BackupAndClearAsync"/>.</summary>
|
||||
public sealed class InvoiceFileBackupResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
|
||||
/// <summary>Machine-readable reason when <see cref="Success"/> is <c>false</c>.</summary>
|
||||
public string? Reason { get; init; }
|
||||
|
||||
/// <summary>The archived blob's backup URI, when the blob rename succeeded (best-effort — can be <c>null</c> even on success).</summary>
|
||||
public Uri? BlobBackupUri { get; init; }
|
||||
}
|
||||
@@ -43,10 +43,13 @@ public interface IReminderDraftService
|
||||
|
||||
/// <summary>
|
||||
/// Persists the cached session to the DB via the existing reminder registration path
|
||||
/// ("Zwischenspeichern"). Sets <see cref="ReminderDraftSession.RemId"/> on success.
|
||||
/// Returns the registered reminder data (for the success event), or null if the token is unknown.
|
||||
/// ("Zwischenspeichern"). Sets <see cref="ReminderDraftSession.RemId"/> on success. Flushes of one
|
||||
/// session are serialised (<see cref="ReminderDraftSession.FlushGate"/>): an overlapping save of a
|
||||
/// not-yet-registered draft waits for the first registration and then runs as an update, so a
|
||||
/// double-click never creates two DB rows. Returns the registered data plus whether the flush
|
||||
/// ran as an update (for the success event), or null if the token is unknown.
|
||||
/// </summary>
|
||||
Task<FdsReminderData?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec);
|
||||
Task<DraftFlushResult<FdsReminderData>?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec);
|
||||
|
||||
/// <summary>Renders a draft PDF straight from the cached session (no client upload). Null if token unknown.</summary>
|
||||
Document? RenderPreview(string token);
|
||||
|
||||
@@ -22,7 +22,11 @@ public interface ISystemStatusService
|
||||
/// <summary>
|
||||
/// Sends a test email through the normal <see cref="IComService"/> pipeline, so the
|
||||
/// dev/test recipient override (<see cref="FuchsEmailSettings.OverrideRecipient"/>) applies
|
||||
/// exactly as for any other outbound email.
|
||||
/// exactly as for any other outbound email. With <paramref name="attachPdf"/> (the default)
|
||||
/// a small letter-layout PDF rendered from the test mail's subject/body is attached, so the
|
||||
/// Mailer's attachment path (multipart + <c>attachmentsProcessed</c> confirmation, ADR 0028)
|
||||
/// is exercised too; <c>false</c> sends a plain text-only mail.
|
||||
/// </summary>
|
||||
Task<EmailTestResult> SendTestEmailAsync(string to, string subject, string body, CancellationToken cancellationToken = default);
|
||||
Task<EmailTestResult> SendTestEmailAsync(string to, string subject, string body,
|
||||
bool attachPdf = true, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -60,27 +60,32 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
||||
var session = _cache.Get(token);
|
||||
if (session == null) return null;
|
||||
|
||||
string oldValue = "", newValue = "";
|
||||
bool mutated = ApplyDelta(session, delta, ref oldValue, ref newValue);
|
||||
if (!mutated)
|
||||
// The editor fires patches (and state/preview fetches) concurrently for one token; without
|
||||
// the lock a block.replace on one request mutates Req while another enumerates it.
|
||||
lock (session.SyncRoot)
|
||||
{
|
||||
_logger.LogDebug("Draft {Token}: no-op patch target={Target} ref={Ref}", token, delta.Target, delta.Ref);
|
||||
string oldValue = "", newValue = "";
|
||||
bool mutated = ApplyDelta(session, delta, ref oldValue, ref newValue);
|
||||
if (!mutated)
|
||||
{
|
||||
_logger.LogDebug("Draft {Token}: no-op patch target={Target} ref={Ref}", token, delta.Target, delta.Ref);
|
||||
return session;
|
||||
}
|
||||
|
||||
Refresh(session);
|
||||
session.Version++;
|
||||
session.History.Add(new ChangeHistoryEntry
|
||||
{
|
||||
UserAccountId = session.UserAccountId,
|
||||
Target = delta.Target,
|
||||
Ref = delta.Ref,
|
||||
OldValue = oldValue,
|
||||
NewValue = newValue,
|
||||
Version = session.Version
|
||||
});
|
||||
_cache.Set(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
Refresh(session);
|
||||
session.Version++;
|
||||
session.History.Add(new ChangeHistoryEntry
|
||||
{
|
||||
UserAccountId = session.UserAccountId,
|
||||
Target = delta.Target,
|
||||
Ref = delta.Ref,
|
||||
OldValue = oldValue,
|
||||
NewValue = newValue,
|
||||
Version = session.Version
|
||||
});
|
||||
_cache.Set(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -424,33 +429,40 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
||||
}
|
||||
|
||||
// ── View state / history ────────────────────────────────────────────────
|
||||
/// <remarks>
|
||||
/// The result is serialised by the caller after this returns, so everything mutable is a
|
||||
/// snapshot taken under the session lock — never a live reference into the session.
|
||||
/// </remarks>
|
||||
public object BuildState(InvoiceDraftSession session)
|
||||
{
|
||||
session.Touch();
|
||||
return new
|
||||
lock (session.SyncRoot)
|
||||
{
|
||||
token = session.Token,
|
||||
version = session.Version,
|
||||
invid = session.InvId,
|
||||
isDraft = session.IsDraft,
|
||||
admin = session.Admin,
|
||||
@new = session.New,
|
||||
req = session.Req,
|
||||
sums = new
|
||||
return new
|
||||
{
|
||||
total_net = session.Sums.TotalNet,
|
||||
total_gross = session.Sums.TotalGross,
|
||||
total_vat = session.Sums.TotalVat,
|
||||
service_net = session.Sums.ServiceNet,
|
||||
service_vat = session.Sums.ServiceVat,
|
||||
vat = session.Sums.VatByRate,
|
||||
block_net = session.Sums.NetByBlock
|
||||
},
|
||||
validation = session.ValidationMessages.Select(v => new { field = v.Field, severity = v.Severity, message = v.Message }),
|
||||
historyCount = session.History.Count,
|
||||
notes = BuildNotes(session),
|
||||
setDisplay = BuildSetDisplay(session)
|
||||
};
|
||||
token = session.Token,
|
||||
version = session.Version,
|
||||
invid = session.InvId,
|
||||
isDraft = session.IsDraft,
|
||||
admin = session.Admin.DeepClone(),
|
||||
@new = session.New.DeepClone(),
|
||||
req = session.Req.DeepClone(),
|
||||
sums = new
|
||||
{
|
||||
total_net = session.Sums.TotalNet,
|
||||
total_gross = session.Sums.TotalGross,
|
||||
total_vat = session.Sums.TotalVat,
|
||||
service_net = session.Sums.ServiceNet,
|
||||
service_vat = session.Sums.ServiceVat,
|
||||
vat = new Dictionary<string, decimal>(session.Sums.VatByRate),
|
||||
block_net = new Dictionary<string, decimal>(session.Sums.NetByBlock)
|
||||
},
|
||||
validation = session.ValidationMessages.Select(v => new { field = v.Field, severity = v.Severity, message = v.Message }).ToList(),
|
||||
historyCount = session.History.Count,
|
||||
notes = BuildNotes(session).ToList(),
|
||||
setDisplay = BuildSetDisplay(session)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -497,34 +509,60 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
||||
.Select(n => new { text = n.Text, style = n.Style });
|
||||
}
|
||||
|
||||
public IReadOnlyList<ChangeHistoryEntry> GetHistory(string token) =>
|
||||
_cache.Get(token)?.History ?? (IReadOnlyList<ChangeHistoryEntry>)Array.Empty<ChangeHistoryEntry>();
|
||||
public IReadOnlyList<ChangeHistoryEntry> GetHistory(string token)
|
||||
{
|
||||
var session = _cache.Get(token);
|
||||
if (session == null) return Array.Empty<ChangeHistoryEntry>();
|
||||
lock (session.SyncRoot) return session.History.ToList();
|
||||
}
|
||||
|
||||
// ── Flush / preview ────────────────────────────────────────────────────────
|
||||
public async Task<FdsInvoiceData?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec)
|
||||
public async Task<DraftFlushResult<FdsInvoiceData>?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec)
|
||||
{
|
||||
var session = _cache.Get(token);
|
||||
if (session == null) return null;
|
||||
|
||||
var fds = BuildFdsData(session);
|
||||
bool change = !string.IsNullOrEmpty(session.InvId);
|
||||
var reg = await _invoices.RegisterInvoiceAsync(fds, change, session.InvId, userAccountId, dbSec);
|
||||
if (!string.IsNullOrEmpty(reg.Id))
|
||||
// One flush at a time per session: the create/update decision below is read-then-write across
|
||||
// an await, so two overlapping saves of a new draft would both see an empty InvId and both
|
||||
// call fds__createInvoice. The second save waits here, then runs as an update of the row the
|
||||
// first one created (with its own, newer snapshot).
|
||||
await session.FlushGate.WaitAsync();
|
||||
try
|
||||
{
|
||||
session.InvId = reg.Id;
|
||||
_cache.Set(session);
|
||||
_logger.LogInformation("Draft {Token} flushed to DB invoice {InvId} (change={Change}, user={User})",
|
||||
token, reg.Id, change, userAccountId);
|
||||
FdsInvoiceData fds;
|
||||
string invId;
|
||||
lock (session.SyncRoot)
|
||||
{
|
||||
fds = BuildFdsData(session); // deep-cloned snapshot — the await below runs outside the lock
|
||||
invId = session.InvId;
|
||||
}
|
||||
bool change = !string.IsNullOrEmpty(invId);
|
||||
var reg = await _invoices.RegisterInvoiceAsync(fds, change, invId, userAccountId, dbSec);
|
||||
if (!string.IsNullOrEmpty(reg.Id))
|
||||
{
|
||||
lock (session.SyncRoot) session.InvId = reg.Id;
|
||||
_cache.Set(session);
|
||||
_logger.LogInformation("Draft {Token} flushed to DB invoice {InvId} (change={Change}, user={User})",
|
||||
token, reg.Id, change, userAccountId);
|
||||
}
|
||||
return new DraftFlushResult<FdsInvoiceData>(reg, change);
|
||||
}
|
||||
finally
|
||||
{
|
||||
session.FlushGate.Release();
|
||||
}
|
||||
return reg;
|
||||
}
|
||||
|
||||
public async Task<Document?> RenderPreviewAsync(string token)
|
||||
{
|
||||
var session = _cache.Get(token);
|
||||
if (session == null) return null;
|
||||
var fds = BuildFdsData(session);
|
||||
fds.InvoiceRegistration = SynthesizeRegistration(session);
|
||||
FdsInvoiceData fds;
|
||||
lock (session.SyncRoot)
|
||||
{
|
||||
fds = BuildFdsData(session);
|
||||
fds.InvoiceRegistration = SynthesizeRegistration(session);
|
||||
}
|
||||
fds.IsDraft = true;
|
||||
return await _invoices.GenerateInvoicePdfAsync(fds, draft: true);
|
||||
}
|
||||
@@ -616,7 +654,8 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
||||
["SendToAddressJson"] = InvoiceRecipientAddress.FromCustomValues(Str(session.New["CustomValues"]))?
|
||||
.ToJson().ToString(Newtonsoft.Json.Formatting.None) ?? "",
|
||||
["InvoiceOptions"] = BuildInvoiceOptions(session),
|
||||
["DateCreated"] = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
|
||||
// UTC like the stored column — InvoiceDates.IssueDate converts it to the German day
|
||||
["DateCreated"] = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
|
||||
};
|
||||
|
||||
int idx = 0;
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Observability;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OCORE.security;
|
||||
using OCORE.SQL;
|
||||
using static OCORE.commons;
|
||||
using static OCORE.SQL.sql;
|
||||
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>See <see cref="IInvoiceFileBackupService"/>.</summary>
|
||||
public class InvoiceFileBackupService : IInvoiceFileBackupService
|
||||
{
|
||||
private static readonly EventId BackupClearEvent = new(0, "AdminBackupClearInvoiceFile");
|
||||
|
||||
private readonly Fuchs_intranet _intranet;
|
||||
private readonly IInvoiceService _invoices;
|
||||
private readonly IBlobStorageService _blobStorage;
|
||||
private readonly ILogger<InvoiceFileBackupService> _logger;
|
||||
|
||||
public InvoiceFileBackupService(Fuchs_intranet intranet, IInvoiceService invoices,
|
||||
IBlobStorageService blobStorage, ILogger<InvoiceFileBackupService> logger)
|
||||
{
|
||||
_intranet = intranet;
|
||||
_invoices = invoices;
|
||||
_blobStorage = blobStorage;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private string Conn => _intranet.Intranet__SQLConnectionString;
|
||||
|
||||
public async Task<InvoiceFileBackupResult> BackupAndClearAsync(string id, string userAccountId,
|
||||
DatabaseSecurity dbSec, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var invoice = await _invoices.LoadInvoiceAsync(id, userAccountId, dbSec);
|
||||
// LoadInvoiceAsync always yields a registration (an empty dictionary for an unknown id),
|
||||
// so "not found" is an empty Id, never a null registration.
|
||||
if (invoice.InvoiceRegistration == null || string.IsNullOrEmpty(invoice.Id))
|
||||
{
|
||||
_logger.LogWarning(BackupClearEvent,
|
||||
"BackupAndClearAsync: invoice {Id} not found, user={User}", id, userAccountId);
|
||||
FuchsTelemetry.InvoiceFileBackups.Add(1, new KeyValuePair<string, object?>("outcome", "invoice_not_found"));
|
||||
return new InvoiceFileBackupResult { Success = false, Reason = "invoice_not_found" };
|
||||
}
|
||||
|
||||
// Same precondition as regenerate-on-read (ADR 0025): only a finalised, non-external
|
||||
// invoice may have its file cleared. Clearing a draft's file would let the next read
|
||||
// persist a draft render through fds__setInvoiceFile (WHERE [file] IS NULL), which then
|
||||
// permanently blocks the real final PDF; an external (MFR) document has no template to
|
||||
// regenerate from, so clearing it would just leave a permanent 404.
|
||||
bool isExternal = invoice.InvoiceRegistration.getItem("IsExternal", false) is true;
|
||||
if (invoice.IsDraft || isExternal)
|
||||
{
|
||||
string reason = invoice.IsDraft ? "not_final" : "external";
|
||||
_logger.LogWarning(BackupClearEvent,
|
||||
"BackupAndClearAsync: refused for {Id} ({Reason}) — only finalised, non-external invoices can be cleared, user={User}",
|
||||
invoice.Id, reason, userAccountId);
|
||||
FuchsTelemetry.InvoiceFileBackups.Add(1, new KeyValuePair<string, object?>("outcome", reason));
|
||||
return new InvoiceFileBackupResult { Success = false, Reason = reason };
|
||||
}
|
||||
|
||||
// fds__getInvoice reports hasFile; without a stored file there is nothing to back up or
|
||||
// clear, so stop here — before the blob copy — rather than leaving a spare backup_* blob
|
||||
// behind on every repeated click on an already-cleared invoice. The proc re-checks
|
||||
// [file] IS NOT NULL itself (OUTPUT row), so a race between this read and the clear is
|
||||
// still reported as no_file, just with one extra blob copy.
|
||||
if (invoice.InvoiceRegistration.getItem("hasFile", false) is not true)
|
||||
{
|
||||
_logger.LogWarning(BackupClearEvent,
|
||||
"BackupAndClearAsync: no file stored for {Id} — nothing to back up or clear, user={User}",
|
||||
invoice.Id, userAccountId);
|
||||
FuchsTelemetry.InvoiceFileBackups.Add(1, new KeyValuePair<string, object?>("outcome", "no_file"));
|
||||
return new InvoiceFileBackupResult { Success = false, Reason = "no_file" };
|
||||
}
|
||||
|
||||
string fileName = invoice.InvoiceRegistration.getString("DocumentName").ne($"Rechnung_{invoice.Id}.pdf");
|
||||
|
||||
// Copy (not move) the archived blob to a backup name BEFORE the DB clear and leave the
|
||||
// original in place: the backup therefore exists before any regenerate-on-read (ADR 0025)
|
||||
// could overwrite the original name, and a DB failure/no-op below leaves the canonical
|
||||
// archive blob untouched (only a spare copy remains — harmless). Best-effort: a failure
|
||||
// here is logged, the DB backup row remains the authoritative copy.
|
||||
Uri? blobBackupUri;
|
||||
try
|
||||
{
|
||||
blobBackupUri = await _blobStorage.CopyInvoicePdfToBackupAsync(invoice.Id, fileName, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(BackupClearEvent, ex,
|
||||
"BackupAndClearAsync: blob backup-copy threw for {Id}, user={User}", invoice.Id, userAccountId);
|
||||
blobBackupUri = null;
|
||||
}
|
||||
|
||||
var pl = new List<SqlParameter>
|
||||
{
|
||||
SQL_VarChar("@authuser", userAccountId),
|
||||
SQL_VarChar("@Id", invoice.Id),
|
||||
};
|
||||
// fds__admin_backupAndClearInvoiceFile writes WHERE [file] IS NOT NULL and returns the
|
||||
// cleared Id via its OUTPUT clause, so an empty result set means nothing was backed
|
||||
// up/cleared (invoice missing, or file already empty) — rows-affected can't be used for
|
||||
// this because the proc runs SET NOCOUNT ON (same reasoning as fds__setInvoiceFile, ADR 0025).
|
||||
var cleared = await getSQLDatatable_async(
|
||||
"EXECUTE [dbo].[fds__admin_backupAndClearInvoiceFile] @authuser, @Id;",
|
||||
Conn, pl, Security: dbSec, options: new FIS_SQLOptions());
|
||||
if (!string.IsNullOrEmpty(cleared.Exception))
|
||||
{
|
||||
_logger.LogError(BackupClearEvent,
|
||||
"BackupAndClearAsync: fds__admin_backupAndClearInvoiceFile failed for {Id}: {Error}, user={User}",
|
||||
invoice.Id, cleared.Exception, userAccountId);
|
||||
FuchsTelemetry.InvoiceFileBackups.Add(1, new KeyValuePair<string, object?>("outcome", "db_error"));
|
||||
return new InvoiceFileBackupResult { Success = false, Reason = "db_error", BlobBackupUri = blobBackupUri };
|
||||
}
|
||||
if (cleared.Count == 0)
|
||||
{
|
||||
_logger.LogWarning(BackupClearEvent,
|
||||
"BackupAndClearAsync: no file to back up/clear for {Id} (already empty or invoice missing), user={User}",
|
||||
invoice.Id, userAccountId);
|
||||
FuchsTelemetry.InvoiceFileBackups.Add(1, new KeyValuePair<string, object?>("outcome", "no_file"));
|
||||
return new InvoiceFileBackupResult { Success = false, Reason = "no_file", BlobBackupUri = blobBackupUri };
|
||||
}
|
||||
|
||||
_logger.LogWarning(BackupClearEvent,
|
||||
"Admin backed up and cleared invoice file for {Id}, blobBackup={BlobBackupUri}, user={User}",
|
||||
invoice.Id, blobBackupUri, userAccountId);
|
||||
FuchsTelemetry.InvoiceFileBackups.Add(1, new KeyValuePair<string, object?>("outcome", "cleared"));
|
||||
return new InvoiceFileBackupResult { Success = true, BlobBackupUri = blobBackupUri };
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,8 @@ public class InvoiceService : IInvoiceService
|
||||
|
||||
private string Conn => _intranet.Intranet__SQLConnectionString;
|
||||
|
||||
private static readonly EventId InvoiceAmountRejected = new(0, "InvoiceAmountRejected");
|
||||
|
||||
public async Task<FdsInvoiceData> LoadInvoiceAsync(string id, string userAccountId, DatabaseSecurity dbSec)
|
||||
{
|
||||
_logger.LogDebug("LoadInvoiceAsync id={Id} user={User}", id, userAccountId);
|
||||
@@ -151,6 +153,27 @@ public class InvoiceService : IInvoiceService
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The header save batch for <see cref="RegisterInvoiceAsync"/>. If the create/set proc fails
|
||||
/// (e.g. a parameter conversion error) the batch would otherwise carry on with a NULL
|
||||
/// <c>@Id</c> and fail again in <c>fds__createInvoice_Details</c> ("Cannot insert NULL into
|
||||
/// InvId"), burying the real error; the guard stops the batch right after the first failure.
|
||||
/// </summary>
|
||||
internal static string BuildRegisterInvoiceSql(bool change, bool hasProvisionLocation)
|
||||
{
|
||||
var sqlParts = new List<string> { "DECLARE @Id varchar(10);" };
|
||||
sqlParts.Add(change
|
||||
? "EXECUTE [dbo].[fds__setInvoice] @InvId, @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @SendToEmailCc, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT, @SendToAddressJson;"
|
||||
: "EXECUTE [dbo].[fds__createInvoice] @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @SendToEmailCc, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT, @SendToAddressJson;");
|
||||
sqlParts.Add(change
|
||||
? "IF @Id IS NULL THROW 50001, N'fds__setInvoice did not return an invoice id - invoice header not saved.', 1;"
|
||||
: "IF @Id IS NULL THROW 50001, N'fds__createInvoice did not return an invoice id - invoice header not saved.', 1;");
|
||||
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice_Details] @Id, @InvoiceService_net, @InvoiceService_VAT, @InvoiceOptions, @authuser;");
|
||||
if (hasProvisionLocation)
|
||||
sqlParts.Add("UPDATE [dbo].[fds__invoices] SET [ProvisionLocation] = LEFT(@ProvisionLocation,1000) WHERE [Id] = @Id AND [isFinal] = 0;");
|
||||
return string.Join("\n", sqlParts);
|
||||
}
|
||||
|
||||
public async Task<FdsInvoiceData> RegisterInvoiceAsync(FdsInvoiceData invoice, bool change, string invId,
|
||||
string userAccountId, DatabaseSecurity dbSec)
|
||||
{
|
||||
@@ -158,28 +181,26 @@ public class InvoiceService : IInvoiceService
|
||||
if (invoice.NewValues == null) { _logger.LogWarning("RegisterInvoiceAsync: no form values supplied (user={User})", userAccountId); return invoice; }
|
||||
|
||||
var pl = new List<SqlParameter> { SQL_VarChar("@authuser", userAccountId) };
|
||||
pl.AddRange(invoice.BuildInvoiceParams(change, invId));
|
||||
try
|
||||
{
|
||||
pl.AddRange(invoice.BuildInvoiceParams(change, invId));
|
||||
}
|
||||
catch (InvoiceAmountOutOfRangeException ex)
|
||||
{
|
||||
// Refuse rather than store a wrong amount; an empty registration makes the caller report the save as failed.
|
||||
_logger.LogError(InvoiceAmountRejected, ex,
|
||||
"RegisterInvoiceAsync: refused invoice {InvId} - amount {Parameter} = {Value} is out of range (user={User})",
|
||||
invId, ex.Parameter, ex.Value, userAccountId);
|
||||
return invoice;
|
||||
}
|
||||
|
||||
var sqlParts = new List<string> { "DECLARE @Id varchar(10);" };
|
||||
if (!change)
|
||||
{
|
||||
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice] @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @SendToEmailCc, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT, @SendToAddressJson;");
|
||||
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice_Details] @Id, @InvoiceService_net, @InvoiceService_VAT, @InvoiceOptions, @authuser;");
|
||||
}
|
||||
else
|
||||
{
|
||||
pl.Add(SQL_VarChar("@InvId", invId));
|
||||
sqlParts.Add("EXECUTE [dbo].[fds__setInvoice] @InvId, @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @SendToEmailCc, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT, @SendToAddressJson;");
|
||||
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice_Details] @Id, @InvoiceService_net, @InvoiceService_VAT, @InvoiceOptions, @authuser;");
|
||||
}
|
||||
if (invoice.RawProvisionLocation.Length > 0)
|
||||
{
|
||||
if (change) pl.Add(SQL_VarChar("@InvId", invId));
|
||||
bool hasProvisionLocation = invoice.RawProvisionLocation.Length > 0;
|
||||
if (hasProvisionLocation)
|
||||
pl.Add(SQL_NVarChar("@ProvisionLocation",
|
||||
string.Join("\n", invoice.RawProvisionLocation).LeftToFirst("<!--", emptyIfNotFound: false)));
|
||||
sqlParts.Add("UPDATE [dbo].[fds__invoices] SET [ProvisionLocation] = LEFT(@ProvisionLocation,1000) WHERE [Id] = @Id AND [isFinal] = 0;");
|
||||
}
|
||||
|
||||
var invdset = await getSQLDataSet_async(string.Join("\n", sqlParts),
|
||||
var invdset = await getSQLDataSet_async(BuildRegisterInvoiceSql(change, hasProvisionLocation),
|
||||
Conn, pl, tablenames: new[] { "inv", "det" },
|
||||
Security: dbSec, options: new FIS_SQLOptions());
|
||||
if (!string.IsNullOrEmpty(invdset.Exception))
|
||||
@@ -205,6 +226,17 @@ public class InvoiceService : IInvoiceService
|
||||
/// The whole set is cleared and rewritten on every call — simpler and safer than diffing —
|
||||
/// mirroring the legacy <c>fds__invoice_data.RegisterInvoice_V1</c> (VB) behaviour.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// Normalises an item's posted <c>ref</c> (the invoice a CanceledInvoice / IntInvoiceAllocation line
|
||||
/// points at) for <c>fds__invoice_items.reference_InvId</c> (varchar(20)); null when absent or not an id.
|
||||
/// </summary>
|
||||
internal static string? ItemReferenceInvId(string? raw)
|
||||
{
|
||||
string s = (raw ?? "").Trim();
|
||||
if (s.Length == 0 || s.Length > 20 || s.Equals("null", StringComparison.OrdinalIgnoreCase)) return null;
|
||||
return s.All(char.IsAsciiLetterOrDigit) ? s : null;
|
||||
}
|
||||
|
||||
private async Task PersistInvoiceLineItemsAsync(FdsInvoiceData invoice, string userAccountId, DatabaseSecurity dbSec)
|
||||
{
|
||||
string invoiceId = invoice.Id;
|
||||
@@ -269,6 +301,7 @@ public class InvoiceService : IInvoiceService
|
||||
float vs = 0;
|
||||
if (TryCastSingle(itmDic.getItem("vs"), ref vs)) row["value_service"] = (decimal)vs;
|
||||
row["det"] = itmDic.no("det", false) is true;
|
||||
if (ItemReferenceInvId(itmDic.nz("ref")) is { } refInvId) row["reference_InvId"] = refInvId;
|
||||
row["SortOrder"] = (byte)Math.Clamp(itemSort, 0, 255);
|
||||
itemsTable.Rows.Add(row);
|
||||
}
|
||||
@@ -287,6 +320,22 @@ public class InvoiceService : IInvoiceService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Puts the stored Leistungsdatum / -zeitraum into the PDF admin block: German dates (legacy ISO
|
||||
/// values converted) and the label the editor shows — "Leistungsdatum" for one date,
|
||||
/// "Leistungszeitraum" for a from–to range.
|
||||
/// </summary>
|
||||
internal static void ApplyProvisionPeriod(FuchsPdf.FdsTextBlocks tb, string? provisionPeriod)
|
||||
{
|
||||
tb.ProvisionPeriod = ProvisionPeriodForDisplay(provisionPeriod).Trim();
|
||||
tb.AdminProvLabel = tb.ProvisionPeriod.Contains(" - ") || tb.ProvisionPeriod.Length == 0
|
||||
? "Leistungszeitraum" : "Leistungsdatum";
|
||||
}
|
||||
|
||||
/// <summary>Shows ISO dates (yyyy-MM-dd) saved by older editor versions as German dd.MM.yyyy.</summary>
|
||||
internal static string ProvisionPeriodForDisplay(string? period) =>
|
||||
System.Text.RegularExpressions.Regex.Replace(period ?? "", @"\b(\d{4})-(\d{2})-(\d{2})\b", "$3.$2.$1");
|
||||
|
||||
public async Task<Document> GenerateInvoicePdfAsync(FdsInvoiceData invoice, bool draft)
|
||||
{
|
||||
using var act = FuchsTelemetry.StartActivity("invoice.render");
|
||||
@@ -302,8 +351,11 @@ public class InvoiceService : IInvoiceService
|
||||
: Array.Empty<string>(),
|
||||
AdminUser = reg?.getString("UserNameFinalized") ?? "",
|
||||
AdminUserEmail = reg?.getString("UserEmailFinalized") ?? "",
|
||||
AdminDatumValue = reg?.getString("DateCreated") is { Length: > 0 } dc ? DateTime.Parse(dc) : DateTime.Now
|
||||
// Rechnungsdatum = DateFinalized (incl. backdating), draft: DateCreated — same source as the
|
||||
// e-invoice issue date (ERechnungMapper), see InvoiceDates / ADR 0032.
|
||||
AdminDatumValue = InvoiceDates.IssueDate(reg).ToDateTime(TimeOnly.MinValue)
|
||||
};
|
||||
ApplyProvisionPeriod(tb, reg?.getString("ProvisionPeriod"));
|
||||
var inlineImages = await ResolveInlineImagesAsync(invoice);
|
||||
var doc = await _pdf.WriteLetterAsync(tb, draft);
|
||||
doc.Info.Title = reg?.getString("InvoiceTitle") ?? "";
|
||||
@@ -426,8 +478,20 @@ public class InvoiceService : IInvoiceService
|
||||
{
|
||||
byte[] ba;
|
||||
try { ba = await RenderInvoicePdfBytesAsync(invoice, draft); }
|
||||
catch (Exception ex) { _logger.LogError(ex, "StoreInvoiceDocumentFileAsync render failed for {Id}", invoice.Id); ba = Array.Empty<byte>(); }
|
||||
if (ba.Length == 0) return Array.Empty<byte>();
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "StoreInvoiceDocumentFileAsync render failed for {Id}", invoice.Id);
|
||||
return Array.Empty<byte>();
|
||||
}
|
||||
if (ba.Length == 0)
|
||||
{
|
||||
// Distinct from the catch above: the renderer returned normally but with no bytes, and
|
||||
// callers only see the same empty array either way.
|
||||
_logger.LogError(
|
||||
"StoreInvoiceDocumentFileAsync: render for {Id} completed without an exception but produced 0 bytes, draft={Draft}, user={User}",
|
||||
invoice.Id, draft, userAccountId);
|
||||
return Array.Empty<byte>();
|
||||
}
|
||||
|
||||
var pl = new List<SqlParameter>
|
||||
{
|
||||
@@ -435,10 +499,21 @@ public class InvoiceService : IInvoiceService
|
||||
SQL_VarChar("@Id", invoice.Id),
|
||||
new("@file", SqlDbType.VarBinary) { Value = ba }
|
||||
};
|
||||
bool r = await setSQLValue_async(
|
||||
// fds__setInvoiceFile writes WHERE [file] IS NULL and returns the updated Id via its
|
||||
// OUTPUT clause, so an empty result set means the UPDATE matched nothing (already stored,
|
||||
// wrong id, invoice gone) without any SQL error. Rows-affected cannot be used for this:
|
||||
// the proc runs SET NOCOUNT ON, so ExecuteNonQuery reports -1 either way (ADR 0025).
|
||||
var stored = await getSQLDatatable_async(
|
||||
"EXECUTE [dbo].[fds__setInvoiceFile] @Id, @file;",
|
||||
Conn, pl, Security: dbSec, options: new FIS_SQLOptions());
|
||||
if (!r) return Array.Empty<byte>();
|
||||
if (!string.IsNullOrEmpty(stored.Exception)) return Array.Empty<byte>();
|
||||
if (stored.Count == 0)
|
||||
{
|
||||
_logger.LogError(
|
||||
"StoreInvoiceDocumentFileAsync: fds__setInvoiceFile updated 0 rows for {Id} (file already stored, or invoice missing), user={User}",
|
||||
invoice.Id, userAccountId);
|
||||
return Array.Empty<byte>();
|
||||
}
|
||||
|
||||
string fileName = invoice.InvoiceRegistration?.getString("DocumentName")
|
||||
.ne($"Rechnung_{invoice.Id}.pdf") ?? $"Rechnung_{invoice.Id}.pdf";
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using OCORE.web.pwmailer;
|
||||
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Fuchs-side pre-validation of email attachments against the ProcessWeb Mailer API contract
|
||||
/// (https://processweb.de/api/mailer/fc, "Attachment Rules"): file count, per-file and per-message
|
||||
/// size, and the server's forbidden-extension list. The rules the server enforces unconditionally
|
||||
/// (empty file, missing extension, required fields) are already covered by
|
||||
/// <see cref="PWMailerMessage.Validate"/>; this adds the limit rules on top — see
|
||||
/// <see cref="FuchsMailerClient.ValidateMessage"/>. Pure — no I/O — so it is unit-testable.
|
||||
/// </summary>
|
||||
public static class MailerAttachmentPolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// Extensions the Mailer rejects (case-insensitive). The contract lists these explicitly and
|
||||
/// states there are further ones; the server remains authoritative for anything not listed.
|
||||
/// </summary>
|
||||
internal static readonly HashSet<string> ForbiddenExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"ade", "adp", "app", "application", "appx", "aspx", "bas", "bat", "cab", "cer", "chm", "cmd",
|
||||
"com", "cpl", "crt", "csh", "dll", "drv", "exe", "fxp", "jar", "js", "jnlp", "msi", "ps1",
|
||||
"psc1", "psd1", "py", "pyc", "reg", "scf", "scr", "sys", "vbs", "wsf", "xll",
|
||||
};
|
||||
|
||||
/// <summary>Returns every limit rule the attachments violate, in a stable order; empty when acceptable.</summary>
|
||||
public static IReadOnlyList<string> Validate(IReadOnlyList<PWMailerAttachment> attachments, MailerAttachmentLimits limits)
|
||||
{
|
||||
var violations = new List<string>();
|
||||
if (attachments.Count == 0) return violations;
|
||||
|
||||
if (attachments.Count > limits.MaxCount)
|
||||
violations.Add($"Too many attachments: {attachments.Count} (max {limits.MaxCount})");
|
||||
|
||||
long total = 0;
|
||||
foreach (var a in attachments)
|
||||
{
|
||||
if (a == null) continue;
|
||||
long size = a.Bytes?.LongLength ?? 0;
|
||||
total += size;
|
||||
if (size > limits.MaxFileBytes)
|
||||
violations.Add($"Attachment '{a.FileName}' is {size:N0} bytes (max {limits.MaxFileBytes:N0})");
|
||||
|
||||
string ext = Path.GetExtension(Path.GetFileName(a.FileName ?? "")).TrimStart('.');
|
||||
if (ext.Length > 0 && ForbiddenExtensions.Contains(ext))
|
||||
violations.Add($"Attachment '{a.FileName}' has a forbidden extension (.{ext.ToLowerInvariant()})");
|
||||
}
|
||||
|
||||
if (total > limits.MaxMessageBytes)
|
||||
violations.Add($"Attachments total {total:N0} bytes (max {limits.MaxMessageBytes:N0} per message)");
|
||||
|
||||
return violations;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Observability;
|
||||
using Microsoft.Data.SqlClient;
|
||||
@@ -8,13 +6,17 @@ using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Newtonsoft.Json;
|
||||
using OCORE.SQL;
|
||||
using OCORE.web.pwmailer;
|
||||
using static OCORE.SQL.sql;
|
||||
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Outbound communication service backed by the ProcessWeb Mailer API
|
||||
/// (POST https://api.processweb.de/mailer/push_com).
|
||||
/// Outbound communication service: the Fuchs-specific parts of sending (recipient override safety
|
||||
/// net, signature, attachment limits, <c>fds__emaillog</c> audit, telemetry) on top of the OCORE_web
|
||||
/// <see cref="IPWMailerClientService"/>, which owns the ProcessWeb Mailer API transport (request
|
||||
/// shapes, auth, error headers, retries). Contract: https://processweb.de/api/mailer/fc — see
|
||||
/// Docs/Concepts/processweb-mailer-interface.md and ADRs 0028/0029.
|
||||
/// When <c>ProcessWebComSettings.Enabled</c> is <c>false</c> the service
|
||||
/// only logs the intended communication without calling the API.
|
||||
/// </summary>
|
||||
@@ -24,7 +26,7 @@ public class ProcessWebComService : IComService
|
||||
private readonly Fuchs_intranet _intranet;
|
||||
private readonly ProcessWebComSettings _settings;
|
||||
private readonly FuchsEmailSettings _emailSettings;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IPWMailerClientService _mailer;
|
||||
|
||||
private const string SignatureIntro =
|
||||
"<p> </p><p style=\"margin:24px 0 16px 0;line-height:140%;\">" +
|
||||
@@ -36,13 +38,13 @@ public class ProcessWebComService : IComService
|
||||
Fuchs_intranet intranet,
|
||||
IOptions<ProcessWebComSettings> settings,
|
||||
IOptions<FuchsEmailSettings> emailSettings,
|
||||
IHttpClientFactory httpClientFactory)
|
||||
IPWMailerClientService mailer)
|
||||
{
|
||||
_logger = logger;
|
||||
_intranet = intranet;
|
||||
_settings = settings.Value;
|
||||
_emailSettings = emailSettings.Value;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_mailer = mailer;
|
||||
}
|
||||
|
||||
public async Task<bool> SendEmailAsync(string reference, string subject, string html,
|
||||
@@ -74,8 +76,8 @@ public class ProcessWebComService : IComService
|
||||
FuchsTelemetry.EmailsFailed.Add(1, new KeyValuePair<string, object?>("reason", "invalid-email"));
|
||||
act?.SetStatus(ActivityStatusCode.Error, "invalid-email");
|
||||
// Every attempt is audit-logged, including this early rejection (previously it returned unlogged).
|
||||
await WriteAuditLogAsync(reference, "", "", default, false,
|
||||
[$"Invalid recipient email address: '{email}'"]);
|
||||
await WriteAuditLogAsync(reference, "", BuildAuditConfig(subject, email, name, cc, attachments?.Keys),
|
||||
default, false, [$"Invalid recipient email address: '{email}'"]);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -86,54 +88,91 @@ public class ProcessWebComService : IComService
|
||||
_logger.LogInformation(
|
||||
"[ComService DISABLED] Would send email ref={Reference} subject='{Subject}' to={Email}",
|
||||
reference, subject, email);
|
||||
await WriteAuditLogAsync(reference, "", "", default, false, ["Service disabled – email not sent"]);
|
||||
await WriteAuditLogAsync(reference, "", BuildAuditConfig(subject, email, name, cc, attachments?.Keys),
|
||||
default, false, ["Service disabled – email not sent"]);
|
||||
FuchsTelemetry.EmailsFailed.Add(1, new KeyValuePair<string, object?>("reason", "disabled"));
|
||||
return false;
|
||||
}
|
||||
|
||||
bool success = false;
|
||||
string messageId = "";
|
||||
string failureReason = "api-error";
|
||||
var errors = new List<string>();
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
// Attachments are transmitted inline as base64 in the same push_com POST.
|
||||
// Each entry: { filename, mimeType, contentBase64 }.
|
||||
var attachmentPayload = (attachments ?? new Dictionary<string, byte[]>())
|
||||
.Where(kv => kv.Value is { Length: > 0 })
|
||||
.Select(kv => new
|
||||
{
|
||||
filename = kv.Key,
|
||||
mimeType = GuessMimeType(kv.Key),
|
||||
contentBase64 = Convert.ToBase64String(kv.Value)
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
var communication = new
|
||||
var message = new PWMailerMessage
|
||||
{
|
||||
to = email,
|
||||
cc,
|
||||
subject,
|
||||
body,
|
||||
attachments = attachmentPayload
|
||||
To = email, Cc = cc, Subject = subject, Body = body, Reference = reference, ComType = "e"
|
||||
};
|
||||
foreach (var (fileName, bytes) in attachments ?? new Dictionary<string, byte[]>())
|
||||
message.Attachments.Add(new PWMailerAttachment(fileName, bytes));
|
||||
|
||||
_logger.LogDebug("SendEmailAsync ref={Reference} to={Email} cc={Cc} attachments={Count}",
|
||||
reference, email, cc ?? "", attachmentPayload.Length);
|
||||
reference, email, cc ?? "", message.Attachments.Count);
|
||||
|
||||
var (ok, status, responseBody) = await PostToApiAsync(reference, "e", communication);
|
||||
if (ok)
|
||||
// PushAsync never throws for HTTP/transport/validation problems; the result says what happened.
|
||||
// Pre-validation (contract rules + Fuchs attachment limits, see FuchsMailerClient) runs inside
|
||||
// it and is reported as a refused call: StatusCode 0 and no attempt made.
|
||||
var result = await _mailer.PushAsync(message);
|
||||
if (result.Success)
|
||||
{
|
||||
success = true;
|
||||
messageId = Guid.NewGuid().ToString("N");
|
||||
var receipt = result.Value!;
|
||||
if (message.Attachments.Count > 0 && receipt.AttachmentsProcessed != message.Attachments.Count)
|
||||
{
|
||||
// A 200 alone does not prove the files made it: the contract reports how many
|
||||
// attachments were received and actually processed. Anything but an exact match
|
||||
// is a failed send from the caller's point of view.
|
||||
failureReason = "attachments-mismatch";
|
||||
errors.Add($"Mailer accepted the message but not all {message.Attachments.Count} attachment(s): " +
|
||||
$"attachmentsReceived={receipt.AttachmentsReceived}, attachmentsProcessed={receipt.AttachmentsProcessed}");
|
||||
_logger.LogError("SendEmailAsync: attachment count mismatch for {Reference}: sent={Sent} received={Received} processed={Processed} comStackID={ComStackId}",
|
||||
reference, message.Attachments.Count, receipt.AttachmentsReceived, receipt.AttachmentsProcessed, receipt.ComStackID);
|
||||
}
|
||||
else
|
||||
{
|
||||
success = true;
|
||||
}
|
||||
// comStackID is what getComStatus/getCom accept, so it is the audit key either way.
|
||||
messageId = receipt.ComStackID;
|
||||
}
|
||||
else if (result.IsConflict)
|
||||
{
|
||||
// Idempotency guard: an identical message (same server/reference/content) is still
|
||||
// pending at the Mailer and WILL be delivered; this one was not queued a second time.
|
||||
// From the caller's point of view the mail is on its way — a resend clicked twice
|
||||
// must not surface as a send error — so this is a success with a note in the audit log.
|
||||
success = true;
|
||||
errors.Add($"Duplicate of a still-pending entry (409) — the earlier entry will be sent, nothing new was queued: {result.Error}");
|
||||
_logger.LogInformation("SendEmailAsync: duplicate of pending entry for {Reference}, earlier entry will be sent: {Error}",
|
||||
reference, result.Error);
|
||||
}
|
||||
else if (result.Attempts == 0 && result.StatusCode == 0 && result.Exception is ArgumentException)
|
||||
{
|
||||
// Refused by message/attachment pre-validation (PWMailerClientService.ValidateMessage,
|
||||
// incl. FuchsMailerClient's limits): the client wraps the rule messages in an
|
||||
// ArgumentException. A caller problem — one audit line per rule.
|
||||
failureReason = "validation";
|
||||
errors.AddRange(result.Error.Split("; ", StringSplitOptions.RemoveEmptyEntries));
|
||||
_logger.LogWarning("SendEmailAsync: refused by pre-validation for {Reference}: {Error}", reference, result.Error);
|
||||
}
|
||||
else if (result.Attempts == 0 && result.StatusCode == 0)
|
||||
{
|
||||
// Refused before sending for an operational reason: unusable PWMailer options or a
|
||||
// token that could not be resolved (Key Vault outage, missing secret). Nothing the
|
||||
// caller did — an Error, and its own metric reason, so it is never mistaken for a
|
||||
// bad attachment.
|
||||
failureReason = "client-config";
|
||||
errors.Add($"Mailer client not usable: {result.Error}");
|
||||
_logger.LogError("SendEmailAsync: mailer client not usable for {Reference}: {Error}", reference, result.Error);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Persist the mailer's raw response so an unsuccessful send is diagnosable from the log.
|
||||
errors.Add($"API error {(int)status} ({status}): {responseBody}");
|
||||
_logger.LogWarning("SendEmailAsync API error for {Reference}: {Status} {Body}",
|
||||
reference, (int)status, responseBody);
|
||||
// Persist the mailer's error so an unsuccessful send is diagnosable from the log.
|
||||
errors.Add($"API error {result.StatusCode}: {result.Error}");
|
||||
_logger.LogWarning("SendEmailAsync API error for {Reference}: {Status} {Error}",
|
||||
reference, result.StatusCode, result.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -149,11 +188,12 @@ public class ProcessWebComService : IComService
|
||||
FuchsTelemetry.EmailSendDuration.Record(sw.Elapsed.TotalMilliseconds,
|
||||
new KeyValuePair<string, object?>("success", success));
|
||||
if (success) FuchsTelemetry.EmailsSent.Add(1);
|
||||
else FuchsTelemetry.EmailsFailed.Add(1, new KeyValuePair<string, object?>("reason", "api-error"));
|
||||
else FuchsTelemetry.EmailsFailed.Add(1, new KeyValuePair<string, object?>("reason", failureReason));
|
||||
_logger.LogInformation("SendEmailAsync ref={Reference} success={Success} in {Ms} ms",
|
||||
reference, success, sw.ElapsedMilliseconds);
|
||||
|
||||
await WriteAuditLogAsync(reference, messageId, "", success ? DateTime.UtcNow : default, success, errors);
|
||||
await WriteAuditLogAsync(reference, messageId, BuildAuditConfig(subject, email, name, cc, attachments?.Keys),
|
||||
success ? DateTime.UtcNow : default, success, errors);
|
||||
return success;
|
||||
}
|
||||
|
||||
@@ -175,21 +215,18 @@ public class ProcessWebComService : IComService
|
||||
|
||||
try
|
||||
{
|
||||
var communication = new
|
||||
var result = await _mailer.PushAsync(new PWMailerMessage
|
||||
{
|
||||
to = mobile,
|
||||
body = message
|
||||
};
|
||||
|
||||
var (ok, status, responseBody) = await PostToApiAsync(mobile, "s", communication);
|
||||
if (ok)
|
||||
To = mobile, Body = message, Reference = mobile, ComType = "s"
|
||||
});
|
||||
if (result.Success)
|
||||
{
|
||||
FuchsTelemetry.SmsSent.Add(1);
|
||||
_logger.LogInformation("SendSmsAsync sent to {Mobile}", mobile);
|
||||
return true;
|
||||
}
|
||||
|
||||
_logger.LogWarning("SendSmsAsync API error for {Mobile}: {Status} {Body}", mobile, (int)status, responseBody);
|
||||
_logger.LogWarning("SendSmsAsync API error for {Mobile}: {Status} {Error}", mobile, result.StatusCode, result.Error);
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -199,32 +236,38 @@ public class ProcessWebComService : IComService
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private helpers ────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<(bool ok, System.Net.HttpStatusCode status, string body)> PostToApiAsync(string reference, string comtype, object communication)
|
||||
public async Task<MailerCredentialsCheckResult> VerifyCredentialsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient("ProcessWebMailer");
|
||||
if (string.IsNullOrWhiteSpace(_settings.AccountId) || string.IsNullOrWhiteSpace(_settings.Token))
|
||||
return new MailerCredentialsCheckResult { Ok = false, Detail = "Konto-ID oder Token nicht konfiguriert." };
|
||||
|
||||
// The Mailer API expects a fixed envelope: { serverid, comtype, communication, reference }.
|
||||
// "communication" carries the actual channel-specific payload (to/subject/body/attachments).
|
||||
var payload = new
|
||||
// Passing serverid makes the Mailer API also confirm that THIS server is one of the
|
||||
// account's usable servers, not just that the account/token pair is valid.
|
||||
string? serverId = string.IsNullOrWhiteSpace(_settings.ServerId) ? null : _settings.ServerId;
|
||||
var result = await _mailer.VerifyAsync(serverId, cancellationToken);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
serverid = _settings.ServerId,
|
||||
comtype,
|
||||
communication,
|
||||
reference
|
||||
if (result.IsTransportError) _logger.LogWarning("VerifyCredentialsAsync failed: {Error}", result.Error);
|
||||
return new MailerCredentialsCheckResult
|
||||
{
|
||||
Ok = false,
|
||||
Detail = result.IsTransportError ? result.Error : $"API-Fehler {result.StatusCode}: {result.Error}",
|
||||
};
|
||||
}
|
||||
|
||||
var payload = result.Value!;
|
||||
string? detail = payload.Ok ? null
|
||||
: (!string.IsNullOrWhiteSpace(payload.ServerId)
|
||||
? $"Server-ID '{payload.ServerId}' gehört nicht zu diesem Mailer-Konto."
|
||||
: "Mailer API meldete ungültige Zugangsdaten.");
|
||||
return new MailerCredentialsCheckResult
|
||||
{
|
||||
Ok = payload.Ok,
|
||||
Account = string.IsNullOrEmpty(payload.Account) ? null : payload.Account,
|
||||
ServerId = payload.ServerId,
|
||||
Detail = detail,
|
||||
};
|
||||
var json = JsonConvert.SerializeObject(payload);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
string credentials = Convert.ToBase64String(
|
||||
Encoding.UTF8.GetBytes($"{_settings.AccountId}:{_settings.Token}"));
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Basic", credentials);
|
||||
|
||||
var response = await client.PostAsync($"{_settings.BaseUrl}/mailer/push_com", content);
|
||||
string responseBody = await response.Content.ReadAsStringAsync();
|
||||
return (response.IsSuccessStatusCode, response.StatusCode, responseBody);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -250,6 +293,30 @@ public class ProcessWebComService : IComService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serialises the audit <c>config</c> stored in <c>fds__emaillog.config</c>: who the mail went
|
||||
/// to, its subject and the attachment <em>names</em> — never the body or attachment bytes.
|
||||
/// The shape (<c>subject</c>/<c>from</c>/<c>to[]</c>/<c>cc[]</c>/<c>bcc[]</c>/<c>attachments[]</c>,
|
||||
/// addresses as <c>"Name" <email></c>) is the one the legacy mailer wrote and that
|
||||
/// <c>[dbo].[fds_wgd_lastEmails]</c> reads back with <c>OPENJSON</c>; an empty string here
|
||||
/// made that dashboard widget fail for every row the new service logged.
|
||||
/// Pure so the contract can be unit-tested without a database.
|
||||
/// </summary>
|
||||
internal static string BuildAuditConfig(string subject, string email, string? name, string? cc,
|
||||
IEnumerable<string>? attachmentNames) =>
|
||||
JsonConvert.SerializeObject(new
|
||||
{
|
||||
subject = subject ?? "",
|
||||
from = "",
|
||||
to = new[] { FormatAddress(email, name) },
|
||||
cc = string.IsNullOrWhiteSpace(cc) ? Array.Empty<string>() : new[] { cc.Trim() },
|
||||
bcc = Array.Empty<string>(),
|
||||
attachments = (attachmentNames ?? []).ToArray()
|
||||
});
|
||||
|
||||
private static string FormatAddress(string email, string? name) =>
|
||||
string.IsNullOrWhiteSpace(name) ? (email ?? "") : $"\"{name.Trim()}\" <{email}>";
|
||||
|
||||
/// <summary>
|
||||
/// Builds the parameter list for <c>[dbo].[fds__logEmail]</c>. <c>fds__emaillog</c> declares
|
||||
/// <c>config</c> and <c>DateSent</c> as NOT NULL, so neither may be sent as NULL:
|
||||
@@ -291,24 +358,6 @@ public class ProcessWebComService : IComService
|
||||
return "";
|
||||
}
|
||||
|
||||
private static string GuessMimeType(string filename) =>
|
||||
Path.GetExtension(filename).ToLowerInvariant() switch
|
||||
{
|
||||
".pdf" => "application/pdf",
|
||||
".png" => "image/png",
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".gif" => "image/gif",
|
||||
".txt" => "text/plain",
|
||||
".csv" => "text/csv",
|
||||
".xml" => "application/xml",
|
||||
".zip" => "application/zip",
|
||||
".doc" => "application/msword",
|
||||
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".xls" => "application/vnd.ms-excel",
|
||||
".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
|
||||
private static bool IsValidEmail(string email)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
namespace Fuchs.Services;
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Settings for the ProcessWeb Mailer API, bound from appsettings.json → "Fuchs:Mailer".
|
||||
/// Contract: https://processweb.de/api/mailer/fc (see Docs/Concepts/processweb-mailer-interface.md).
|
||||
/// </summary>
|
||||
public class ProcessWebComSettings
|
||||
{
|
||||
/// <summary>Base URL of the ProcessWeb API (e.g. "https://api.processweb.de").</summary>
|
||||
public string BaseUrl { get; set; } = "https://api.processweb.de";
|
||||
/// <summary>Base URL of the ProcessWeb host (e.g. "https://processweb.de"); endpoints live under <c>/api/mailer/…</c>.</summary>
|
||||
public string BaseUrl { get; set; } = "https://processweb.de";
|
||||
|
||||
/// <summary>Account ID used for HTTP Basic authentication.</summary>
|
||||
public string AccountId { get; set; } = "";
|
||||
@@ -23,4 +24,24 @@ public class ProcessWebComSettings
|
||||
/// Set to <c>true</c> to enable live sending.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Client-side mirror of the Mailer API's attachment limits, checked before a push_com call
|
||||
/// so a violation is reported with a precise reason instead of an opaque 400. Defaults are
|
||||
/// the contract's documented defaults; the server stays authoritative.
|
||||
/// </summary>
|
||||
public MailerAttachmentLimits Attachments { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>See <see cref="ProcessWebComSettings.Attachments"/>.</summary>
|
||||
public sealed class MailerAttachmentLimits
|
||||
{
|
||||
/// <summary>Maximum size of a single attachment in bytes (contract default: 10 MB).</summary>
|
||||
public long MaxFileBytes { get; set; } = 10L * 1024 * 1024;
|
||||
|
||||
/// <summary>Maximum total attachment size per message in bytes (contract default: 20 MB).</summary>
|
||||
public long MaxMessageBytes { get; set; } = 20L * 1024 * 1024;
|
||||
|
||||
/// <summary>Maximum number of attachments per message (contract default: 10).</summary>
|
||||
public int MaxCount { get; set; } = 10;
|
||||
}
|
||||
|
||||
@@ -59,27 +59,30 @@ public sealed class ReminderDraftEditService : IReminderDraftService
|
||||
var session = _cache.Get(token);
|
||||
if (session == null) return null;
|
||||
|
||||
string oldValue = "", newValue = "";
|
||||
bool mutated = ApplyDelta(session, delta, ref oldValue, ref newValue);
|
||||
if (!mutated)
|
||||
lock (session.SyncRoot) // concurrent patch/state/preview requests for one token must not interleave
|
||||
{
|
||||
_logger.LogDebug("Reminder draft {Token}: no-op patch target={Target} ref={Ref}", token, delta.Target, delta.Ref);
|
||||
string oldValue = "", newValue = "";
|
||||
bool mutated = ApplyDelta(session, delta, ref oldValue, ref newValue);
|
||||
if (!mutated)
|
||||
{
|
||||
_logger.LogDebug("Reminder draft {Token}: no-op patch target={Target} ref={Ref}", token, delta.Target, delta.Ref);
|
||||
return session;
|
||||
}
|
||||
|
||||
Refresh(session);
|
||||
session.Version++;
|
||||
session.History.Add(new ChangeHistoryEntry
|
||||
{
|
||||
UserAccountId = session.UserAccountId,
|
||||
Target = delta.Target,
|
||||
Ref = delta.Ref,
|
||||
OldValue = oldValue,
|
||||
NewValue = newValue,
|
||||
Version = session.Version
|
||||
});
|
||||
_cache.Set(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
Refresh(session);
|
||||
session.Version++;
|
||||
session.History.Add(new ChangeHistoryEntry
|
||||
{
|
||||
UserAccountId = session.UserAccountId,
|
||||
Target = delta.Target,
|
||||
Ref = delta.Ref,
|
||||
OldValue = oldValue,
|
||||
NewValue = newValue,
|
||||
Version = session.Version
|
||||
});
|
||||
_cache.Set(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -144,24 +147,27 @@ public sealed class ReminderDraftEditService : IReminderDraftService
|
||||
public object BuildState(ReminderDraftSession session)
|
||||
{
|
||||
session.Touch();
|
||||
return new
|
||||
lock (session.SyncRoot) // serialised by the caller later — hand out snapshots, not live references
|
||||
{
|
||||
token = session.Token,
|
||||
version = session.Version,
|
||||
remid = session.RemId,
|
||||
isDraft = session.IsDraft,
|
||||
@new = session.New,
|
||||
rem = session.Rem,
|
||||
sums = new
|
||||
return new
|
||||
{
|
||||
amount_total = session.Sums.AmountTotal,
|
||||
amount_payed = session.Sums.AmountPayed,
|
||||
amount_open = session.Sums.AmountOpen
|
||||
},
|
||||
validation = session.ValidationMessages.Select(v => new { field = v.Field, severity = v.Severity, message = v.Message }),
|
||||
historyCount = session.History.Count,
|
||||
notes = BuildNotes(session)
|
||||
};
|
||||
token = session.Token,
|
||||
version = session.Version,
|
||||
remid = session.RemId,
|
||||
isDraft = session.IsDraft,
|
||||
@new = session.New.DeepClone(),
|
||||
rem = session.Rem.DeepClone(),
|
||||
sums = new
|
||||
{
|
||||
amount_total = session.Sums.AmountTotal,
|
||||
amount_payed = session.Sums.AmountPayed,
|
||||
amount_open = session.Sums.AmountOpen
|
||||
},
|
||||
validation = session.ValidationMessages.Select(v => new { field = v.Field, severity = v.Severity, message = v.Message }).ToList(),
|
||||
historyCount = session.History.Count,
|
||||
notes = BuildNotes(session)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -177,34 +183,58 @@ public sealed class ReminderDraftEditService : IReminderDraftService
|
||||
return new { intro, closing };
|
||||
}
|
||||
|
||||
public IReadOnlyList<ChangeHistoryEntry> GetHistory(string token) =>
|
||||
_cache.Get(token)?.History ?? (IReadOnlyList<ChangeHistoryEntry>)Array.Empty<ChangeHistoryEntry>();
|
||||
public IReadOnlyList<ChangeHistoryEntry> GetHistory(string token)
|
||||
{
|
||||
var session = _cache.Get(token);
|
||||
if (session == null) return Array.Empty<ChangeHistoryEntry>();
|
||||
lock (session.SyncRoot) return session.History.ToList();
|
||||
}
|
||||
|
||||
// ── Flush / preview ────────────────────────────────────────────────────────
|
||||
public async Task<FdsReminderData?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec)
|
||||
public async Task<DraftFlushResult<FdsReminderData>?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec)
|
||||
{
|
||||
var session = _cache.Get(token);
|
||||
if (session == null) return null;
|
||||
|
||||
var fds = BuildReminderData(session);
|
||||
bool change = !string.IsNullOrEmpty(session.RemId);
|
||||
var reg = await _reminders.RegisterReminderAsync(fds, change, session.RemId, userAccountId, dbSec);
|
||||
if (!string.IsNullOrEmpty(reg.Id))
|
||||
// One flush at a time per session (see InvoiceDraftEditService.FlushToDbAsync): two overlapping
|
||||
// saves of a new draft would otherwise both see an empty RemId and both call fds__createReminder.
|
||||
await session.FlushGate.WaitAsync();
|
||||
try
|
||||
{
|
||||
session.RemId = reg.Id;
|
||||
_cache.Set(session);
|
||||
_logger.LogInformation("Reminder draft {Token} flushed to DB reminder {RemId} (change={Change}, user={User})",
|
||||
token, reg.Id, change, userAccountId);
|
||||
FdsReminderData fds;
|
||||
string remId;
|
||||
lock (session.SyncRoot)
|
||||
{
|
||||
fds = BuildReminderData(session); // deep-cloned snapshot — the await below runs outside the lock
|
||||
remId = session.RemId;
|
||||
}
|
||||
bool change = !string.IsNullOrEmpty(remId);
|
||||
var reg = await _reminders.RegisterReminderAsync(fds, change, remId, userAccountId, dbSec);
|
||||
if (!string.IsNullOrEmpty(reg.Id))
|
||||
{
|
||||
lock (session.SyncRoot) session.RemId = reg.Id;
|
||||
_cache.Set(session);
|
||||
_logger.LogInformation("Reminder draft {Token} flushed to DB reminder {RemId} (change={Change}, user={User})",
|
||||
token, reg.Id, change, userAccountId);
|
||||
}
|
||||
return new DraftFlushResult<FdsReminderData>(reg, change);
|
||||
}
|
||||
finally
|
||||
{
|
||||
session.FlushGate.Release();
|
||||
}
|
||||
return reg;
|
||||
}
|
||||
|
||||
public Document? RenderPreview(string token)
|
||||
{
|
||||
var session = _cache.Get(token);
|
||||
if (session == null) return null;
|
||||
var fds = BuildReminderData(session);
|
||||
fds.ReminderRegistration = SynthesizeRegistration(session);
|
||||
FdsReminderData fds;
|
||||
lock (session.SyncRoot)
|
||||
{
|
||||
fds = BuildReminderData(session);
|
||||
fds.ReminderRegistration = SynthesizeRegistration(session);
|
||||
}
|
||||
fds.IsDraft = true;
|
||||
return _reminders.GenerateReminderPdf(fds, draft: true);
|
||||
}
|
||||
@@ -271,7 +301,8 @@ public sealed class ReminderDraftEditService : IReminderDraftService
|
||||
["invoices"] = invoices,
|
||||
["CustomValues"] = Str(session.New["CustomValues"]),
|
||||
["IsFinal"] = false,
|
||||
["DateCreated"] = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
|
||||
// UTC like the stored column — InvoiceDates.IssueDate converts it to the German day
|
||||
["DateCreated"] = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
|
||||
};
|
||||
return new GenericObjectDictionary(d);
|
||||
}
|
||||
|
||||
@@ -63,6 +63,25 @@ public class ReminderService : IReminderService
|
||||
return rem;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The save batch for <see cref="RegisterReminderAsync"/>: <c>fds__createReminder</c> for a new
|
||||
/// draft, <c>fds__setReminder</c> (update in place, same result row) for a re-saved one. Either
|
||||
/// proc leaves <c>@Id</c> NULL when it did not save — the set proc refuses a finalised reminder
|
||||
/// or one with a stored file — so the guard turns that into an error instead of an empty result
|
||||
/// row the caller would read as "saved".
|
||||
/// </summary>
|
||||
internal static string BuildRegisterReminderSql(bool change)
|
||||
{
|
||||
var sqlParts = new List<string> { "DECLARE @Id varchar(10);" };
|
||||
sqlParts.Add(change
|
||||
? "EXECUTE [dbo].[fds__setReminder] @RemId, @type, @amount, @amount_payed, @SendToAddress, @SendToEmail, @subject, @text, @authuser, @Id OUTPUT;"
|
||||
: "EXECUTE [dbo].[fds__createReminder] @InvId, @type, @amount, @amount_payed, @SendToAddress, @SendToEmail, @subject, @text, @authuser, @Id OUTPUT;");
|
||||
sqlParts.Add(change
|
||||
? "IF @Id IS NULL THROW 50001, N'fds__setReminder did not return a reminder id - reminder draft not saved (finalised, has a file, or unknown).', 1;"
|
||||
: "IF @Id IS NULL THROW 50001, N'fds__createReminder did not return a reminder id - reminder not saved.', 1;");
|
||||
return string.Join("\n", sqlParts);
|
||||
}
|
||||
|
||||
public async Task<FdsReminderData> RegisterReminderAsync(FdsReminderData reminder, bool change, string remId,
|
||||
string userAccountId, DatabaseSecurity dbSec)
|
||||
{
|
||||
@@ -82,20 +101,17 @@ public class ReminderService : IReminderService
|
||||
SQL_NVarChar("text", reminder.NewValues?.nz("text") ?? "", dbNull_IfEmpty: true)
|
||||
};
|
||||
|
||||
var sqlParts = new List<string> { "DECLARE @Id varchar(10);" };
|
||||
if (!change || string.IsNullOrEmpty(remId))
|
||||
sqlParts.Add("EXECUTE [dbo].[fds__createReminder] @InvId, @type, @amount, @amount_payed, @SendToAddress, @SendToEmail, @subject, @text, @authuser, @Id OUTPUT;");
|
||||
else
|
||||
pl.Add(SQL_VarChar("RemId", remId));
|
||||
change = change && !string.IsNullOrEmpty(remId);
|
||||
if (change) pl.Add(SQL_VarChar("RemId", remId));
|
||||
|
||||
var remdset = await getSQLDataSet_async(string.Join("\n", sqlParts),
|
||||
var remdset = await getSQLDataSet_async(BuildRegisterReminderSql(change),
|
||||
Conn, pl, tablenames: new[] { "rem" },
|
||||
Security: dbSec, options: new FIS_SQLOptions());
|
||||
if (!string.IsNullOrEmpty(remdset.Exception))
|
||||
_logger.LogError("RegisterReminderAsync sql exception: {Ex}", remdset.Exception);
|
||||
|
||||
reminder.ReminderRegistration = new GenericObjectDictionary(remdset.Table("rem").FirstRow.toObjectDictionary());
|
||||
_logger.LogInformation("RegisterReminderAsync registered id={Id}", reminder.Id);
|
||||
_logger.LogInformation("RegisterReminderAsync registered id={Id} (change={Change})", reminder.Id, change);
|
||||
return reminder;
|
||||
}
|
||||
|
||||
@@ -111,7 +127,8 @@ public class ReminderService : IReminderService
|
||||
Address = reminder.InvoiceAddress,
|
||||
AdminUser = reminder.UserNameFinalized,
|
||||
AdminUserEmail = reminder.UserEmailFinalized,
|
||||
AdminDatumValue = reminder.DateCreated ?? DateTime.Now
|
||||
// Reminder date = DateFinalized, draft: DateCreated — same rule as the invoice date (ADR 0035)
|
||||
AdminDatumValue = InvoiceDates.IssueDate(reminder.ReminderRegistration).ToDateTime(TimeOnly.MinValue)
|
||||
};
|
||||
if (!string.IsNullOrEmpty(reminder.RawCustomValues))
|
||||
{
|
||||
@@ -139,8 +156,18 @@ public class ReminderService : IReminderService
|
||||
{
|
||||
byte[] ba;
|
||||
try { ba = await RenderReminderPdfBytesAsync(reminder, draft); }
|
||||
catch (Exception ex) { _logger.LogError(ex, "StoreReminderDocumentFileAsync render failed for {Id}", reminder.Id); ba = Array.Empty<byte>(); }
|
||||
if (ba.Length == 0) return Array.Empty<byte>();
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "StoreReminderDocumentFileAsync render failed for {Id}", reminder.Id);
|
||||
return Array.Empty<byte>();
|
||||
}
|
||||
if (ba.Length == 0)
|
||||
{
|
||||
_logger.LogError(
|
||||
"StoreReminderDocumentFileAsync: render for {Id} completed without an exception but produced 0 bytes, draft={Draft}, user={User}",
|
||||
reminder.Id, draft, userAccountId);
|
||||
return Array.Empty<byte>();
|
||||
}
|
||||
|
||||
var pl = new List<SqlParameter>
|
||||
{
|
||||
@@ -148,10 +175,19 @@ public class ReminderService : IReminderService
|
||||
SQL_VarChar("@authuser", userAccountId),
|
||||
new("@file", SqlDbType.VarBinary) { Value = ba }
|
||||
};
|
||||
bool r = await setSQLValue_async(
|
||||
// OUTPUT-clause row count, not rows-affected (SET NOCOUNT ON) — see the matching note in
|
||||
// InvoiceService.StoreInvoiceDocumentFileAsync / ADR 0025.
|
||||
var stored = await getSQLDatatable_async(
|
||||
"EXECUTE [dbo].[fds__setReminderFile] @Id, @file;",
|
||||
Conn, pl, Security: dbSec, options: new FIS_SQLOptions());
|
||||
if (!r) return Array.Empty<byte>();
|
||||
if (!string.IsNullOrEmpty(stored.Exception)) return Array.Empty<byte>();
|
||||
if (stored.Count == 0)
|
||||
{
|
||||
_logger.LogError(
|
||||
"StoreReminderDocumentFileAsync: fds__setReminderFile updated 0 rows for {Id} (file already stored, or reminder missing), user={User}",
|
||||
reminder.Id, userAccountId);
|
||||
return Array.Empty<byte>();
|
||||
}
|
||||
|
||||
string fileName = reminder.ReminderRegistration?.getString("DocumentName")
|
||||
.ne($"Zahlungserinnerung_{reminder.Id}.pdf") ?? $"Zahlungserinnerung_{reminder.Id}.pdf";
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace Fuchs.Services;
|
||||
/// </summary>
|
||||
public sealed class SystemProbeResult
|
||||
{
|
||||
/// <summary>Probe identifier: <c>database</c>, <c>keyvault</c>, <c>blob</c> or <c>mfr</c>.</summary>
|
||||
/// <summary>Probe identifier: <c>database</c>, <c>email</c>, <c>keyvault</c>, <c>blob</c> or <c>mfr</c>.</summary>
|
||||
public string Component { get; init; } = "";
|
||||
|
||||
/// <summary><c>ok</c> | <c>error</c> | <c>disabled</c> | <c>unconfigured</c>.</summary>
|
||||
@@ -129,4 +129,10 @@ public sealed class EmailTestResult
|
||||
/// (see <see cref="FuchsEmailSettings.OverrideRecipient"/>); otherwise <c>null</c>.
|
||||
/// </summary>
|
||||
public string? OverrideRecipient { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// File name of the PDF that was attached to the test mail, or <c>null</c> when the mail was
|
||||
/// sent without an attachment (or never reached the Mailer).
|
||||
/// </summary>
|
||||
public string? AttachmentName { get; init; }
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user