diff --git a/backend/tests/BigRegister.Tests/StamdataValidationTests.cs b/backend/tests/BigRegister.Tests/StamdataValidationTests.cs index 60f8c8f..eadda22 100644 --- a/backend/tests/BigRegister.Tests/StamdataValidationTests.cs +++ b/backend/tests/BigRegister.Tests/StamdataValidationTests.cs @@ -11,14 +11,31 @@ namespace BigRegister.Tests; /// public class StamdataValidationTests { - [Fact] - public void Every_seeded_diploma_program_maps_to_a_known_profession() + /// Declared references INTO stamdata keys — the FK-like invariants the build gate enforces + /// (WP-48). Add an entry when a consumer starts depending on a stamdata key; the gate then + /// fails a delete/rename/expire that orphans it. Resolvers use the "valid today" views, so + /// expiring a row (geldigTot in the past) that current data still references also fails — + /// which steers the editor toward closing validity only once nothing current relies on it. + private sealed record StamdataRef(string Description, IEnumerable Keys, Func Resolves); + + private static readonly IReadOnlyList References = new[] { - // The dangling-reference guard: a seed program with no entry in Professions would - // silently render "Onbekend" to the user. Fail the build instead. - foreach (var d in SeedData.Diplomas) - Assert.True(DiplomaRules.ProfessionFor(d) != "Onbekend", - $"Diploma program '{d.Opleiding}' has no profession in Stamdata.Professions."); + new StamdataRef( + "Diploma.Opleiding → professions.program (valid today)", + SeedData.Diplomas.Select(d => d.Opleiding), + key => Professions.ByProgram.ContainsKey(key)), + }; + + [Fact] + public void Every_declared_reference_into_stamdata_resolves() + { + // The dangling-reference guard (generalized): a referenced key with no (currently valid) + // stamdata row would silently break its consumer. Fail the build instead of prod. + foreach (var r in References) + foreach (var key in r.Keys) + Assert.True(r.Resolves(key), + $"Dangling stamdata reference [{r.Description}]: '{key}' no longer resolves — " + + "deleting or expiring the referenced row would break it."); } [Fact] diff --git a/docs/project/backlog/README.md b/docs/project/backlog/README.md index 57232fb..944042f 100644 --- a/docs/project/backlog/README.md +++ b/docs/project/backlog/README.md @@ -92,6 +92,7 @@ for its existing violations, so every WP ends green. | [WP-45](WP-45-create-ssp-generator.md) | `create-ssp` bootstrap generator (mechanise new-ssp) | 8 · platform/DX/showcase | todo | | [WP-46](WP-46-vitest-coverage.md) | Vitest coverage (report + report-only thresholds) | 8 · platform/DX/showcase | done | | [WP-47](WP-47-feature-flags.md) | Runtime feature flags (catalog-in-code, admin toggle, FE+backend) | 8 · platform/DX/showcase | done | +| [WP-48](WP-48-stamdata-deletion-protection.md) | Stamdata deletion protection (CI referential gate + editor expire/warn) | 8 · platform/DX/showcase | done | Sequencing dependencies (stated in the WPs too): 01 before 10–15 (axe covers story churn); 03/04 before 05–09 (boundaries stop new violations during refactors); 06 before 07 (typed diff --git a/docs/project/backlog/WP-48-stamdata-deletion-protection.md b/docs/project/backlog/WP-48-stamdata-deletion-protection.md new file mode 100644 index 0000000..a59aedd --- /dev/null +++ b/docs/project/backlog/WP-48-stamdata-deletion-protection.md @@ -0,0 +1,40 @@ +# WP-48 — Stamdata deletion protection (referential integrity) + +Status: done +Phase: 8 — platform/DX/showcase + +## Why + +Deleting a stamdata row that something relies on (e.g. a `professions.program` a diploma maps +through) would silently break behaviour. Stamdata is config-as-code (PR-applied, CI-gated), so the +authoritative guard belongs at the build gate; the editor gets a fast-feedback nudge. + +## Decisions (locked with the user) + +- **CI gate (authoritative) + editor warning (fast feedback).** +- **Steer temporal rows toward expiring** (set `geldigTot`) over hard delete. + +## Outcome + +- **CI gate:** generalized the dangling-reference test in `StamdataValidationTests` into a declared, + extensible reference list (`StamdataRef` records) — "every declared reference into a stamdata key + resolves against the currently-valid stamdata." Today one entry: `Diploma.Opleiding → +professions.program (valid today)`. Resolvers use the "valid today" view (`Professions.ByProgram`), + so removing/renaming a referenced program OR expiring it while current data still references it + **fails the PR build**; expiring once nothing current relies on it passes. Adding a future FK is + one list entry. +- **Editor (fast feedback):** `stamdata-table-editor` now confirms before delete (`@@beheer.removeConfirm` + — warns that a referenced row fails CI and, for a dated table, to close validity instead) and, for + **temporal** tables, adds a **"Sluiten per vandaag"** action that sets `geldigTot` to today + (reusing `CellEdited`) — steering to expire over hard delete. CI stays the authority. + +## Acceptance criteria + +- [x] A delete/expire that orphans a declared reference fails the build gate (existing seed passes). +- [x] Editor confirms deletes and offers expire (close validity) for temporal tables. +- [x] `npm run ci` green (backend `dotnet test`, localized build). + +## Deferred (noted) + +A per-row "referenced" hint in the editor DTO (server-computed usage) — would let the editor warn on +the _specific_ referenced rows rather than a generic confirm. Not needed for the authoritative gate. diff --git a/src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts b/src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts index b312532..66b3671 100644 --- a/src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts +++ b/src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts @@ -145,10 +145,15 @@ interface DisplayRow { } @if (!previewing()) { + @if (table().temporal) { + {{ + expireLabel + }} + } {{ removeLabel }} } @@ -234,6 +239,21 @@ export class StamdataTableEditorComponent { protected previewNote = $localize`:@@beheer.previewNote:Voorbeeld: alleen de rijen die op deze datum geldig zijn. Bewerken staat uit.`; protected actionsLabel = $localize`:@@beheer.actions:Acties`; protected removeLabel = $localize`:@@beheer.remove:Verwijderen`; + protected expireLabel = $localize`:@@beheer.expire:Sluiten per vandaag`; + private removeConfirm = $localize`:@@beheer.removeConfirm:Rij verwijderen? Als andere gegevens ernaar verwijzen, faalt de build-controle (CI). Bij een tabel met een geldigheidsperiode kunt u de rij beter sluiten (geldig tot) in plaats van verwijderen.`; + + /** Deletions can orphan a reference (the CI gate catches it); confirm first (WP-48). */ + protected onRemove(index: number) { + if (confirm(this.removeConfirm)) this.rowRemoved.emit(index); + } + + /** Steer temporal tables toward expiring (close the validity per today) over hard delete — + preserves history and can't orphan a reference that was valid earlier (WP-48). */ + protected onExpire(index: number) { + const col = this.table().columns.find((c) => /geldigtot/i.test(c.name)); + if (col) this.cellEdited.emit({ row: index, column: col.name, value: this.today }); + } + private today = new Date().toISOString().slice(0, 10); protected undoLabel = $localize`:@@beheer.undo:Ongedaan maken`; protected redoLabel = $localize`:@@beheer.redo:Opnieuw uitvoeren`; protected addRowLabel = $localize`:@@beheer.addRow:Rij toevoegen`; diff --git a/src/locale/messages.en.xlf b/src/locale/messages.en.xlf index e4233cc..0344a8f 100644 --- a/src/locale/messages.en.xlf +++ b/src/locale/messages.en.xlf @@ -3016,6 +3016,14 @@ 226 + + Sluiten per vandaag + Close as of today + + + Rij verwijderen? Als andere gegevens ernaar verwijzen, faalt de build-controle (CI). Bij een tabel met een geldigheidsperiode kunt u de rij beter sluiten (geldig tot) in plaats van verwijderen. + Delete this row? If other data references it, the build check (CI) will fail. For a table with a validity period, prefer closing the row (valid until) over deleting it. + Rij toevoegen Add row diff --git a/src/locale/messages.xlf b/src/locale/messages.xlf index d2385f6..77f7ba3 100644 --- a/src/locale/messages.xlf +++ b/src/locale/messages.xlf @@ -245,98 +245,112 @@ toegevoegd src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts - 223 + 228 gewijzigd src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts - 224 + 229 verwijderd src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts - 225 + 230 Tabel src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts - 231 + 236 Toon geldig op src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts - 232 + 237 Toon alles src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts - 233 + 238 Voorbeeld: alleen de rijen die op deze datum geldig zijn. Bewerken staat uit. src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts - 234 + 239 Acties src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts - 235 + 240 Verwijderen src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts - 236 + 241 + + + + Sluiten per vandaag + + src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts + 242 + + + + Rij verwijderen? Als andere gegevens ernaar verwijzen, faalt de build-controle (CI). Bij een tabel met een geldigheidsperiode kunt u de rij beter sluiten (geldig tot) in plaats van verwijderen. + + src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts + 243 Ongedaan maken src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts - 237 + 257 Opnieuw uitvoeren src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts - 238 + 258 Rij toevoegen src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts - 239 + 259 Download JSON src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts - 240 + 260 Wijzigingen worden als JSON-bestand gedownload en via een pull request toegepast — de build (CI) controleert ze. src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts - 241 + 261