Files
nexarch/archive/internal/retentionengine/retentionengine.go
T
sysops 21278f1405 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.
2026-08-30 02:09:37 +02:00

143 lines
5.6 KiB
Go

// 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()
}