Files
patrick 9a24ea29e1 FDN-01: repository & projektgerüst
Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
2026-08-11 21:27:53 +02:00

534 lines
20 KiB
Go

// GoBD-Aufbewahrungsregeln (retention rules / "Disposition Schedules").
//
// Naming/model reference: Alfresco's Disposition-Schedule terminology
// (trigger -> retention period -> disposition), but NOT its architecture —
// archivdms stays single-binary/Postgres. A retention rule declares, per
// document type (or tenant-wide as a default with doc_type_id IS NULL), HOW
// LONG a document must be kept and from WHICH base date the retention period
// starts counting. The batch job ApplyRetentionRules computes and SETS
// documents.retain_until from the matching rule — it never hard-deletes and
// never shortens an existing lock. Actual disposition (final deletion) still
// runs through the existing Papierkorb + Vier-Augen delete-request flow in
// trash.go, which re-checks retain_until before allowing deletion.
//
// State model (deliberately reusing existing fields, no new status table):
// - "locked" : documents.retain_until IS NOT NULL AND >= now()
// (WORM lock, blocks CreateDeleteRequest/Confirm)
// - "eligible_for_disposition": retain_until IS NOT NULL AND < now() AND
// deleted_at IS NULL (see ListEligibleForDisposition)
// - disposition proper : handled by trash.go once a human soft-deletes
// the document and starts the Vier-Augen request.
package storage
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// ErrRetentionRuleNotFound is returned when a tenant-scoped retention-rule
// lookup/update/delete affects zero rows (wrong id or wrong tenant).
var ErrRetentionRuleNotFound = errors.New("storage: retention rule not found for tenant")
// Trigger types: how the retention base date is derived for a document.
const (
// RetentionTriggerDocumentDate uses documents.document_date (belegdatum),
// falling back to created_at when no document_date is set.
RetentionTriggerDocumentDate = "document_date"
// RetentionTriggerUploadDate uses documents.created_at (scan/upload time).
RetentionTriggerUploadDate = "upload_date"
// RetentionTriggerFixedDate uses trigger_reference parsed as 2006-01-02 as
// the base date for every matching document (one-time legal cutoff).
RetentionTriggerFixedDate = "fixed_date"
// RetentionTriggerEvent marks event-based retention (e.g. Geschäftsjahres-
// ende / Vertragsende). NOT auto-computed by ApplyRetentionRules — the
// system does not yet observe such events. Documents matched by an event
// rule are skipped (retain_until left NULL). This is the known future
// extension point.
RetentionTriggerEvent = "event"
)
// RetentionRule is a per-tenant GoBD retention policy. doc_type_id NULL makes
// it the tenant-wide default rule (lowest precedence). A UNIQUE(tenant_id,
// doc_type_id) constraint guarantees at most one active rule per (tenant,
// doc_type), so no "strictest wins" tie-break logic is needed.
type RetentionRule struct {
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
DocTypeID *int64 `json:"doc_type_id,omitempty"`
Name string `json:"name"`
TriggerType string `json:"trigger_type"`
TriggerReference string `json:"trigger_reference,omitempty"`
RetentionYears *int `json:"retention_years,omitempty"`
RetentionDays *int `json:"retention_days,omitempty"`
LegalBasis string `json:"legal_basis,omitempty"`
RequiresApprovalForDestroy bool `json:"requires_approval_for_destroy"`
DSGVOConflict bool `json:"dsgvo_conflict"`
Active bool `json:"active"`
CreatedBy *int64 `json:"created_by,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// RetentionPreview is one line of a dry-run: which retain_until WOULD be set
// on which document by which rule, without writing anything.
type RetentionPreview struct {
DocumentID int64 `json:"document_id"`
TenantID int64 `json:"tenant_id"`
RuleID int64 `json:"rule_id"`
RuleName string `json:"rule_name"`
RetainUntil *time.Time `json:"retain_until"`
}
func (s *Store) initRetentionRulesSchema(ctx context.Context) error {
_, err := s.db.Exec(ctx, `
CREATE TABLE IF NOT EXISTS retention_rules (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
doc_type_id BIGINT REFERENCES document_types(id) ON DELETE CASCADE,
name TEXT NOT NULL,
trigger_type TEXT NOT NULL
CHECK (trigger_type IN ('document_date','upload_date','fixed_date','event')),
trigger_reference TEXT NOT NULL DEFAULT '',
retention_years INT,
retention_days INT,
legal_basis TEXT NOT NULL DEFAULT '',
requires_approval_for_destroy BOOLEAN NOT NULL DEFAULT true,
dsgvo_conflict BOOLEAN NOT NULL DEFAULT false,
active BOOLEAN NOT NULL DEFAULT true,
created_by BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(tenant_id, doc_type_id)
);
CREATE INDEX IF NOT EXISTS idx_retention_rules_tenant ON retention_rules(tenant_id) WHERE active;
`)
if err != nil {
return fmt.Errorf("storage: create retention_rules table: %w", err)
}
return nil
}
const retentionRuleCols = `id, tenant_id, doc_type_id, name, trigger_type, trigger_reference,
retention_years, retention_days, legal_basis, requires_approval_for_destroy,
dsgvo_conflict, active, created_by, created_at, updated_at`
func scanRetentionRule(row pgx.Row, r *RetentionRule) error {
return row.Scan(&r.ID, &r.TenantID, &r.DocTypeID, &r.Name, &r.TriggerType, &r.TriggerReference,
&r.RetentionYears, &r.RetentionDays, &r.LegalBasis, &r.RequiresApprovalForDestroy,
&r.DSGVOConflict, &r.Active, &r.CreatedBy, &r.CreatedAt, &r.UpdatedAt)
}
// validateRetentionRule enforces the cross-field invariants that a CHECK
// constraint cannot express: non-event rules must carry at least one of
// retention_years/retention_days (otherwise retain_until would equal the base
// date, a misconfiguration), and fixed_date rules must carry a parseable
// trigger_reference date.
func validateRetentionRule(r RetentionRule) error {
if r.Name == "" {
return fmt.Errorf("retention rule: name is required")
}
switch r.TriggerType {
case RetentionTriggerDocumentDate, RetentionTriggerUploadDate, RetentionTriggerFixedDate:
yrs := 0
if r.RetentionYears != nil {
yrs = *r.RetentionYears
}
days := 0
if r.RetentionDays != nil {
days = *r.RetentionDays
}
if yrs <= 0 && days <= 0 {
return fmt.Errorf("retention rule: trigger_type %q requires retention_years and/or retention_days", r.TriggerType)
}
if r.TriggerType == RetentionTriggerFixedDate {
if _, err := time.Parse("2006-01-02", r.TriggerReference); err != nil {
return fmt.Errorf("retention rule: fixed_date trigger_reference must be YYYY-MM-DD: %w", err)
}
}
case RetentionTriggerEvent:
// event rules never auto-compute retain_until; retention_years/days are
// optional and only informational until the event-observation feature
// exists.
default:
return fmt.Errorf("retention rule: invalid trigger_type %q", r.TriggerType)
}
return nil
}
// CreateRetentionRule inserts a new retention rule for a tenant and returns it.
func (s *Store) CreateRetentionRule(ctx context.Context, tenantID int64, rule RetentionRule) (*RetentionRule, error) {
if err := validateRetentionRule(rule); err != nil {
return nil, err
}
var r RetentionRule
err := scanRetentionRule(s.db.QueryRow(ctx, `
INSERT INTO retention_rules
(tenant_id, doc_type_id, name, trigger_type, trigger_reference,
retention_years, retention_days, legal_basis, requires_approval_for_destroy,
dsgvo_conflict, active, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING `+retentionRuleCols,
tenantID, rule.DocTypeID, rule.Name, rule.TriggerType, rule.TriggerReference,
rule.RetentionYears, rule.RetentionDays, rule.LegalBasis, rule.RequiresApprovalForDestroy,
rule.DSGVOConflict, rule.Active, rule.CreatedBy), &r)
if err != nil {
return nil, fmt.Errorf("retention rules: create: %w", err)
}
return &r, nil
}
// ListRetentionRules returns all retention rules for a tenant, tenant-wide
// default (doc_type_id IS NULL) last.
func (s *Store) ListRetentionRules(ctx context.Context, tenantID int64) ([]RetentionRule, error) {
rows, err := s.db.Query(ctx, `
SELECT `+retentionRuleCols+`
FROM retention_rules WHERE tenant_id = $1
ORDER BY doc_type_id IS NULL, doc_type_id, id
`, tenantID)
if err != nil {
return nil, fmt.Errorf("retention rules: list: %w", err)
}
defer rows.Close()
out := make([]RetentionRule, 0)
for rows.Next() {
var r RetentionRule
if err := scanRetentionRule(rows, &r); err != nil {
return nil, fmt.Errorf("retention rules: scan: %w", err)
}
out = append(out, r)
}
return out, rows.Err()
}
// GetRetentionRule returns a single retention rule, tenant-scoped.
func (s *Store) GetRetentionRule(ctx context.Context, id, tenantID int64) (*RetentionRule, error) {
var r RetentionRule
err := scanRetentionRule(s.db.QueryRow(ctx, `
SELECT `+retentionRuleCols+`
FROM retention_rules WHERE id = $1 AND tenant_id = $2
`, id, tenantID), &r)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrRetentionRuleNotFound
}
if err != nil {
return nil, fmt.Errorf("retention rules: get: %w", err)
}
return &r, nil
}
// UpdateRetentionRule updates a retention rule in place, tenant-scoped.
func (s *Store) UpdateRetentionRule(ctx context.Context, id, tenantID int64, rule RetentionRule) (*RetentionRule, error) {
if err := validateRetentionRule(rule); err != nil {
return nil, err
}
var r RetentionRule
err := scanRetentionRule(s.db.QueryRow(ctx, `
UPDATE retention_rules SET
doc_type_id = $3,
name = $4,
trigger_type = $5,
trigger_reference = $6,
retention_years = $7,
retention_days = $8,
legal_basis = $9,
requires_approval_for_destroy = $10,
dsgvo_conflict = $11,
active = $12,
updated_at = now()
WHERE id = $1 AND tenant_id = $2
RETURNING `+retentionRuleCols,
id, tenantID, rule.DocTypeID, rule.Name, rule.TriggerType, rule.TriggerReference,
rule.RetentionYears, rule.RetentionDays, rule.LegalBasis, rule.RequiresApprovalForDestroy,
rule.DSGVOConflict, rule.Active), &r)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrRetentionRuleNotFound
}
if err != nil {
return nil, fmt.Errorf("retention rules: update: %w", err)
}
return &r, nil
}
// DeleteRetentionRule removes a retention rule, tenant-scoped. Deleting a rule
// does NOT retroactively clear retain_until on documents it previously locked
// (GoBD: a WORM lock, once set, is never shortened).
func (s *Store) DeleteRetentionRule(ctx context.Context, id, tenantID int64) error {
tag, err := s.db.Exec(ctx, `DELETE FROM retention_rules WHERE id = $1 AND tenant_id = $2`, id, tenantID)
if err != nil {
return fmt.Errorf("retention rules: delete: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrRetentionRuleNotFound
}
return nil
}
// computeRuleRetainUntil is the pure retain_until computation shared by the batch
// job and the dry-run preview. It returns (retainUntil, ok): ok=false means
// the document must be SKIPPED (event-based rule, or a misconfigured
// years+days==0 rule that validation should have prevented but we defend
// against anyway). It never writes to the DB.
//
// Base date by trigger_type:
// - document_date: doc.DocumentDate if set, else doc.CreatedAt
// - upload_date: doc.CreatedAt
// - fixed_date: rule.TriggerReference parsed as 2006-01-02
// - event: skipped (ok=false)
//
// retain_until = base + retention_years years + retention_days days.
func computeRuleRetainUntil(rule RetentionRule, doc Document) (*time.Time, bool) {
var base time.Time
switch rule.TriggerType {
case RetentionTriggerDocumentDate:
if doc.DocumentDate != nil {
base = *doc.DocumentDate
} else {
base = doc.CreatedAt
}
case RetentionTriggerUploadDate:
base = doc.CreatedAt
case RetentionTriggerFixedDate:
parsed, err := time.Parse("2006-01-02", rule.TriggerReference)
if err != nil {
return nil, false
}
base = parsed
case RetentionTriggerEvent:
// Not auto-computed — requires a real trigger event the system does not
// yet observe. Future extension point.
return nil, false
default:
return nil, false
}
years, days := 0, 0
if rule.RetentionYears != nil {
years = *rule.RetentionYears
}
if rule.RetentionDays != nil {
days = *rule.RetentionDays
}
if years <= 0 && days <= 0 {
// Misconfigured rule — never produce retain_until == base silently.
return nil, false
}
ru := base.AddDate(years, 0, days)
return &ru, true
}
// tenantIDsWithRules returns the distinct tenant_ids that have at least one
// active retention rule. Used by ApplyRetentionRules/PreviewRetentionRules when
// tenantID == 0 ("all tenants").
func (s *Store) tenantIDsWithRules(ctx context.Context) ([]int64, error) {
rows, err := s.db.Query(ctx, `SELECT DISTINCT tenant_id FROM retention_rules WHERE active`)
if err != nil {
return nil, fmt.Errorf("retention rules: distinct tenants: %w", err)
}
defer rows.Close()
var out []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("retention rules: scan tenant: %w", err)
}
out = append(out, id)
}
return out, rows.Err()
}
// matchDocumentsForRule loads documents of a tenant that (a) have no
// retain_until yet, (b) are not in the trash, and (c) match the given rule's
// scope: for a doc-type-specific rule (doc_type_id NOT NULL) exactly that
// doc_type; for the tenant-wide default rule (doc_type_id IS NULL) every
// document whose doc_type_id has NO own specific active rule (so the specific
// rule always wins the precedence). Only the columns computeRuleRetainUntil needs
// are loaded.
func (s *Store) matchDocumentsForRule(ctx context.Context, tenantID int64, rule RetentionRule) ([]Document, error) {
var (
rows pgx.Rows
err error
)
if rule.DocTypeID != nil {
rows, err = s.db.Query(ctx, `
SELECT id, tenant_id, document_date, created_at
FROM documents
WHERE tenant_id = $1 AND deleted_at IS NULL
AND retain_until IS NULL
AND doc_type_id = $2
`, tenantID, *rule.DocTypeID)
} else {
// Tenant-wide default: only documents whose doc_type_id has no own
// active specific rule (NULL doc_type_id included). Specific rule wins.
rows, err = s.db.Query(ctx, `
SELECT d.id, d.tenant_id, d.document_date, d.created_at
FROM documents d
WHERE d.tenant_id = $1 AND d.deleted_at IS NULL
AND d.retain_until IS NULL
AND NOT EXISTS (
SELECT 1 FROM retention_rules r
WHERE r.tenant_id = $1 AND r.active
AND r.doc_type_id IS NOT NULL
AND r.doc_type_id = d.doc_type_id
)
`, tenantID)
}
if err != nil {
return nil, fmt.Errorf("retention rules: match documents: %w", err)
}
defer rows.Close()
var out []Document
for rows.Next() {
var d Document
if err := rows.Scan(&d.ID, &d.TenantID, &d.DocumentDate, &d.CreatedAt); err != nil {
return nil, fmt.Errorf("retention rules: scan matched document: %w", err)
}
out = append(out, d)
}
return out, rows.Err()
}
// PreviewRetentionRules computes (without writing) which retain_until values
// ApplyRetentionRules WOULD set. tenantID == 0 means all tenants that have
// active rules. Documents skipped by computeRuleRetainUntil (event rules,
// misconfigured rules) are omitted from the preview.
func (s *Store) PreviewRetentionRules(ctx context.Context, tenantID int64) ([]RetentionPreview, error) {
tenants := []int64{tenantID}
if tenantID == 0 {
var err error
tenants, err = s.tenantIDsWithRules(ctx)
if err != nil {
return nil, err
}
}
var out []RetentionPreview
for _, tid := range tenants {
rules, err := s.ListRetentionRules(ctx, tid)
if err != nil {
return nil, err
}
for _, rule := range rules {
if !rule.Active {
continue
}
docs, err := s.matchDocumentsForRule(ctx, tid, rule)
if err != nil {
return nil, err
}
for _, doc := range docs {
ru, ok := computeRuleRetainUntil(rule, doc)
if !ok {
continue
}
out = append(out, RetentionPreview{
DocumentID: doc.ID,
TenantID: tid,
RuleID: rule.ID,
RuleName: rule.Name,
RetainUntil: ru,
})
}
}
}
return out, nil
}
// ApplyRetentionRules scans documents for the tenant (or all tenants if
// tenantID == 0) whose retain_until is NULL and which have a matching active
// retention rule (by doc_type_id, falling back to the tenant-wide default rule
// with doc_type_id IS NULL), computes retain_until from
// trigger_type/trigger_reference + retention_years/retention_days, and sets it.
//
// Never touches documents that already have retain_until set (a rule change
// does not retroactively shrink an existing lock — GoBD: once WORM, only ever
// extend, never shorten; extension is a separate future feature, not
// implemented here). Event-based rules are skipped (see computeRuleRetainUntil).
// Returns the number of documents updated.
func (s *Store) ApplyRetentionRules(ctx context.Context, tenantID int64) (int, error) {
tenants := []int64{tenantID}
if tenantID == 0 {
var err error
tenants, err = s.tenantIDsWithRules(ctx)
if err != nil {
return 0, err
}
}
updated := 0
for _, tid := range tenants {
rules, err := s.ListRetentionRules(ctx, tid)
if err != nil {
return updated, err
}
for _, rule := range rules {
if !rule.Active {
continue
}
docs, err := s.matchDocumentsForRule(ctx, tid, rule)
if err != nil {
return updated, err
}
for _, doc := range docs {
ru, ok := computeRuleRetainUntil(rule, doc)
if !ok {
continue
}
// Re-assert retain_until IS NULL in the WHERE so a concurrent
// run / manual set is never overwritten (never shorten a lock).
tag, err := s.db.Exec(ctx, `
UPDATE documents SET retain_until = $3, updated_at = now()
WHERE id = $1 AND tenant_id = $2 AND retain_until IS NULL AND deleted_at IS NULL
`, doc.ID, tid, *ru)
if err != nil {
return updated, fmt.Errorf("retention rules: set retain_until: %w", err)
}
updated += int(tag.RowsAffected())
}
}
}
return updated, nil
}
// ListEligibleForDisposition returns documents whose retention has expired
// (retain_until IS NOT NULL AND < now()) but which are not yet in the trash
// (deleted_at IS NULL). These are implicitly "eligible for disposition": a
// human can now soft-delete them and start the Vier-Augen delete-request flow
// (trash.go). No new status is invented — eligibility is derived from
// retain_until alone.
func (s *Store) ListEligibleForDisposition(ctx context.Context, tenantID int64) ([]Document, error) {
rows, err := s.db.Query(ctx, `
SELECT id, tenant_id, title, COALESCE(doc_type, ''), COALESCE(correspondent, ''),
doc_type_id, correspondent_id, storage_path, content_hash, COALESCE(ocr_text, ''),
retain_until, document_date, COALESCE(source, ''), COALESCE(source_ref, ''),
created_by, title_manually_set, has_thumbnail, created_at, updated_at
FROM documents
WHERE tenant_id = $1 AND deleted_at IS NULL
AND retain_until IS NOT NULL AND retain_until < now()
ORDER BY retain_until ASC
`, tenantID)
if err != nil {
return nil, fmt.Errorf("retention rules: list eligible for disposition: %w", err)
}
defer rows.Close()
out := make([]Document, 0)
for rows.Next() {
var d Document
if err := rows.Scan(&d.ID, &d.TenantID, &d.Title, &d.DocType, &d.Correspondent,
&d.DocTypeID, &d.CorrespondentID, &d.StoragePath, &d.ContentHash, &d.OCRText,
&d.RetainUntil, &d.DocumentDate, &d.Source, &d.SourceRef,
&d.CreatedBy, &d.TitleManuallySet, &d.HasThumbnail, &d.CreatedAt, &d.UpdatedAt); err != nil {
return nil, fmt.Errorf("retention rules: scan eligible document: %w", err)
}
out = append(out, d)
}
return out, rows.Err()
}