// Package retentionengine implementiert RET-02: Fristenmodell je // Aufbewahrungsklasse mit Stichtagsberechnung und ein periodischer Job, // der ablaufende Objekte ermittelt. Baut auf RET-01 (retention_objects, // retention_class_assignments) auf — kennt weiter keine Modul-Interna // (dieselbe Adapter-Disziplin). package retentionengine import ( "context" "fmt" "time" "github.com/jackc/pgx/v5/pgxpool" ) // ConfigureClassRule legt die Frist (Postgres-INTERVAL, z. B. "10 years", // "6 months") für eine Aufbewahrungsklasse fest oder ändert sie // (Akzeptanzkriterium 1) — je Klasse GENAU eine aktive Regel. func ConfigureClassRule(ctx context.Context, pool *pgxpool.Pool, retentionClass, duration string) error { _, err := pool.Exec(ctx, ` INSERT INTO retention_class_rules (retention_class, duration) VALUES ($1, $2::interval) ON CONFLICT (retention_class) DO UPDATE SET duration = EXCLUDED.duration `, retentionClass, duration) if err != nil { return fmt.Errorf("retentionengine: fristregel konfigurieren: %w", err) } return nil } // ComputeDueDate berechnet den Stichtag aus Beginn (start) und der // konfigurierten Frist der Klasse — DELEGIERT an Postgres' eigene // 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 AND r.active `, start, retentionClass).Scan(&due) if err != nil { return time.Time{}, fmt.Errorf("retentionengine: stichtag berechnen: %w", err) } 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 ObjectType string ObjectReference string RetentionClass string DueDate time.Time } // ListExpiringObjects ist der periodische Job (Akzeptanzkriterium 3): // liefert alle aktiven Retention-Objekte, deren Stichtag (aktuelle // Klassenzuordnung + deren Frist) bis asOf erreicht ist. Betrachtet je // Objekt AUSSCHLIESSLICH die JÜNGSTE Klassenzuordnung (`DISTINCT ON`) - // ohne diese Einschränkung würde ein Objekt mit mehrfach geänderter // Klasse (RET-01s Historisierung) mehrfach im Ergebnis auftauchen, // genau der Doppelte-Einträge-Fehler, den Pflichtprüfung 3 ausschließt. // Ein leerer Bestand liefert eine leere Liste, keinen Fehler // (Akzeptanzkriterium/Pflichtprüfung 2). func ListExpiringObjects(ctx context.Context, pool *pgxpool.Pool, asOf time.Time) ([]ExpiringObject, error) { rows, err := pool.Query(ctx, ` WITH latest_assignment AS ( SELECT DISTINCT ON (retention_object_id) retention_object_id, retention_class, assigned_at FROM retention_class_assignments ORDER BY retention_object_id, assigned_at DESC ) SELECT o.id, o.object_type, o.object_reference, a.retention_class, 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 AND r.active WHERE o.status = 'active' AND (a.assigned_at + r.duration) <= $1 ORDER BY due_date ASC `, asOf) if err != nil { return nil, fmt.Errorf("retentionengine: ablaufende objekte ermitteln: %w", err) } defer rows.Close() var out []ExpiringObject for rows.Next() { var e ExpiringObject if err := rows.Scan(&e.RetentionObjectID, &e.ObjectType, &e.ObjectReference, &e.RetentionClass, &e.DueDate); err != nil { return nil, fmt.Errorf("retentionengine: zeile lesen: %w", err) } out = append(out, e) } return out, rows.Err() }