feat: Implement admin-only specialties management interface
Playwright Tests / test (push) Canceled after 0s
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:
@@ -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.
|
||||
Reference in New Issue
Block a user