feat: Enhance invoice file backup procedure to include JSON snapshots
Playwright Tests / test (push) Canceled after 0s

- Updated the stored procedure `fds__admin_backupAndClearInvoiceFile.sql` to back up not only the invoice file but also JSON snapshots of related data (invoices, items, details, service requests) to ensure consistency and traceability.
- Modified the table structure of `fds__invoice_file_backups` to accommodate new JSON columns for storing these snapshots.
- Added an ADR (0030) document to outline the decision and rationale behind these changes, emphasizing the importance of maintaining a consistent set of data alongside the backed-up file.
This commit is contained in:
Stefan
2026-09-17 22:39:01 +02:00
parent 249a945f5e
commit 9569c1b65e
14 changed files with 187 additions and 23 deletions
+8
View File
@@ -22,6 +22,14 @@ rest of the Administration menu (`fds_sys > 4`), and reachable both from the
menu (Administration → Spezialfunktionen) and by opening the URL
`/specialties` directly.
The menu entry is deliberately hidden: it only appears when the Administration
dropdown is opened with **Shift** held (Shift+click, or Shift+Enter/Space on
the focused toggle); a plain open hides it again
(`$fis.bindShiftOnlyMenuItems` in `fis_main_menu.js`, driven by the item's
`lclass: 'shift-only'`). This keeps the maintenance actions out of casual
reach without adding another authorization level — the item is still part of
the menu array, so the `/specialties` deep link is unaffected.
## How it works
- **Module load** follows the standard contract (see
+4 -1
View File
@@ -32,7 +32,10 @@ Access is restricted to users whose `fds_sys` module authorization is greater th
does it push a single "Administration" dropdown entry into `$ocms.ocmsmenu` — containing this
module's "Systemüberwachung" (`init:admin`) item alongside `usr`'s "Benutzerverwaltung"
(`init:usr`) item — and re-render `#mainmenu`. Users below the threshold never see the
dropdown and never fetch either module's script.
dropdown and never fetch either module's script. Within the dropdown, "Systemüberwachung"
(like "Spezialfunktionen") is a `shift-only` item: it is listed only when the dropdown is
opened with **Shift** held (`$fis.bindShiftOnlyMenuItems`); the `/admin` deep link is
unaffected. This is a convenience/accident guard, not an authorization level.
2. **Server-side gate** — every `/do/admin/*` endpoint resolves
`fis_getModuleAuth('fds_sys', authuser)` and returns 401 unless it is > 4. The only
exception is `admin/auth`, which returns `{ manage: 0 }` for unauthorized users so the
@@ -0,0 +1,69 @@
---
status: Accepted
date: 2026-09-17
applyTo:
- "Fuchs_Database/dbo/Stored Procedures/fds__admin_backupAndClearInvoiceFile.sql"
- "Fuchs_Database/dbo/Tables/fds__invoice_file_backups.sql"
supersededBy: ""
---
# 0030 — Invoice-file backup carries a JSON snapshot of the invoice data
## Context
ADR 0027 introduced `[dbo].[fds__invoice_file_backups]`: one row per
admin backup-and-clear action holding the invoice's `[file]` bytes before
they are cleared for regeneration (ADR 0025). The backup held only the
rendered PDF. The data it was rendered from — the `fds__invoices` row, its
`fds__invoice_items` rows, its `fds__invoice_details` row and its
`fds__invoice_servicerequests` link rows — lives on in
the live tables and keeps changing (payment status, modification stamps,
later edits). A backed-up file could therefore no longer be matched against
the data state it was produced from, which is exactly the question the
backup exists to answer when a regenerated document differs from the
original.
## Decision
- Each backup row additionally stores four JSON snapshots, written in the
same statement and transaction as the file copy:
- `InvoiceJson` — the `fds__invoices` row (`FOR JSON PATH,
WITHOUT_ARRAY_WRAPPER`), **without** `[file]`, which is already stored as
bytes in the row and would otherwise be base64-duplicated;
- `InvoiceItemsJson` — all `fds__invoice_items` rows for the invoice as a
JSON array, ordered by `SortOrder`, `Position`, `Id`;
- `InvoiceDetailsJson` — the `fds__invoice_details` row, or `NULL` if the
invoice has none;
- `InvoiceServiceRequestsJson` — all `fds__invoice_servicerequests` rows
for the invoice as a JSON array, ordered by `SortOrder`, `Id`. Not part
of the rendered document, but part of the invoice's data set.
- The snapshots are JSON columns on the **same** backup row rather than
four mirror tables: one row is one consistent set (file + data), the
atomicity guarantee of ADR 0027 covers it without extra keys, and adding a
column to an invoice table does not require touching the backup schema.
The item/detail/service-request snapshots use `SELECT *`, so new columns appear
automatically; the invoice snapshot lists its columns explicitly only to
exclude `[file]` — a new `fds__invoices` column must be added to that
list.
- Restore is manual (`OPENJSON` in SSMS); no app-side reader is added. The
snapshots are forensic evidence, not a rollback mechanism.
## Consequences
- Backup rows grow by the size of the invoice's data (small next to the
PDF). No retention policy is added — same standing as ADR 0027.
- `INCLUDE_NULL_VALUES` is used so a `NULL` in the snapshot is
distinguishable from a column that did not exist at backup time.
- Adding a column to `fds__invoices` requires updating the explicit column
list in the proc; `fds__invoice_items` / `fds__invoice_details` /
`fds__invoice_servicerequests` need no change.
## Alternatives considered
- **Mirror tables** (`fds__invoice_backups`, `fds__invoice_items_backups`,
…) keyed by backup `uid`: typed and queryable, but four schemas that
drift with every invoice-table change and four inserts to keep atomic.
Rejected for maintenance cost; the snapshots are read rarely and by hand.
- **Snapshot only the invoice header**: the items are what the PDF body is
rendered from; leaving them out would not answer "what did this file
contain and why".
+23 -3
View File
@@ -17,7 +17,13 @@
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. */
the initial main menu) is ready. Returns a promise that settles once the menu is final.
Systemüberwachung and Spezialfunktionen are hidden entries: they are only shown while the
Administration dropdown is opened with the Shift key held (Shift+click on the toggle). A plain
click hides them again, so the diagnostics/maintenance pages are never one accidental click
away. The items still exist in the menu array, so the /admin and /specialties deep links
(openModuleFromPath) keep working. */
$fis.addAdminMenuButtonsIfAuthorized = function () {
if ($('#m_admin_grp').length > 0) { return Promise.resolve(); } // idempotent
return $fis.getAuth('fds_sys').then(function (auth) {
@@ -27,18 +33,32 @@ $fis.addAdminMenuButtonsIfAuthorized = function () {
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_specialties, id: 'm_specialties', fnc: 'init:specialties', ico: 'glyphicon glyphicon-wrench' }
{ lbl: $t.m_admin_status, id: 'm_admin', fnc: 'init:admin', ico: 'glyphicon glyphicon-dashboard', lclass: 'shift-only' },
{ lbl: $t.m_specialties, id: 'm_specialties', fnc: 'init:specialties', ico: 'glyphicon glyphicon-wrench', lclass: 'shift-only' }
]
});
/* Re-render the main menu cleanly: drop the previously generated menu list
(never the .nav-right settings dropdown) and rebuild it from the array. */
$('#mainmenu').children('ul:not(.nav-right)').remove();
$('#mainmenu').ocmsmenu($ocms.ocmsmenu);
$fis.bindShiftOnlyMenuItems($('#m_admin_grp'));
}
}).catch(function () { /* auth lookup failed → leave the menu unchanged */ });
};
/* Hides every `.shift-only` entry of a dropdown group and reveals them only for an opening
initiated with Shift held. Bound on mousedown (fires before Bootstrap's click toggle, so the
list is already in the right state when the dropdown becomes visible) and on keydown for
keyboard users (Shift+Enter / Shift+Space on the focused toggle). */
$fis.bindShiftOnlyMenuItems = function (group) {
let items = group.find('.shift-only').hide();
if (items.length === 0) { return; }
let apply = function (e) { items.toggle(e.shiftKey === true); };
group.children('a.dropdown-toggle')
.on('mousedown', apply)
.on('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { apply(e); } });
};
/* 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
+2 -1
View File
@@ -1,4 +1,5 @@
/* "Specialties" — admin-only page (URL /specialties, menu Administration → Spezialfunktionen)
/* "Specialties" — admin-only page (URL /specialties, menu Administration → Spezialfunktionen;
the menu entry is only visible when the dropdown is opened with Shift held, see fis_main_menu.js)
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`. */
+23 -3
View File
@@ -3248,7 +3248,13 @@ $fis.draft = {
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. */
the initial main menu) is ready. Returns a promise that settles once the menu is final.
Systemüberwachung and Spezialfunktionen are hidden entries: they are only shown while the
Administration dropdown is opened with the Shift key held (Shift+click on the toggle). A plain
click hides them again, so the diagnostics/maintenance pages are never one accidental click
away. The items still exist in the menu array, so the /admin and /specialties deep links
(openModuleFromPath) keep working. */
$fis.addAdminMenuButtonsIfAuthorized = function () {
if ($('#m_admin_grp').length > 0) { return Promise.resolve(); } // idempotent
return $fis.getAuth('fds_sys').then(function (auth) {
@@ -3258,18 +3264,32 @@ $fis.addAdminMenuButtonsIfAuthorized = function () {
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_specialties, id: 'm_specialties', fnc: 'init:specialties', ico: 'glyphicon glyphicon-wrench' }
{ lbl: $t.m_admin_status, id: 'm_admin', fnc: 'init:admin', ico: 'glyphicon glyphicon-dashboard', lclass: 'shift-only' },
{ lbl: $t.m_specialties, id: 'm_specialties', fnc: 'init:specialties', ico: 'glyphicon glyphicon-wrench', lclass: 'shift-only' }
]
});
/* Re-render the main menu cleanly: drop the previously generated menu list
(never the .nav-right settings dropdown) and rebuild it from the array. */
$('#mainmenu').children('ul:not(.nav-right)').remove();
$('#mainmenu').ocmsmenu($ocms.ocmsmenu);
$fis.bindShiftOnlyMenuItems($('#m_admin_grp'));
}
}).catch(function () { /* auth lookup failed → leave the menu unchanged */ });
};
/* Hides every `.shift-only` entry of a dropdown group and reveals them only for an opening
initiated with Shift held. Bound on mousedown (fires before Bootstrap's click toggle, so the
list is already in the right state when the dropdown becomes visible) and on keydown for
keyboard users (Shift+Enter / Shift+Space on the focused toggle). */
$fis.bindShiftOnlyMenuItems = function (group) {
let items = group.find('.shift-only').hide();
if (items.length === 0) { return; }
let apply = function (e) { items.toggle(e.shiftKey === true); };
group.children('a.dropdown-toggle')
.on('mousedown', apply)
.on('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { apply(e); } });
};
/* 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
+2 -2
View File
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -25,7 +25,8 @@ let $spt = {
}
};
/* "Specialties" — admin-only page (URL /specialties, menu Administration → Spezialfunktionen)
/* "Specialties" — admin-only page (URL /specialties, menu Administration → Spezialfunktionen;
the menu entry is only visible when the dropdown is opened with Shift held, see fis_main_menu.js)
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`. */
File diff suppressed because one or more lines are too long