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.
This commit is contained in:
@@ -0,0 +1,533 @@
|
||||
// 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()
|
||||
}
|
||||
Reference in New Issue
Block a user