feat: Implement admin-only specialties management interface
Playwright Tests / test (push) Canceled after 0s

- Added MailerAttachmentPolicy for validating email attachments against server rules.
- Introduced fis.specialties.js for managing hidden maintenance actions via an admin interface.
- Created fis.specialties.scss for styling the specialties management UI.
- Added German localization for specialties management in fis.specialties_txt_de.js.
- Developed CSS for specialties management in fis.specialties.css.
- Implemented server-side stored procedure fds__admin_backupAndClearInvoiceFile for backing up and clearing invoice files.
- Created fds__invoice_file_backups table to store backup records of invoice files.
This commit is contained in:
Stefan
2026-09-17 21:17:23 +02:00
parent 86f6a60860
commit 249a945f5e
59 changed files with 2720 additions and 457 deletions
+73
View File
@@ -0,0 +1,73 @@
---
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.
## 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)
+14 -5
View File
@@ -47,7 +47,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)
@@ -60,7 +60,7 @@ click "Administration" → $ocms.init('admin')
(`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()`; `email` calls
`IComService.VerifyCredentialsAsync` (GET `{BaseUrl}/mailer/fc?fn=verify` with the configured
`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
@@ -74,9 +74,18 @@ click "Administration" → $ocms.init('admin')
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.
- `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.
@@ -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)