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.
143 lines
5.6 KiB
Go
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()
|
|
}
|