- migrations/0009_data_subject_ref: additive nullable Spalte auf retention_objects, schliesst die Luecke fuer 'betroffene Person' in RET-01 - retention.RegisterObjectForSubject: NEUE additive Funktion, RegisterObject selbst unveraendert (kein Diff, keine Produktionsaufrufer betroffen) - archive/internal/dpreport: SubjectReport (Auskunftsbericht), ProcessingOverview (Verarbeitungsuebersicht, statisch gepflegte Zweck/Rechtsgrundlage je Objekttyp), WriteSubjectReportCSV - Mandantentrennung strukturell durch Modell C (ein Pool pro Tenant), real mit zwei physisch getrennten Tenant-DBs bewiesen (tenant_acme/ tenant_globex), nicht nur behauptet - 4 Tests, alle Pflichtpruefungen real bestanden - Migration real auf dms_tenant_test angewendet Pruefungen siehe archive/docs/CMP-02-PRUEFPROTOKOLL.md
126 lines
4.7 KiB
Go
126 lines
4.7 KiB
Go
// Package retention implementiert RET-01: ein generisches Datenmodell
|
|
// für aufbewahrungspflichtige Objekte, modulübergreifend über Adapter
|
|
// (Objekttyp + Objekt-Referenz als reine Textfelder) — Archive kennt die
|
|
// Fachobjekte anderer Module (DMS, Mail) nicht im Detail, nur ihren Typ
|
|
// und ihre Referenz. Keine Fremdschlüssel auf modulspezifische Tabellen,
|
|
// damit ein neues Modul retention-pflichtige Objekte einbinden kann,
|
|
// ohne dieses Paket zu ändern.
|
|
package retention
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// Status eines Retention-Objekts.
|
|
type Status string
|
|
|
|
const (
|
|
StatusActive Status = "active"
|
|
StatusExpired Status = "expired"
|
|
StatusDeleted Status = "deleted"
|
|
)
|
|
|
|
// RegisterObject registriert ein Objekt eines beliebigen Moduls unter
|
|
// seinem Typ+Referenz — idempotent (ON CONFLICT), ein Adapter kann ein
|
|
// bereits bekanntes Objekt gefahrlos erneut registrieren
|
|
// (Akzeptanzkriterium 1: bildet beliebige Objekttypen ab, ohne
|
|
// modulspezifische Spalten).
|
|
func RegisterObject(ctx context.Context, pool *pgxpool.Pool, objectType, objectReference string) (string, error) {
|
|
var id string
|
|
err := pool.QueryRow(ctx, `
|
|
INSERT INTO retention_objects (object_type, object_reference)
|
|
VALUES ($1, $2)
|
|
ON CONFLICT (object_type, object_reference) DO UPDATE SET object_type = EXCLUDED.object_type
|
|
RETURNING id
|
|
`, objectType, objectReference).Scan(&id)
|
|
if err != nil {
|
|
return "", fmt.Errorf("retention: objekt registrieren: %w", err)
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
// RegisterObjectForSubject ist CMP-02s additive Ergänzung zu
|
|
// RegisterObject: registriert das Objekt zusätzlich mit einer Referenz
|
|
// auf die betroffene Person (dataSubjectRef, z. B. E-Mail oder
|
|
// User-ID), Grundlage für den DSGVO-Auskunftsbericht. Leeres
|
|
// dataSubjectRef bedeutet: nicht personenbezogen, kein Fehler.
|
|
// RegisterObject selbst bleibt unverändert (kein Umbau bestehenden
|
|
// Verhaltens) — dies ist ein separater, additiver Registrierungsweg.
|
|
func RegisterObjectForSubject(ctx context.Context, pool *pgxpool.Pool, objectType, objectReference, dataSubjectRef string) (string, error) {
|
|
var id string
|
|
err := pool.QueryRow(ctx, `
|
|
INSERT INTO retention_objects (object_type, object_reference, data_subject_ref)
|
|
VALUES ($1, $2, NULLIF($3, ''))
|
|
ON CONFLICT (object_type, object_reference)
|
|
DO UPDATE SET data_subject_ref = COALESCE(NULLIF(EXCLUDED.data_subject_ref, ''), retention_objects.data_subject_ref)
|
|
RETURNING id
|
|
`, objectType, objectReference, dataSubjectRef).Scan(&id)
|
|
if err != nil {
|
|
return "", fmt.Errorf("retention: objekt mit betroffener person registrieren: %w", err)
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
// Assignment ist EINE historische Zuordnung einer Aufbewahrungsklasse.
|
|
type Assignment struct {
|
|
RetentionClass string
|
|
AssignedAt time.Time
|
|
}
|
|
|
|
// AssignClass ordnet einem Retention-Objekt eine neue Aufbewahrungsklasse
|
|
// zu — fügt IMMER eine neue Zeile hinzu, ändert nie eine bestehende
|
|
// (Akzeptanzkriterium 2: historisierbar).
|
|
func AssignClass(ctx context.Context, pool *pgxpool.Pool, retentionObjectID, retentionClass string) error {
|
|
_, err := pool.Exec(ctx, `
|
|
INSERT INTO retention_class_assignments (retention_object_id, retention_class)
|
|
VALUES ($1, $2)
|
|
`, retentionObjectID, retentionClass)
|
|
if err != nil {
|
|
return fmt.Errorf("retention: aufbewahrungsklasse zuordnen: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CurrentClass liefert die AKTUELLE Aufbewahrungsklasse (jüngste
|
|
// Zuordnung) eines Retention-Objekts.
|
|
func CurrentClass(ctx context.Context, pool *pgxpool.Pool, retentionObjectID string) (Assignment, error) {
|
|
var a Assignment
|
|
err := pool.QueryRow(ctx, `
|
|
SELECT retention_class, assigned_at FROM retention_class_assignments
|
|
WHERE retention_object_id = $1
|
|
ORDER BY assigned_at DESC LIMIT 1
|
|
`, retentionObjectID).Scan(&a.RetentionClass, &a.AssignedAt)
|
|
if err != nil {
|
|
return Assignment{}, fmt.Errorf("retention: aktuelle aufbewahrungsklasse lesen: %w", err)
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
// ClassHistory liefert ALLE Zuordnungen eines Retention-Objekts,
|
|
// chronologisch aufsteigend — voller Nachvollzug der Historie.
|
|
func ClassHistory(ctx context.Context, pool *pgxpool.Pool, retentionObjectID string) ([]Assignment, error) {
|
|
rows, err := pool.Query(ctx, `
|
|
SELECT retention_class, assigned_at FROM retention_class_assignments
|
|
WHERE retention_object_id = $1
|
|
ORDER BY assigned_at ASC
|
|
`, retentionObjectID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("retention: klassenhistorie lesen: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var history []Assignment
|
|
for rows.Next() {
|
|
var a Assignment
|
|
if err := rows.Scan(&a.RetentionClass, &a.AssignedAt); err != nil {
|
|
return nil, fmt.Errorf("retention: historien-zeile lesen: %w", err)
|
|
}
|
|
history = append(history, a)
|
|
}
|
|
return history, rows.Err()
|
|
}
|