Files
Fuchs_Intranet/Fuchs/Docs/Decisions/0027-admin-invoice-file-backup-and-clear.md
Stefan 249a945f5e
Playwright Tests / test (push) Canceled after 0s
feat: Implement admin-only specialties management interface
- 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.
2026-09-17 21:17:23 +02:00

6.3 KiB

status, date, applyTo, supersededBy
status date applyTo supersededBy
Accepted 2026-09-17
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

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 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.