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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user