feat(PROJ-51): Aufbewahrungsfristen nach Dokumentenart (Retention-Kategorien)

Fuehrt archiving_rules ein (PROJ-43-Basis: Tabelle + CRUD-API + Admin-UI) und
erweitert die Retention-Logik (PROJ-34) um Regel-basierte Fristen, eine
globale Mindestfrist (min_retention_days) sowie Nachvollziehbarkeit der
Frist-Quelle (retain_until_source) in API und Mail-Detailansicht.
This commit is contained in:
sysops
2026-06-13 20:48:16 +02:00
parent 7c08ebe1b7
commit 507dee6431
16 changed files with 1175 additions and 21 deletions
+36
View File
@@ -47,6 +47,7 @@ func runStatus(args []string) {
checkStorage(cfg),
checkEncryption(cfg),
checkAuditLog(cfg),
checkRetention(cfg),
}
allOK := true
@@ -227,6 +228,41 @@ func checkAuditLog(cfg *config.Config) checkResult {
return checkResult{Name: "Audit-Log", OK: true, Detail: detail}
}
// checkRetention warns (PROJ-51) when effectively no deletion lock is active:
// global retention_days = 0 AND min_retention_days = 0 AND no tenant with
// retention_days > 0 AND no archiving rule carrying a retention_days value.
// Like the encryption check it never reports a hard error (OK stays true), it
// only surfaces the GoBD-relevant state in the detail text.
func checkRetention(cfg *config.Config) checkResult {
global := cfg.Storage.RetentionDays
minRet := cfg.Storage.MinRetentionDays
if global > 0 || minRet > 0 {
return checkResult{Name: "Retention", OK: true,
Detail: fmt.Sprintf("Löschsperre aktiv (global=%d Tage, min=%d Tage)", global, minRet)}
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
pool, err := pgxpool.New(ctx, cfg.Database.DSN())
if err != nil {
return checkResult{Name: "Retention", OK: true,
Detail: "DB nicht erreichbar — Tenant-/Regel-Aufbewahrung nicht prüfbar"}
}
defer pool.Close()
var tenantLocks, ruleLocks int
_ = pool.QueryRow(ctx, `SELECT COUNT(*) FROM tenants WHERE retention_days > 0`).Scan(&tenantLocks)
_ = pool.QueryRow(ctx, `SELECT COUNT(*) FROM archiving_rules WHERE retention_days IS NOT NULL`).Scan(&ruleLocks)
if tenantLocks > 0 || ruleLocks > 0 {
return checkResult{Name: "Retention", OK: true,
Detail: fmt.Sprintf("Löschsperre aktiv (%d Mandant(en), %d Regel(n) mit Aufbewahrung)", tenantLocks, ruleLocks)}
}
return checkResult{Name: "Retention", OK: true,
Detail: "WARNUNG — keine Löschsperre aktiv: retention_days=0, min_retention_days=0, keine Mandanten-/Regel-Aufbewahrung (GoBD-relevant)"}
}
func formatBytes(b uint64) string {
const unit = 1024
if b < unit {
+9 -2
View File
@@ -124,8 +124,9 @@ func main() {
Dir: cfg.Storage.StorePath,
Keyfile: cfg.Storage.Keyfile,
DSN: cfg.Database.DSN(),
RetentionDays: cfg.Storage.RetentionDays,
CompressEnabled: cfg.Storage.Compress,
RetentionDays: cfg.Storage.RetentionDays,
MinRetentionDays: cfg.Storage.MinRetentionDays,
CompressEnabled: cfg.Storage.Compress,
}
mailStore, err := storage.New(storeCfg)
if err != nil {
@@ -556,6 +557,12 @@ func runBackfill(ctx context.Context, store *storage.Store, idx index.Indexer, w
logger.Warn("backfill: save meta failed", "id", id, "err", err)
}
// PROJ-51: (re-)apply retention for mails without a retain_until yet.
// Only sets a value when retain_until IS NULL — never shortens an
// existing lock (no retroactive shortening per spec).
tenantID, _ := store.GetTenantForMail(ctx, id)
store.ApplyRetentionBackfill(ctx, id, pm, tenantID)
// Check if already indexed
alreadyIndexed, err := store.IsIndexed(ctx, id)
if err != nil {