Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
534 lines
20 KiB
Go
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()
|
|
}
|