- 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.
6.3 KiB
status, date, applyTo, supersededBy
| status | date | applyTo | supersededBy | |||||||
|---|---|---|---|---|---|---|---|---|---|---|
| Accepted | 2026-09-17 |
|
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, 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 normalfds__admin_activityinsert (matchingfds__setInvoiceFile's pattern) and every call is also logged through the app'sILogger/DB-log-sink pipeline (ADR 0024) — unlike the manual proc, this path is fully auditable._BackupAndClearInvoiceFileis 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 throughfds__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. IBlobStorageServicegainsCopyInvoicePdfToBackupAsync: 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_Admincase,admin/backupclearinvoicefile/<invoiceId>, gated by the existingfds_sys > 4check and restricted toPOST(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, seeDocs/Concepts/admin-specialties.md).
Consequences
- Two invoice-file "backup and clear" code paths now exist for different
audiences:
_BackupAndClearInvoiceFile(manual, SSMS, no app log) andfds__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_backupsaccumulates 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; likefds__invoice_file_backups, no retention is applied.
Alternatives considered
- Call
_BackupAndClearInvoiceFiledirectly 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.