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
+24 -3
View File
@@ -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();
@@ -155,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}",
@@ -247,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"))}",
@@ -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();
}
}
+4
View File
@@ -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;
@@ -185,6 +188,7 @@ 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),
+2 -2
View File
@@ -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`
+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)
@@ -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.
+13 -5
View File
@@ -216,7 +216,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 +236,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 +258,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
+9
View File
@@ -51,6 +51,15 @@ 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> 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.");
+18 -2
View File
@@ -8,9 +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;
@@ -136,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"));
@@ -148,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,
@@ -166,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>();
+54
View File
@@ -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)
{
+34
View File
@@ -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;
}
}
+10
View File
@@ -37,6 +37,16 @@ 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.
+12 -4
View File
@@ -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);
@@ -30,7 +38,7 @@ public interface IComService
/// <summary>
/// Verifies the configured account/token against the Mailer API's credential-check
/// endpoint (GET {BaseUrl}/mailer/fc?fn=verify) without sending anything.
/// endpoint (GET {BaseUrl}/api/mailer/verify) without sending anything.
/// </summary>
Task<MailerCredentialsCheckResult> VerifyCredentialsAsync(CancellationToken cancellationToken = default);
}
@@ -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; }
}
+6 -2
View File
@@ -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);
}
+131
View File
@@ -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 };
}
}
+54
View File
@@ -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;
}
}
+95 -130
View File
@@ -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>&nbsp;</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,
@@ -94,47 +96,83 @@ public class ProcessWebComService : IComService
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)
@@ -150,7 +188,7 @@ 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);
@@ -177,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)
@@ -206,85 +241,33 @@ public class ProcessWebComService : IComService
if (string.IsNullOrWhiteSpace(_settings.AccountId) || string.IsNullOrWhiteSpace(_settings.Token))
return new MailerCredentialsCheckResult { Ok = false, Detail = "Konto-ID oder Token nicht konfiguriert." };
var client = _httpClientFactory.CreateClient("ProcessWebMailer");
string credentials = Convert.ToBase64String(
Encoding.UTF8.GetBytes($"{_settings.AccountId}:{_settings.Token}"));
// 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 url = $"{_settings.BaseUrl}/mailer/fc?fn=verify";
if (!string.IsNullOrWhiteSpace(_settings.ServerId))
url += $"&serverid={Uri.EscapeDataString(_settings.ServerId)}";
string? serverId = string.IsNullOrWhiteSpace(_settings.ServerId) ? null : _settings.ServerId;
var result = await _mailer.VerifyAsync(serverId, cancellationToken);
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials);
try
if (!result.Success)
{
using var response = await client.SendAsync(request, cancellationToken);
string body = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
return new MailerCredentialsCheckResult
{
Ok = false,
Detail = $"API-Fehler {(int)response.StatusCode} ({response.StatusCode}): {body}",
};
var payload = JsonConvert.DeserializeObject<MailerVerifyResponse>(body);
bool ok = payload?.Ok == true;
string? detail = ok ? null
: (!string.IsNullOrWhiteSpace(payload?.ServerId)
? $"Server-ID '{payload!.ServerId}' gehört nicht zu diesem Mailer-Konto."
: "Mailer API meldete ungültige Zugangsdaten.");
if (result.IsTransportError) _logger.LogWarning("VerifyCredentialsAsync failed: {Error}", result.Error);
return new MailerCredentialsCheckResult
{
Ok = ok,
Account = payload?.Account,
ServerId = payload?.ServerId,
Detail = detail,
Ok = false,
Detail = result.IsTransportError ? result.Error : $"API-Fehler {result.StatusCode}: {result.Error}",
};
}
catch (Exception ex)
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
{
_logger.LogWarning(ex, "VerifyCredentialsAsync failed");
return new MailerCredentialsCheckResult { Ok = false, Detail = ex.Message };
}
}
private sealed class MailerVerifyResponse
{
public bool Ok { get; set; }
public string? Account { get; set; }
public string? ServerId { get; set; }
}
// ── Private helpers ────────────────────────────────────────────────────────
private async Task<(bool ok, System.Net.HttpStatusCode status, string body)> PostToApiAsync(string reference, string comtype, object communication)
{
var client = _httpClientFactory.CreateClient("ProcessWebMailer");
// 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
{
serverid = _settings.ServerId,
comtype,
communication,
reference
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>
@@ -375,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
+24 -3
View File
@@ -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;
}
+6
View File
@@ -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; }
}
+63 -5
View File
@@ -1,6 +1,7 @@
using System.Diagnostics;
using Azure;
using Azure.Security.KeyVault.Secrets;
using Fuchs.intranet;
using Fuchs.Observability;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
@@ -28,6 +29,7 @@ public sealed class SystemStatusService : ISystemStatusService
private readonly IBlobStorageService _blob;
private readonly IMfrClientFactory _mfrFactory;
private readonly IComService _comService;
private readonly IPdfService _pdf;
private readonly ProcessWebComSettings _mailer;
private readonly FuchsEmailSettings _email;
private readonly AzureBlobStorageSettings _blobSettings;
@@ -41,6 +43,7 @@ public sealed class SystemStatusService : ISystemStatusService
IBlobStorageService blob,
IMfrClientFactory mfrFactory,
IComService comService,
IPdfService pdf,
IOptions<ProcessWebComSettings> mailer,
IOptions<FuchsEmailSettings> email,
IOptions<AzureBlobStorageSettings> blobSettings,
@@ -53,6 +56,7 @@ public sealed class SystemStatusService : ISystemStatusService
_blob = blob;
_mfrFactory = mfrFactory;
_comService = comService;
_pdf = pdf;
_mailer = mailer.Value;
_email = email.Value;
_blobSettings = blobSettings.Value;
@@ -409,8 +413,11 @@ public sealed class SystemStatusService : ISystemStatusService
}
}
/// <summary>File name of the PDF attached to a test email when <c>attachPdf</c> is requested.</summary>
internal const string TestPdfAttachmentName = "Fuchs-Intranet-Testmail.pdf";
public async Task<EmailTestResult> SendTestEmailAsync(string to, string subject, string body,
CancellationToken cancellationToken = default)
bool attachPdf = true, CancellationToken cancellationToken = default)
{
to = (to ?? "").Trim();
subject = (subject ?? "").Trim();
@@ -422,14 +429,43 @@ public sealed class SystemStatusService : ISystemStatusService
// Body arrives as plain text from the Admin form; wrap the (HTML-encoded) lines in <p>.
string html = "<p>" + System.Net.WebUtility.HtmlEncode(body).Replace("\r\n", "\n").Replace("\n", "<br/>") + "</p>";
bool sent = await _comService.SendEmailAsync("admin_test", subject, html, to, "Admin Test", attachments: null);
// Optional PDF attachment: exercises the Mailer's multipart/attachment path end-to-end
// (ADR 0028 — the send only counts as successful when the Mailer confirms the attachment).
// The PDF is the same letter layout the invoices/reminders use, rendered in memory from
// the test mail's own subject/body, so no DB or stored document is involved.
Dictionary<string, byte[]>? attachments = null;
if (attachPdf)
{
try
{
attachments = new Dictionary<string, byte[]>
{
[TestPdfAttachmentName] = await RenderTestPdfAsync(to, subject, body),
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Admin test email: rendering the PDF attachment failed");
return new EmailTestResult
{
Sent = false,
Message = $"PDF-Anhang konnte nicht erzeugt werden: {ex.Message}",
RequestedRecipient = to,
};
}
}
bool sent = await _comService.SendEmailAsync("admin_test", subject, html, to, "Admin Test", attachments);
string? overrideTo = string.IsNullOrWhiteSpace(_email.OverrideRecipient) ? null : _email.OverrideRecipient;
string attachmentNote = attachments is null ? "" : $" (mit PDF-Anhang {TestPdfAttachmentName})";
string message = sent
? (overrideTo is null
? "Test-E-Mail wurde an den Mailer übergeben."
: $"Test-E-Mail wurde an den Mailer übergeben (durch OverrideRecipient umgeleitet an {overrideTo}).")
: "Test-E-Mail wurde vom Mailer nicht akzeptiert (ggf. ist der Mailer deaktiviert).";
? $"Test-E-Mail wurde an den Mailer übergeben{attachmentNote}."
: $"Test-E-Mail wurde an den Mailer übergeben{attachmentNote} (durch OverrideRecipient umgeleitet an {overrideTo}).")
: (attachments is null
? "Test-E-Mail wurde vom Mailer nicht akzeptiert (ggf. ist der Mailer deaktiviert)."
: "Test-E-Mail wurde vom Mailer nicht akzeptiert oder der PDF-Anhang wurde nicht bestätigt (ggf. ist der Mailer deaktiviert).");
return new EmailTestResult
{
@@ -437,6 +473,28 @@ public sealed class SystemStatusService : ISystemStatusService
Message = message,
RequestedRecipient = to,
OverrideRecipient = overrideTo,
AttachmentName = attachments is null ? null : TestPdfAttachmentName,
};
}
/// <summary>
/// Renders the test mail as a one-page letter (standard Fuchs letterhead) to PDF/A bytes.
/// Pure in-memory: text blocks are built from the request, nothing is loaded or persisted.
/// </summary>
private async Task<byte[]> RenderTestPdfAsync(string to, string subject, string body)
{
var tb = new FuchsPdf.FdsTextBlocks
{
Address = new[] { "Test-Empfänger", to },
AdminRef = "admin_test",
AdminDatumLabel = "Datum",
AdminDatumValue = DateTime.Now,
AdminProvLabel = "Umgebung",
ProvisionPeriod = _environment.EnvironmentName,
Subject = subject,
Body = body.Replace("\r\n", "\n"),
};
var doc = await _pdf.WriteLetterAsync(tb, draft: false);
return _pdf.DocToPdfBytes(doc);
}
}
+1 -1
View File
@@ -28,7 +28,7 @@
"CheckPdfLicense": true
},
"Mailer": {
"BaseUrl": "https://api.processweb.de",
"BaseUrl": "https://processweb.de",
"AccountId": "82d87114-c8c3-4d33-95e5-4c781a9229ab",
"Token": "SHWRQGM4C3P6VAFFG6RA",
"ServerId": "87AAB12E-29E0-47AB-8DD1-5F5D49B4AC2E",
+7 -2
View File
@@ -42,11 +42,16 @@
"fuchs_intranet_TOTP": "MANAGED_BY_KEYVAULT",
"SMS_APIKey": "MANAGED_BY_KEYVAULT",
"Mailer": {
"BaseUrl": "https://api.processweb.de",
"BaseUrl": "https://processweb.de",
"AccountId": "7da3c1e6-4180-42d6-9a60-5a09d005cce6",
"Token": "MANAGED_BY_KEYVAULT",
"ServerId": "1192f3bf-57e5-4fd2-bb5d-3b7b983a2d98",
"Enabled": true
"Enabled": true,
"Attachments": {
"MaxFileBytes": 10485760,
"MaxMessageBytes": 20971520,
"MaxCount": 10
}
},
"Email": {
"OverrideRecipient": ""
+19
View File
@@ -173,6 +173,25 @@
],
"minify": { "enabled": true }
},
{
"context": "intranet:specialties",
"outputFileName": "wwwroot/web/fis.specialties.de.js",
"inputFiles": [
"js/intranet/modules/fis.specialties_txt_de.js",
"js/intranet/modules/fis.specialties.js"
],
"minify": { "enabled": true }
},
{
"context": "intranet:specialties",
"outputFileName": "wwwroot/web/fis.specialties.min.css",
"inputFiles": [
"css/intranet/oci_variables.scss",
"css/intranet/fis_variables.scss",
"js/intranet/modules/fis.specialties.scss"
],
"minify": { "enabled": true }
},
{
"context": "intranet:usr",
"outputFileName": "wwwroot/web/fis.usr.de.js",
+1 -1
View File
@@ -2,5 +2,5 @@
$fis.notifications.init();
$fis.draft.init();
$fis.ov();
$fis.addAdminMenuButtonsIfAuthorized();
$fis.addAdminMenuButtonsIfAuthorized().then($fis.openModuleFromPath);
});
+29 -8
View File
@@ -12,21 +12,23 @@
})();
/* "Administration" is a single sidemenu dropdown (ocmsmenu supports nested items via `itm`)
containing three buttons — Benutzerverwaltung (fn=usr), Gruppenverwaltung (fn=usrgrp) and
Systemüberwachung (fn=admin) — all restricted to users whose fds_sys authorization exceeds 4.
One auth check gates the whole dropdown; it (and therefore all module scripts) is only added
after that check resolves, so lower-privileged users never see it or fetch any module's code.
Called from fis_main_go.js once the DOM (and the initial main menu) is ready. */
containing four buttons — Benutzerverwaltung (fn=usr), Gruppenverwaltung (fn=usrgrp),
Systemüberwachung (fn=admin) and Spezialfunktionen (fn=specialties) — all restricted to users
whose fds_sys authorization exceeds 4. One auth check gates the whole dropdown; it (and
therefore all module scripts) is only added after that check resolves, so lower-privileged
users never see it or fetch any module's code. Called from fis_main_go.js once the DOM (and
the initial main menu) is ready. Returns a promise that settles once the menu is final. */
$fis.addAdminMenuButtonsIfAuthorized = function () {
if ($('#m_admin_grp').length > 0) { return; } // idempotent
$fis.getAuth('fds_sys').then(function (auth) {
if ($('#m_admin_grp').length > 0) { return Promise.resolve(); } // idempotent
return $fis.getAuth('fds_sys').then(function (auth) {
if ((auth || -1) > 4 && $('#m_admin_grp').length === 0) {
$ocms.ocmsmenu.push({
lbl: $t.m_admin, id: 'm_admin_grp', ico: 'glyphicon glyphicon-cog',
itm: [
{ lbl: $t.m_usr, id: 'm_usr', fnc: 'init:usr', ico: 'glyphicon glyphicon-user' },
{ lbl: $t.m_usrgrp, id: 'm_usrgrp', fnc: 'init:usrgrp', ico: 'glyphicon glyphicon-list' },
{ lbl: $t.m_admin_status, id: 'm_admin', fnc: 'init:admin', ico: 'glyphicon glyphicon-dashboard' }
{ lbl: $t.m_admin_status, id: 'm_admin', fnc: 'init:admin', ico: 'glyphicon glyphicon-dashboard' },
{ lbl: $t.m_specialties, id: 'm_specialties', fnc: 'init:specialties', ico: 'glyphicon glyphicon-wrench' }
]
});
/* Re-render the main menu cleanly: drop the previously generated menu list
@@ -35,4 +37,23 @@ $fis.addAdminMenuButtonsIfAuthorized = function () {
$('#mainmenu').ocmsmenu($ocms.ocmsmenu);
}
}).catch(function () { /* auth lookup failed → leave the menu unchanged */ });
};
/* Deep link: the server renders the same SPA shell for every /{fn} URL (IntranetController.Index
ignores fn), so opening e.g. /specialties directly would otherwise just show the dashboard.
Opens the module named by the first path segment — but only one the user's menu actually
offers (`init:<fn>` item, incl. the admin dropdown), so arbitrary paths, /do/… links and
pre-auth flows never trigger a module load. Must run after the admin menu settled. */
$fis.openModuleFromPath = function () {
let fn = (window.location.pathname.split('/')[1] || '').toLowerCase();
if (!/^[a-z]+$/.test(fn) || fn === 'home') { return; }
let offered = false;
let scan = function (items) {
$.each(items || [], function (i, it) {
if (it.fnc === 'init:' + fn) { offered = true; }
if (it.itm) { scan(it.itm); }
});
};
scan($ocms.ocmsmenu);
if (offered) { $ocms.init(fn); }
};
+1
View File
@@ -8,6 +8,7 @@
, m_admin_status: 'Systemüberwachung'
, m_usr: 'Benutzerverwaltung'
, m_usrgrp: 'Gruppenverwaltung'
, m_specialties: 'Spezialfunktionen'
, rsp: 'Passwort ändern'
, pnm: 'Die Passwörter stimmen nicht überein'
, cps: 'Das neue Passwort wurde gespeichert.'
+4 -3
View File
@@ -187,7 +187,7 @@ let $adm = {
];
if (e.overrideActive === true) { rows.push([$adt.override, e.overrideRecipient]); }
// The pill only turns green once the credentials are verified against the Mailer API
// (probe 'email', GET /mailer/fc?fn=verify) — mailerEnabled alone is not proof they work.
// (probe 'email', GET /api/mailer/verify) — mailerEnabled alone is not proof they work.
let card = $adm.probeCard(grid, $adt.mail, 'email', rows, probe);
let ft = $$.dc('adm_card_ft', card);
$$.bbtn($adt.testmail_btn, 'adm_btn adm_btn_primary').appendTo(ft).click($adm.testMailDlg);
@@ -221,11 +221,12 @@ let $adm = {
$ocms.dlgform([
{ name: 'to', label: $adt.tm_to, type: 'email', required: true, value: ($ocms.auth.email || '') },
{ name: 'subject', label: $adt.tm_subject, type: 'string', required: true, value: $adt.tm_default_subject },
{ name: 'body', label: $adt.tm_body, type: 'text', required: true, value: $adt.tm_default_body }
{ name: 'body', label: $adt.tm_body, type: 'text', required: true, value: $adt.tm_default_body },
{ name: 'attachpdf', label: $adt.tm_attachpdf, type: 'bool', required: true, value: true, note: $adt.tm_attachpdf_note }
], {
title: $adt.testmail,
button: $adt.testmail_btn,
size: [420, 560],
size: [420, 640],
addcontent: $$.dc('adm_note').text($adt.tm_hint),
url: $ocms.url('admin/testmail'),
typedvalues: true,
@@ -75,6 +75,8 @@ let $adt = {
, tm_to: 'Empfänger'
, tm_subject: 'Betreff'
, tm_body: 'Nachricht'
, tm_attachpdf: 'PDF-Anhang mitsenden'
, tm_attachpdf_note: 'Hängt ein aus Betreff und Nachricht erzeugtes Test-PDF (Briefbogen-Layout) an und prüft so auch den Anhang-Pfad des Mailers.'
, tm_hint: 'Der Versand nutzt die normale E-Mail-Pipeline; bei gesetztem OverrideRecipient wird die Nachricht dorthin umgeleitet.'
, tm_sent: 'Test-E-Mail übergeben.'
, tm_failed: 'Test-E-Mail konnte nicht gesendet werden.'
@@ -0,0 +1,97 @@
/* "Specialties" — admin-only page (URL /specialties, menu Administration → Spezialfunktionen)
that exposes the otherwise hidden maintenance actions as cards with small forms. Each card
calls its real action endpoint (which enforces the fds_sys > 4 gate itself); this module only
renders and reports. Add a card per action in `cards`. */
let $spc = {
init2: function () {
$ocms.getScript([], function () { $spc.init3(); });
},
init3: function () {
$fis.cf(true);
$fis.lf(true);
$('#activemodule').text($spt.mdl);
$('#topbar').ocmsmenu([]);
let frm = $$.dc('spcfrm', $('#contentframe'));
$$.dc('spc_intro', frm).text($spt.intro);
let grid = $$.dc('spc_grid', frm);
$.each($spc.cards, function (i, build) { build(grid); });
},
/* ── building blocks ─────────────────────────────────────────────────── */
card: function (grid, title, desc) {
let card = $$.dc('spc_card', grid);
let hd = $$.dc('spc_card_hd', card);
$$.s(title).appendTo(hd);
let bd = $$.dc('spc_card_bd', card);
if (desc) { $$.dc('spc_card_desc', bd).text(desc); }
let form = $('<form/>', { class: 'spc_form', novalidate: 'novalidate' }).appendTo(bd);
let result = $$.dc('spc_result', bd).hide();
return { card: card, body: bd, form: form, result: result };
},
field: function (form, name, label, options) {
options = options || {};
let fg = $$.dc('spc_field', form);
let id = 'spc_' + name;
$$.lbl(label, { for: id }).appendTo(fg);
let inp = $$.i({ id: id, name: name, type: options.type || 'text', placeholder: options.placeholder || '' }).appendTo(fg);
if (options.required) { inp.attr('required', 'required'); }
if (options.pattern) { inp.attr('pattern', options.pattern); }
if (options.note) { $$.dc('spc_field_note', fg).text(options.note); }
return inp;
},
showResult: function (result, ok, lines) {
result.empty().attr('class', 'spc_result ' + (ok ? 'spc_result_ok' : 'spc_result_err'));
$.each([].concat(lines || []), function (i, l) { if (l) { $$.dc('spc_result_line', result).text(l); } });
result.show();
},
/* Wires a card form: validates, confirms, POSTs, reports. `run(values)` must return a
postXT options object without success/error/complete; `report(response)` returns
{ ok, lines }. Callbacks run inside jQuery's dispatch and must never throw. */
submit: function (c, options) {
let btn = $$.bbtn(options.button, 'spc_btn spc_btn_primary').attr('type', 'submit').appendTo($$.dc('spc_actions', c.form));
c.form.on('submit', function (ev) {
ev.preventDefault();
let f = c.form[0];
if (typeof f.reportValidity === 'function' && !f.reportValidity()) { return; }
let values = c.form.serializeObject(true, { typedvalues: false });
if (typeof options.confirm === 'function' && !confirm(options.confirm(values))) { return; }
c.result.hide();
btn.prop('disabled', true).text($spt.working);
$ocms.postXT($.extend(options.run(values), {
success: function (r) {
let rep = options.report(r || {});
$spc.showResult(c.result, rep.ok, rep.lines);
},
error: function (jqXHR) {
$spc.showResult(c.result, false, [$spt.failed, (jqXHR && jqXHR.internalText) || '']);
},
complete: function () { btn.prop('disabled', false).text(options.button); }
}));
});
},
/* ── cards ───────────────────────────────────────────────────────────── */
cards: [
/* Invoice file backup + clear → POST /do/admin/backupclearinvoicefile/<id> (ADR 0027). */
function (grid) {
let c = $spc.card(grid, $spt.ifc_title, $spt.ifc_desc);
$spc.field(c.form, 'id', $spt.ifc_id, { required: true, placeholder: $spt.ifc_id_ph, note: $spt.ifc_id_note, pattern: '[A-Za-z0-9]{1,10}' });
$spc.submit(c, {
button: $spt.ifc_btn,
confirm: function (v) { return $spt.ifc_confirm.replace('{0}', v.id); },
run: function (v) {
return { url: $ocms.url('admin/backupclearinvoicefile/' + encodeURIComponent((v.id || '').trim())), method: 'post', data: {} };
},
report: function (r) {
let b = r.backup || {};
if (b.success === true) {
return { ok: true, lines: [$spt.ifc_ok, b.blobBackupUri ? $spt.ifc_ok_blob.replace('{0}', b.blobBackupUri) : $spt.ifc_ok_noblob] };
}
return { ok: false, lines: [$spt.ifc_reason[b.reason] || ($spt.failed + ' (' + (b.reason || '?') + ')')] };
}
});
}
]
};
let $$spc = { init2: $spc.init2, auth: {} };
export default $$spc;
@@ -0,0 +1,99 @@
.spcfrm {
padding: 1.5rem;
.spc_intro {
margin-bottom: 1rem;
padding: 0.5rem 0.6rem;
background: #f3f6fb;
border-left: 3px solid $fuchs_blau;
font-size: 0.9rem;
color: #444;
}
.spc_grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(24rem, 1fr));
gap: 1rem;
align-items: start;
}
.spc_card {
border: 1px solid #d5d5d5;
border-radius: 0.4rem;
background: #fff;
box-shadow: 0 1px 3px rgba(50, 50, 50, 0.12);
overflow: hidden;
}
.spc_card_hd {
padding: 0.6rem 0.85rem;
background: $fuchs_blau;
color: #fff;
font-weight: bold;
}
.spc_card_bd {
padding: 0.75rem 0.85rem;
}
.spc_card_desc {
font-size: 0.88rem;
color: #555;
margin-bottom: 0.75rem;
}
.spc_form {
.spc_field {
display: flex;
flex-direction: column;
gap: 0.2rem;
margin-bottom: 0.6rem;
label { font-size: 0.85rem; color: #333; font-weight: 600; }
input {
padding: 0.3rem 0.45rem;
border: 1px solid #bbb;
border-radius: 0.25rem;
font-size: 0.95rem;
&:invalid { border-color: #c0341d; }
}
.spc_field_note { font-size: 0.78rem; color: #777; }
}
.spc_actions { text-align: right; margin-top: 0.25rem; }
}
.spc_btn {
cursor: pointer;
border-radius: 0.28rem;
padding: 0.3rem 0.8rem;
font-size: 0.9rem;
border: 1px solid #ababab;
background-color: #fff;
color: #333;
&.spc_btn_primary {
background-color: $fuchs_akzent;
border-color: darken($fuchs_akzent, 8%);
color: #fff;
}
&:disabled { opacity: 0.6; cursor: default; }
}
.spc_result {
margin-top: 0.75rem;
padding: 0.5rem 0.6rem;
border-radius: 0.25rem;
font-size: 0.88rem;
word-break: break-word;
.spc_result_line + .spc_result_line { margin-top: 0.25rem; }
&.spc_result_ok { background: #e2f3da; color: #2f6d18; }
&.spc_result_err { background: #f3ded9; color: #c0341d; }
}
}
@@ -0,0 +1,26 @@
let $spt = {
mdl: 'Spezialfunktionen',
intro: 'Versteckte Wartungsfunktionen für Administratoren. Jede Aktion wird protokolliert (fds__admin_activity / Anwendungslog).',
run: 'Ausführen',
working: 'Wird ausgeführt…',
failed: 'Die Aktion ist fehlgeschlagen.',
/* Card: invoice file backup + clear */
ifc_title: 'Rechnungsdatei zurücksetzen (Soft-Delete)',
ifc_desc: 'Sichert die gespeicherte PDF-Datei einer finalisierten Rechnung (DB-Tabelle fds__invoice_file_backups + Kopie im Blob-Archiv) und entfernt sie aus der Rechnung. Beim nächsten Aufruf des Dokuments wird die PDF neu generiert (ADR 0025/0027).',
ifc_id: 'Interne Rechnungs-ID',
ifc_id_ph: 'z.B. W7VAL',
ifc_id_note: 'Die interne ID (Spalte Id), nicht die Rechnungsnummer R2026-….',
ifc_btn: 'Datei sichern und entfernen',
ifc_confirm: 'Die Rechnungsdatei von "{0}" wird gesichert und aus der Rechnung entfernt. Fortfahren?',
ifc_ok: 'Erledigt: Die Datei wurde gesichert und entfernt. Das Dokument wird beim nächsten Aufruf neu generiert.',
ifc_ok_blob: 'Blob-Backup: {0}',
ifc_ok_noblob: 'Kein Blob-Backup angelegt (Blob-Archiv deaktiviert, kein Blob vorhanden oder Kopie fehlgeschlagen — siehe Log).',
ifc_reason: {
invoice_not_found: 'Rechnung nicht gefunden.',
not_final: 'Die Rechnung ist nicht finalisiert — nur finalisierte Rechnungen können zurückgesetzt werden (Entwürfe würden sonst dauerhaft als Entwurf gespeichert).',
external: 'Externe (MFR-)Dokumente können nicht neu generiert werden.',
no_file: 'Es ist keine Datei gespeichert — nichts zu tun.',
db_error: 'Datenbankfehler — Details im Anwendungslog (CodeReference AdminBackupClearInvoiceFile).'
}
};
+6 -3
View File
@@ -75,6 +75,8 @@ let $adt = {
, tm_to: 'Empfänger'
, tm_subject: 'Betreff'
, tm_body: 'Nachricht'
, tm_attachpdf: 'PDF-Anhang mitsenden'
, tm_attachpdf_note: 'Hängt ein aus Betreff und Nachricht erzeugtes Test-PDF (Briefbogen-Layout) an und prüft so auch den Anhang-Pfad des Mailers.'
, tm_hint: 'Der Versand nutzt die normale E-Mail-Pipeline; bei gesetztem OverrideRecipient wird die Nachricht dorthin umgeleitet.'
, tm_sent: 'Test-E-Mail übergeben.'
, tm_failed: 'Test-E-Mail konnte nicht gesendet werden.'
@@ -272,7 +274,7 @@ let $adm = {
];
if (e.overrideActive === true) { rows.push([$adt.override, e.overrideRecipient]); }
// The pill only turns green once the credentials are verified against the Mailer API
// (probe 'email', GET /mailer/fc?fn=verify) — mailerEnabled alone is not proof they work.
// (probe 'email', GET /api/mailer/verify) — mailerEnabled alone is not proof they work.
let card = $adm.probeCard(grid, $adt.mail, 'email', rows, probe);
let ft = $$.dc('adm_card_ft', card);
$$.bbtn($adt.testmail_btn, 'adm_btn adm_btn_primary').appendTo(ft).click($adm.testMailDlg);
@@ -306,11 +308,12 @@ let $adm = {
$ocms.dlgform([
{ name: 'to', label: $adt.tm_to, type: 'email', required: true, value: ($ocms.auth.email || '') },
{ name: 'subject', label: $adt.tm_subject, type: 'string', required: true, value: $adt.tm_default_subject },
{ name: 'body', label: $adt.tm_body, type: 'text', required: true, value: $adt.tm_default_body }
{ name: 'body', label: $adt.tm_body, type: 'text', required: true, value: $adt.tm_default_body },
{ name: 'attachpdf', label: $adt.tm_attachpdf, type: 'bool', required: true, value: true, note: $adt.tm_attachpdf_note }
], {
title: $adt.testmail,
button: $adt.testmail_btn,
size: [420, 560],
size: [420, 640],
addcontent: $$.dc('adm_note').text($adt.tm_hint),
url: $ocms.url('admin/testmail'),
typedvalues: true,
File diff suppressed because one or more lines are too long
+31 -9
View File
@@ -2846,6 +2846,7 @@ $.extend($t, {
, m_admin_status: 'Systemüberwachung'
, m_usr: 'Benutzerverwaltung'
, m_usrgrp: 'Gruppenverwaltung'
, m_specialties: 'Spezialfunktionen'
, rsp: 'Passwort ändern'
, pnm: 'Die Passwörter stimmen nicht überein'
, cps: 'Das neue Passwort wurde gespeichert.'
@@ -3242,21 +3243,23 @@ $fis.draft = {
})();
/* "Administration" is a single sidemenu dropdown (ocmsmenu supports nested items via `itm`)
containing three buttons — Benutzerverwaltung (fn=usr), Gruppenverwaltung (fn=usrgrp) and
Systemüberwachung (fn=admin) — all restricted to users whose fds_sys authorization exceeds 4.
One auth check gates the whole dropdown; it (and therefore all module scripts) is only added
after that check resolves, so lower-privileged users never see it or fetch any module's code.
Called from fis_main_go.js once the DOM (and the initial main menu) is ready. */
containing four buttons — Benutzerverwaltung (fn=usr), Gruppenverwaltung (fn=usrgrp),
Systemüberwachung (fn=admin) and Spezialfunktionen (fn=specialties) — all restricted to users
whose fds_sys authorization exceeds 4. One auth check gates the whole dropdown; it (and
therefore all module scripts) is only added after that check resolves, so lower-privileged
users never see it or fetch any module's code. Called from fis_main_go.js once the DOM (and
the initial main menu) is ready. Returns a promise that settles once the menu is final. */
$fis.addAdminMenuButtonsIfAuthorized = function () {
if ($('#m_admin_grp').length > 0) { return; } // idempotent
$fis.getAuth('fds_sys').then(function (auth) {
if ($('#m_admin_grp').length > 0) { return Promise.resolve(); } // idempotent
return $fis.getAuth('fds_sys').then(function (auth) {
if ((auth || -1) > 4 && $('#m_admin_grp').length === 0) {
$ocms.ocmsmenu.push({
lbl: $t.m_admin, id: 'm_admin_grp', ico: 'glyphicon glyphicon-cog',
itm: [
{ lbl: $t.m_usr, id: 'm_usr', fnc: 'init:usr', ico: 'glyphicon glyphicon-user' },
{ lbl: $t.m_usrgrp, id: 'm_usrgrp', fnc: 'init:usrgrp', ico: 'glyphicon glyphicon-list' },
{ lbl: $t.m_admin_status, id: 'm_admin', fnc: 'init:admin', ico: 'glyphicon glyphicon-dashboard' }
{ lbl: $t.m_admin_status, id: 'm_admin', fnc: 'init:admin', ico: 'glyphicon glyphicon-dashboard' },
{ lbl: $t.m_specialties, id: 'm_specialties', fnc: 'init:specialties', ico: 'glyphicon glyphicon-wrench' }
]
});
/* Re-render the main menu cleanly: drop the previously generated menu list
@@ -3266,9 +3269,28 @@ $fis.addAdminMenuButtonsIfAuthorized = function () {
}
}).catch(function () { /* auth lookup failed → leave the menu unchanged */ });
};
/* Deep link: the server renders the same SPA shell for every /{fn} URL (IntranetController.Index
ignores fn), so opening e.g. /specialties directly would otherwise just show the dashboard.
Opens the module named by the first path segment — but only one the user's menu actually
offers (`init:<fn>` item, incl. the admin dropdown), so arbitrary paths, /do/… links and
pre-auth flows never trigger a module load. Must run after the admin menu settled. */
$fis.openModuleFromPath = function () {
let fn = (window.location.pathname.split('/')[1] || '').toLowerCase();
if (!/^[a-z]+$/.test(fn) || fn === 'home') { return; }
let offered = false;
let scan = function (items) {
$.each(items || [], function (i, it) {
if (it.fnc === 'init:' + fn) { offered = true; }
if (it.itm) { scan(it.itm); }
});
};
scan($ocms.ocmsmenu);
if (offered) { $ocms.init(fn); }
};
$(document).ready(function () {
$fis.notifications.init();
$fis.draft.init();
$fis.ov();
$fis.addAdminMenuButtonsIfAuthorized();
$fis.addAdminMenuButtonsIfAuthorized().then($fis.openModuleFromPath);
});
+2 -2
View File
File diff suppressed because one or more lines are too long
+120
View File
@@ -0,0 +1,120 @@
/* basics */
/* Color SCHEME */
/* #8396bd */
/* #e3e6e6 */
/* #ffffff */
/* #262626 */
/* #262626 */
/* #e3e6e6 */
/* #FFC801 */
/* #ffff4a */
/* media breaks */
/* other */
/* mixin */
/* basics */
/* Color SCHEME */
/* #1b4379 #0033b3 */
/*neu: #56a532 ; alt: rgb(32,144,119);*/
/* 10% sw */
.spcfrm {
padding: 1.5rem;
}
.spcfrm .spc_intro {
margin-bottom: 1rem;
padding: 0.5rem 0.6rem;
background: #f3f6fb;
border-left: 3px solid rgb(27, 67, 121);
font-size: 0.9rem;
color: #444;
}
.spcfrm .spc_grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(24rem, 1fr));
gap: 1rem;
align-items: start;
}
.spcfrm .spc_card {
border: 1px solid #d5d5d5;
border-radius: 0.4rem;
background: #fff;
box-shadow: 0 1px 3px rgba(50, 50, 50, 0.12);
overflow: hidden;
}
.spcfrm .spc_card_hd {
padding: 0.6rem 0.85rem;
background: rgb(27, 67, 121);
color: #fff;
font-weight: bold;
}
.spcfrm .spc_card_bd {
padding: 0.75rem 0.85rem;
}
.spcfrm .spc_card_desc {
font-size: 0.88rem;
color: #555;
margin-bottom: 0.75rem;
}
.spcfrm .spc_form .spc_field {
display: flex;
flex-direction: column;
gap: 0.2rem;
margin-bottom: 0.6rem;
}
.spcfrm .spc_form .spc_field label {
font-size: 0.85rem;
color: #333;
font-weight: 600;
}
.spcfrm .spc_form .spc_field input {
padding: 0.3rem 0.45rem;
border: 1px solid #bbb;
border-radius: 0.25rem;
font-size: 0.95rem;
}
.spcfrm .spc_form .spc_field input:invalid {
border-color: #c0341d;
}
.spcfrm .spc_form .spc_field .spc_field_note {
font-size: 0.78rem;
color: #777;
}
.spcfrm .spc_form .spc_actions {
text-align: right;
margin-top: 0.25rem;
}
.spcfrm .spc_btn {
cursor: pointer;
border-radius: 0.28rem;
padding: 0.3rem 0.8rem;
font-size: 0.9rem;
border: 1px solid #ababab;
background-color: #fff;
color: #333;
}
.spcfrm .spc_btn.spc_btn_primary {
background-color: rgb(86, 165, 50);
border-color: rgb(69.68, 133.688372093, 40.511627907);
color: #fff;
}
.spcfrm .spc_btn:disabled {
opacity: 0.6;
cursor: default;
}
.spcfrm .spc_result {
margin-top: 0.75rem;
padding: 0.5rem 0.6rem;
border-radius: 0.25rem;
font-size: 0.88rem;
word-break: break-word;
}
.spcfrm .spc_result .spc_result_line + .spc_result_line {
margin-top: 0.25rem;
}
.spcfrm .spc_result.spc_result_ok {
background: #e2f3da;
color: #2f6d18;
}
.spcfrm .spc_result.spc_result_err {
background: #f3ded9;
color: #c0341d;
}
+124
View File
@@ -0,0 +1,124 @@
let $spt = {
mdl: 'Spezialfunktionen',
intro: 'Versteckte Wartungsfunktionen für Administratoren. Jede Aktion wird protokolliert (fds__admin_activity / Anwendungslog).',
run: 'Ausführen',
working: 'Wird ausgeführt…',
failed: 'Die Aktion ist fehlgeschlagen.',
/* Card: invoice file backup + clear */
ifc_title: 'Rechnungsdatei zurücksetzen (Soft-Delete)',
ifc_desc: 'Sichert die gespeicherte PDF-Datei einer finalisierten Rechnung (DB-Tabelle fds__invoice_file_backups + Kopie im Blob-Archiv) und entfernt sie aus der Rechnung. Beim nächsten Aufruf des Dokuments wird die PDF neu generiert (ADR 0025/0027).',
ifc_id: 'Interne Rechnungs-ID',
ifc_id_ph: 'z.B. W7VAL',
ifc_id_note: 'Die interne ID (Spalte Id), nicht die Rechnungsnummer R2026-….',
ifc_btn: 'Datei sichern und entfernen',
ifc_confirm: 'Die Rechnungsdatei von "{0}" wird gesichert und aus der Rechnung entfernt. Fortfahren?',
ifc_ok: 'Erledigt: Die Datei wurde gesichert und entfernt. Das Dokument wird beim nächsten Aufruf neu generiert.',
ifc_ok_blob: 'Blob-Backup: {0}',
ifc_ok_noblob: 'Kein Blob-Backup angelegt (Blob-Archiv deaktiviert, kein Blob vorhanden oder Kopie fehlgeschlagen — siehe Log).',
ifc_reason: {
invoice_not_found: 'Rechnung nicht gefunden.',
not_final: 'Die Rechnung ist nicht finalisiert — nur finalisierte Rechnungen können zurückgesetzt werden (Entwürfe würden sonst dauerhaft als Entwurf gespeichert).',
external: 'Externe (MFR-)Dokumente können nicht neu generiert werden.',
no_file: 'Es ist keine Datei gespeichert — nichts zu tun.',
db_error: 'Datenbankfehler — Details im Anwendungslog (CodeReference AdminBackupClearInvoiceFile).'
}
};
/* "Specialties" — admin-only page (URL /specialties, menu Administration → Spezialfunktionen)
that exposes the otherwise hidden maintenance actions as cards with small forms. Each card
calls its real action endpoint (which enforces the fds_sys > 4 gate itself); this module only
renders and reports. Add a card per action in `cards`. */
let $spc = {
init2: function () {
$ocms.getScript([], function () { $spc.init3(); });
},
init3: function () {
$fis.cf(true);
$fis.lf(true);
$('#activemodule').text($spt.mdl);
$('#topbar').ocmsmenu([]);
let frm = $$.dc('spcfrm', $('#contentframe'));
$$.dc('spc_intro', frm).text($spt.intro);
let grid = $$.dc('spc_grid', frm);
$.each($spc.cards, function (i, build) { build(grid); });
},
/* ── building blocks ─────────────────────────────────────────────────── */
card: function (grid, title, desc) {
let card = $$.dc('spc_card', grid);
let hd = $$.dc('spc_card_hd', card);
$$.s(title).appendTo(hd);
let bd = $$.dc('spc_card_bd', card);
if (desc) { $$.dc('spc_card_desc', bd).text(desc); }
let form = $('<form/>', { class: 'spc_form', novalidate: 'novalidate' }).appendTo(bd);
let result = $$.dc('spc_result', bd).hide();
return { card: card, body: bd, form: form, result: result };
},
field: function (form, name, label, options) {
options = options || {};
let fg = $$.dc('spc_field', form);
let id = 'spc_' + name;
$$.lbl(label, { for: id }).appendTo(fg);
let inp = $$.i({ id: id, name: name, type: options.type || 'text', placeholder: options.placeholder || '' }).appendTo(fg);
if (options.required) { inp.attr('required', 'required'); }
if (options.pattern) { inp.attr('pattern', options.pattern); }
if (options.note) { $$.dc('spc_field_note', fg).text(options.note); }
return inp;
},
showResult: function (result, ok, lines) {
result.empty().attr('class', 'spc_result ' + (ok ? 'spc_result_ok' : 'spc_result_err'));
$.each([].concat(lines || []), function (i, l) { if (l) { $$.dc('spc_result_line', result).text(l); } });
result.show();
},
/* Wires a card form: validates, confirms, POSTs, reports. `run(values)` must return a
postXT options object without success/error/complete; `report(response)` returns
{ ok, lines }. Callbacks run inside jQuery's dispatch and must never throw. */
submit: function (c, options) {
let btn = $$.bbtn(options.button, 'spc_btn spc_btn_primary').attr('type', 'submit').appendTo($$.dc('spc_actions', c.form));
c.form.on('submit', function (ev) {
ev.preventDefault();
let f = c.form[0];
if (typeof f.reportValidity === 'function' && !f.reportValidity()) { return; }
let values = c.form.serializeObject(true, { typedvalues: false });
if (typeof options.confirm === 'function' && !confirm(options.confirm(values))) { return; }
c.result.hide();
btn.prop('disabled', true).text($spt.working);
$ocms.postXT($.extend(options.run(values), {
success: function (r) {
let rep = options.report(r || {});
$spc.showResult(c.result, rep.ok, rep.lines);
},
error: function (jqXHR) {
$spc.showResult(c.result, false, [$spt.failed, (jqXHR && jqXHR.internalText) || '']);
},
complete: function () { btn.prop('disabled', false).text(options.button); }
}));
});
},
/* ── cards ───────────────────────────────────────────────────────────── */
cards: [
/* Invoice file backup + clear → POST /do/admin/backupclearinvoicefile/<id> (ADR 0027). */
function (grid) {
let c = $spc.card(grid, $spt.ifc_title, $spt.ifc_desc);
$spc.field(c.form, 'id', $spt.ifc_id, { required: true, placeholder: $spt.ifc_id_ph, note: $spt.ifc_id_note, pattern: '[A-Za-z0-9]{1,10}' });
$spc.submit(c, {
button: $spt.ifc_btn,
confirm: function (v) { return $spt.ifc_confirm.replace('{0}', v.id); },
run: function (v) {
return { url: $ocms.url('admin/backupclearinvoicefile/' + encodeURIComponent((v.id || '').trim())), method: 'post', data: {} };
},
report: function (r) {
let b = r.backup || {};
if (b.success === true) {
return { ok: true, lines: [$spt.ifc_ok, b.blobBackupUri ? $spt.ifc_ok_blob.replace('{0}', b.blobBackupUri) : $spt.ifc_ok_noblob] };
}
return { ok: false, lines: [$spt.ifc_reason[b.reason] || ($spt.failed + ' (' + (b.reason || '?') + ')')] };
}
});
}
]
};
let $$spc = { init2: $spc.init2, auth: {} };
export default $$spc;
+1
View File
@@ -0,0 +1 @@
let $spt={mdl:"Spezialfunktionen",intro:"Versteckte Wartungsfunktionen für Administratoren. Jede Aktion wird protokolliert (fds__admin_activity / Anwendungslog).",run:"Ausführen",working:"Wird ausgeführt…",failed:"Die Aktion ist fehlgeschlagen.",ifc_title:"Rechnungsdatei zurücksetzen (Soft-Delete)",ifc_desc:"Sichert die gespeicherte PDF-Datei einer finalisierten Rechnung (DB-Tabelle fds__invoice_file_backups + Kopie im Blob-Archiv) und entfernt sie aus der Rechnung. Beim nächsten Aufruf des Dokuments wird die PDF neu generiert (ADR 0025/0027).",ifc_id:"Interne Rechnungs-ID",ifc_id_ph:"z.B. W7VAL",ifc_id_note:"Die interne ID (Spalte Id), nicht die Rechnungsnummer R2026-….",ifc_btn:"Datei sichern und entfernen",ifc_confirm:'Die Rechnungsdatei von "{0}" wird gesichert und aus der Rechnung entfernt. Fortfahren?',ifc_ok:"Erledigt: Die Datei wurde gesichert und entfernt. Das Dokument wird beim nächsten Aufruf neu generiert.",ifc_ok_blob:"Blob-Backup: {0}",ifc_ok_noblob:"Kein Blob-Backup angelegt (Blob-Archiv deaktiviert, kein Blob vorhanden oder Kopie fehlgeschlagen — siehe Log).",ifc_reason:{invoice_not_found:"Rechnung nicht gefunden.",not_final:"Die Rechnung ist nicht finalisiert — nur finalisierte Rechnungen können zurückgesetzt werden (Entwürfe würden sonst dauerhaft als Entwurf gespeichert).",external:"Externe (MFR-)Dokumente können nicht neu generiert werden.",no_file:"Es ist keine Datei gespeichert — nichts zu tun.",db_error:"Datenbankfehler — Details im Anwendungslog (CodeReference AdminBackupClearInvoiceFile)."}},$spc={init2:function(){$ocms.getScript([],(function(){$spc.init3()}))},init3:function(){$fis.cf(!0),$fis.lf(!0),$("#activemodule").text($spt.mdl),$("#topbar").ocmsmenu([]);let e=$$.dc("spcfrm",$("#contentframe"));$$.dc("spc_intro",e).text($spt.intro);let t=$$.dc("spc_grid",e);$.each($spc.cards,(function(e,n){n(t)}))},card:function(e,t,n){let i=$$.dc("spc_card",e),r=$$.dc("spc_card_hd",i);$$.s(t).appendTo(r);let c=$$.dc("spc_card_bd",i);return n&&$$.dc("spc_card_desc",c).text(n),{card:i,body:c,form:$("<form/>",{class:"spc_form",novalidate:"novalidate"}).appendTo(c),result:$$.dc("spc_result",c).hide()}},field:function(e,t,n,i){i=i||{};let r=$$.dc("spc_field",e),c="spc_"+t;$$.lbl(n,{for:c}).appendTo(r);let o=$$.i({id:c,name:t,type:i.type||"text",placeholder:i.placeholder||""}).appendTo(r);return i.required&&o.attr("required","required"),i.pattern&&o.attr("pattern",i.pattern),i.note&&$$.dc("spc_field_note",r).text(i.note),o},showResult:function(e,t,n){e.empty().attr("class","spc_result "+(t?"spc_result_ok":"spc_result_err")),$.each([].concat(n||[]),(function(t,n){n&&$$.dc("spc_result_line",e).text(n)})),e.show()},submit:function(e,t){let n=$$.bbtn(t.button,"spc_btn spc_btn_primary").attr("type","submit").appendTo($$.dc("spc_actions",e.form));e.form.on("submit",(function(i){i.preventDefault();let r=e.form[0];if("function"==typeof r.reportValidity&&!r.reportValidity())return;let c=e.form.serializeObject(!0,{typedvalues:!1});("function"!=typeof t.confirm||confirm(t.confirm(c)))&&(e.result.hide(),n.prop("disabled",!0).text($spt.working),$ocms.postXT($.extend(t.run(c),{success:function(n){let i=t.report(n||{});$spc.showResult(e.result,i.ok,i.lines)},error:function(t){$spc.showResult(e.result,!1,[$spt.failed,t&&t.internalText||""])},complete:function(){n.prop("disabled",!1).text(t.button)}})))}))},cards:[function(e){let t=$spc.card(e,$spt.ifc_title,$spt.ifc_desc);$spc.field(t.form,"id",$spt.ifc_id,{required:!0,placeholder:$spt.ifc_id_ph,note:$spt.ifc_id_note,pattern:"[A-Za-z0-9]{1,10}"}),$spc.submit(t,{button:$spt.ifc_btn,confirm:function(e){return $spt.ifc_confirm.replace("{0}",e.id)},run:function(e){return{url:$ocms.url("admin/backupclearinvoicefile/"+encodeURIComponent((e.id||"").trim())),method:"post",data:{}}},report:function(e){let t=e.backup||{};return!0===t.success?{ok:!0,lines:[$spt.ifc_ok,t.blobBackupUri?$spt.ifc_ok_blob.replace("{0}",t.blobBackupUri):$spt.ifc_ok_noblob]}:{ok:!1,lines:[$spt.ifc_reason[t.reason]||$spt.failed+" ("+(t.reason||"?")+")"]}}})}]},$$spc={init2:$spc.init2,auth:{}};export default $$spc;
+1
View File
@@ -0,0 +1 @@
.spcfrm{padding:1.5rem}.spcfrm .spc_intro{margin-bottom:1rem;padding:.5rem .6rem;background:#f3f6fb;border-left:3px solid #1b4379;font-size:.9rem;color:#444}.spcfrm .spc_grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(24rem,1fr));gap:1rem;align-items:start}.spcfrm .spc_card{border:1px solid #d5d5d5;border-radius:.4rem;background:#fff;box-shadow:0 1px 3px rgba(50,50,50,.12);overflow:hidden}.spcfrm .spc_card_hd{padding:.6rem .85rem;background:#1b4379;color:#fff;font-weight:700}.spcfrm .spc_card_bd{padding:.75rem .85rem}.spcfrm .spc_card_desc{font-size:.88rem;color:#555;margin-bottom:.75rem}.spcfrm .spc_form .spc_field{display:flex;flex-direction:column;gap:.2rem;margin-bottom:.6rem}.spcfrm .spc_form .spc_field label{font-size:.85rem;color:#333;font-weight:600}.spcfrm .spc_form .spc_field input{padding:.3rem .45rem;border:1px solid #bbb;border-radius:.25rem;font-size:.95rem}.spcfrm .spc_form .spc_field input:invalid{border-color:#c0341d}.spcfrm .spc_form .spc_field .spc_field_note{font-size:.78rem;color:#777}.spcfrm .spc_form .spc_actions{text-align:right;margin-top:.25rem}.spcfrm .spc_btn{cursor:pointer;border-radius:.28rem;padding:.3rem .8rem;font-size:.9rem;border:1px solid #ababab;background-color:#fff;color:#333}.spcfrm .spc_btn.spc_btn_primary{background-color:#56a532;border-color:rgb(69.68,133.688372093,40.511627907);color:#fff}.spcfrm .spc_btn:disabled{opacity:.6;cursor:default}.spcfrm .spc_result{margin-top:.75rem;padding:.5rem .6rem;border-radius:.25rem;font-size:.88rem;word-break:break-word}.spcfrm .spc_result .spc_result_line+.spc_result_line{margin-top:.25rem}.spcfrm .spc_result.spc_result_ok{background:#e2f3da;color:#2f6d18}.spcfrm .spc_result.spc_result_err{background:#f3ded9;color:#c0341d}
File diff suppressed because one or more lines are too long