feat(archive): RET-06-API Aufbewahrungsfristen-Konfigurations-Backend

Board-Entscheidung: Backend-API zuerst, echtes Next.js-Frontend als
separates Folgeticket - vermeidet Pseudo-Frontend-Protokoll.
internal/retentionapi: 4 Endpunkte (anlegen/aendern, deaktivieren,
liste, vorschau), Vorschau nutzt dieselbe ListExpiringObjects-Funktion
wie RET-02s periodischer Job (keine Doppel-Implementierung).
RequireRole ist AUSDRUECKLICH kein RBAC-02-Ersatz, sondern ein
dokumentiertes Provisorium (Header-Check) - RBAC-02 ist reiner
Core-interner Go-Code ohne HTTP-Schnittstelle fuer andere Module,
derselbe Befund wie FDN-03/FDN-09. Provisorium real getestet inkl.
Negativfall (403 ohne/mit falscher Rolle). retention_class_rules um
active-Flag erweitert (deaktivieren ohne Historienverlust). Real auf
131 deployed und per curl end-to-end verifiziert.
This commit is contained in:
sysops
2026-08-30 02:09:37 +02:00
parent 2c9a7482b6
commit 21278f1405
11 changed files with 644 additions and 3 deletions
@@ -33,11 +33,12 @@ func ConfigureClassRule(ctx context.Context, pool *pgxpool.Pool, retentionClass,
// INTERVAL-Arithmetik (Akzeptanzkriterium 2: korrekt inklusive
// Schaltjahr/Monatsende), keine eigene Kalenderrechnung in Go, die von
// Postgres' späterer WHERE-Klausel im periodischen Job abweichen könnte.
// Nur AKTIVE Regeln werden verwendet (siehe DeactivateClassRule).
func ComputeDueDate(ctx context.Context, pool *pgxpool.Pool, start time.Time, retentionClass string) (time.Time, error) {
var due time.Time
err := pool.QueryRow(ctx, `
SELECT $1::timestamptz + r.duration
FROM retention_class_rules r WHERE r.retention_class = $2
FROM retention_class_rules r WHERE r.retention_class = $2 AND r.active
`, start, retentionClass).Scan(&due)
if err != nil {
return time.Time{}, fmt.Errorf("retentionengine: stichtag berechnen: %w", err)
@@ -45,6 +46,51 @@ func ComputeDueDate(ctx context.Context, pool *pgxpool.Pool, start time.Time, re
return due, nil
}
// DeactivateClassRule (RET-06): eine Aufbewahrungsklasse wird deaktiviert,
// OHNE ihre Historie (bereits erfolgte Zuordnungen/Berechnungen) zu
// verlieren — kein DELETE. Deaktivierte Klassen fließen nicht mehr in
// ComputeDueDate/ListExpiringObjects ein, ändern aber nichts an bereits
// getroffenen Berechnungen (Pflichtprüfung: Änderung wirkt nur auf
// künftige Berechnungen, nicht rückwirkend).
func DeactivateClassRule(ctx context.Context, pool *pgxpool.Pool, retentionClass string) error {
tag, err := pool.Exec(ctx, `UPDATE retention_class_rules SET active = false WHERE retention_class = $1`, retentionClass)
if err != nil {
return fmt.Errorf("retentionengine: klasse deaktivieren: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("retentionengine: unbekannte aufbewahrungsklasse %q", retentionClass)
}
return nil
}
// ClassRule ist EINE konfigurierte Aufbewahrungsklasse mit Frist und
// Aktiv-Status.
type ClassRule struct {
RetentionClass string
Duration string
Active bool
}
// ListClassRules liefert alle konfigurierten Aufbewahrungsklassen
// (aktiv und deaktiviert) — Grundlage für die Konfigurationsoberfläche.
func ListClassRules(ctx context.Context, pool *pgxpool.Pool) ([]ClassRule, error) {
rows, err := pool.Query(ctx, `SELECT retention_class, duration::text, active FROM retention_class_rules ORDER BY retention_class`)
if err != nil {
return nil, fmt.Errorf("retentionengine: aufbewahrungsklassen auflisten: %w", err)
}
defer rows.Close()
var rules []ClassRule
for rows.Next() {
var r ClassRule
if err := rows.Scan(&r.RetentionClass, &r.Duration, &r.Active); err != nil {
return nil, fmt.Errorf("retentionengine: klassen-zeile lesen: %w", err)
}
rules = append(rules, r)
}
return rules, rows.Err()
}
// ExpiringObject ist EIN Objekt, dessen Aufbewahrungsfrist erreicht ist.
type ExpiringObject struct {
RetentionObjectID string
@@ -75,7 +121,7 @@ func ListExpiringObjects(ctx context.Context, pool *pgxpool.Pool, asOf time.Time
a.assigned_at + r.duration AS due_date
FROM retention_objects o
JOIN latest_assignment a ON a.retention_object_id = o.id
JOIN retention_class_rules r ON r.retention_class = a.retention_class
JOIN retention_class_rules r ON r.retention_class = a.retention_class AND r.active
WHERE o.status = 'active' AND (a.assigned_at + r.duration) <= $1
ORDER BY due_date ASC
`, asOf)
@@ -37,7 +37,8 @@ func requireTestPool(t *testing.T) *pgxpool.Pool {
retention_class TEXT NOT NULL, assigned_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS retention_class_rules (
retention_class TEXT PRIMARY KEY, duration INTERVAL NOT NULL
retention_class TEXT PRIMARY KEY, duration INTERVAL NOT NULL,
active BOOLEAN NOT NULL DEFAULT true
);
`); err != nil {
t.Fatalf("schema: %v", err)
@@ -105,6 +106,66 @@ func TestComputeDueDate_KnownReferenceDates(t *testing.T) {
}
}
// TestConfigureClassRule_ChangeAppliesOnlyToFutureCalculations ist
// Pruefung fuer RET-06: eine Fristaenderung wirkt sich nur auf
// KUENFTIGE Berechnungen aus, nicht rueckwirkend - real bewiesen, indem
// ein bereits berechneter Stichtag (in einer lokalen Variable, dem
// einzigen Ort, an dem ein "bereits berechnetes" Ergebnis ueberhaupt
// existiert - es gibt keine Tabelle, die rueckwirkend veraendert werden
// koennte) nach der Regelaenderung unveraendert bleibt, waehrend eine
// NEUE Berechnung fuer dieselbe Klasse die NEUE Frist verwendet.
func TestConfigureClassRule_ChangeAppliesOnlyToFutureCalculations(t *testing.T) {
pool := requireTestPool(t)
ctx := context.Background()
start := mustTime(t, "2006-01-02", "2026-01-01")
if err := ConfigureClassRule(ctx, pool, "klasse-aenderung", "1 year"); err != nil {
t.Fatal(err)
}
before, err := ComputeDueDate(ctx, pool, start, "klasse-aenderung")
if err != nil {
t.Fatalf("erste berechnung: %v", err)
}
if err := ConfigureClassRule(ctx, pool, "klasse-aenderung", "2 years"); err != nil {
t.Fatal(err)
}
// Der bereits berechnete Wert (before) bleibt unveraendert - er ist
// eine lokale Kopie, es existiert keine Tabelle, die eine
// nachtraegliche "Umschreibung" ermoeglichen wuerde.
if !before.Equal(mustTime(t, "2006-01-02", "2027-01-01")) {
t.Fatalf("bereits berechneter stichtag veraendert: %v", before)
}
after, err := ComputeDueDate(ctx, pool, start, "klasse-aenderung")
if err != nil {
t.Fatalf("zweite berechnung: %v", err)
}
if !after.Equal(mustTime(t, "2006-01-02", "2028-01-01")) {
t.Fatalf("neue berechnung uebernimmt neue frist nicht: %v", after)
}
if before.Equal(after) {
t.Fatal("neue frist haette eine andere berechnung liefern muessen")
}
}
// TestDeactivateClassRule_ExcludesFromFutureCalculations.
func TestDeactivateClassRule_ExcludesFromFutureCalculations(t *testing.T) {
pool := requireTestPool(t)
ctx := context.Background()
if err := ConfigureClassRule(ctx, pool, "klasse-deakt-eng", "1 year"); err != nil {
t.Fatal(err)
}
if err := DeactivateClassRule(ctx, pool, "klasse-deakt-eng"); err != nil {
t.Fatalf("deactivateclassrule: %v", err)
}
if _, err := ComputeDueDate(ctx, pool, time.Now(), "klasse-deakt-eng"); err == nil {
t.Fatal("erwartet fehler: deaktivierte klasse darf nicht mehr verwendet werden")
}
}
// TestListExpiringObjects_EmptyBacklogReturnsEmptyNotError ist Pruefung 2.
func TestListExpiringObjects_EmptyBacklogReturnsEmptyNotError(t *testing.T) {
pool := requireTestPool(t)