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