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

589 lines
28 KiB
Go

package storage
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5/pgconn"
)
// ErrDuplicateContentHash is returned by CreateDocument when the tenant
// already has an (active) document with the same content_hash — the DB-level
// half of the duplicate-upload protection (see
// migrations/003_documents_unique_hash.sql). The upload handler translates
// this into an HTTP 409.
var ErrDuplicateContentHash = errors.New("storage: document with this content hash already exists for tenant")
// ErrDocumentNotFound is returned when a tenant-scoped document lookup/update
// affects zero rows (wrong id, wrong tenant, or already soft-deleted).
var ErrDocumentNotFound = errors.New("storage: document not found")
// Document is the core archivdms record: a stored, indexed, and (optionally)
// GoBD-retention-locked document. Unlike archivmail's `emails` table this is
// intentionally generic — it is not tied to any particular ingestion source.
//
// Source / SourceRef are nullable placeholders for a later, purely optional
// archivmail-pull importer (via archivmail's REST API) — no importer exists
// yet, these columns just reserve the shape.
type Document struct {
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
Title string `json:"title"`
DocType string `json:"doc_type,omitempty"` // deprecated free-text, kept for Bestandsschutz
Correspondent string `json:"correspondent,omitempty"` // deprecated free-text, kept for Bestandsschutz
DocTypeID *int64 `json:"doc_type_id,omitempty"`
CorrespondentID *int64 `json:"correspondent_id,omitempty"`
StoragePath string `json:"storage_path"`
ContentHash string `json:"content_hash"`
OCRText string `json:"ocr_text,omitempty"`
RetainUntil *time.Time `json:"retain_until,omitempty"`
// DocumentDate is the recognised belegdatum (invoice/document date) parsed
// from the OCR text at upload/reprocess time, if any. It is separate from
// CreatedAt (the immutable scan/upload timestamp kept for GoBD
// traceability) and drives the store/<tenant>/<yyyy>/<mm>/ archival path at
// upload time. Nullable — nil when no plausible date was found in the text.
DocumentDate *time.Time `json:"document_date,omitempty"`
// DocumentDateScore is the confidence (0.4-0.9 automatic, 1.0 = manually
// confirmed by a user via PUT .../document-date) of DocumentDate, or nil
// when DocumentDate itself is nil / unknown (pre-existing rows from before
// this column existed). Intended as a quality gate for the future
// Buchhaltungs-Pull-API (only Score >= 0.75 auto-pullable) — see
// migrations/025_document_date_score.sql. Never backfilled for existing
// rows; NULL correctly means "no known score", not "score 0".
DocumentDateScore *float64 `json:"document_date_score,omitempty"`
Source string `json:"source,omitempty"` // e.g. "upload" | "archivmail_import"
SourceRef string `json:"source_ref,omitempty"` // external ref, e.g. archivmail mail ID
CreatedBy *int64 `json:"created_by,omitempty"`
// TitleManuallySet is true once a user has explicitly renamed the document
// via PATCH /api/documents/{id} (handleUpdateDocumentTitle). It gates
// whether POST .../reprocess is allowed to overwrite the title with a
// freshly re-derived one — see UpdateDocumentTitleAuto.
TitleManuallySet bool `json:"title_manually_set"`
// HasThumbnail is true once an eager preview thumbnail has been rendered
// for this document (at upload or reprocess time, see internal/api
// storeUploadedFile / ReprocessDocument) to
// config.StorageConfig.ThumbnailPath()/<tenant>/<hash>.png. False for
// documents whose thumbnail render failed/was skipped (unsupported
// format, missing generator) or for any pre-existing document from before
// this column existed — GET /api/documents/{id}/thumbnail still falls
// back to on-demand lazy generation regardless of this flag, so a false
// value never hard-blocks the preview, it's purely an optimisation hint.
HasThumbnail bool `json:"has_thumbnail"`
// ProcessingStatus spiegelt den Zustand der asynchronen Nachverarbeitung
// (OCR/Taxonomie/Workflows) wider: queued | processing | done | failed
// (siehe processing_jobs.go). Altbestand steht per Spalten-Default auf
// 'done'. ACHTUNG: nur die Abfragen in dieser Datei (CreateDocument*,
// GetDocument, ListDocuments) füllen das Feld — andere Selects (Suche,
// Papierkorb, Retention) lassen es leer; "" ist vom Frontend wie 'done'
// zu behandeln.
ProcessingStatus string `json:"processing_status,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// CreateDocumentRequest holds parameters for creating a new document.
type CreateDocumentRequest struct {
TenantID int64
Title string
DocType string
Correspondent string
StoragePath string
ContentHash string
OCRText string
RetainUntil *time.Time
// DocumentDate is the recognised belegdatum from the OCR text (nil if none).
DocumentDate *time.Time
// DocumentDateScore is the confidence for DocumentDate, mirrors the field
// on Document (nil if DocumentDate is nil / unknown).
DocumentDateScore *float64
Source string
SourceRef string
// CreatedBy is the uploading user's ID, if known (nil for non-interactive
// sources like the SFTP watcher). Used so an uploader can always see
// their own document even before any permission-group grant exists for
// it — otherwise a plain 'user' role could upload a document and
// immediately lose visibility of it (see RecomputeVisibility/ACL model).
CreatedBy *int64
}
func (s *Store) initSchema(ctx context.Context) error {
_, err := s.db.Exec(ctx, `
CREATE TABLE IF NOT EXISTS documents (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
title TEXT NOT NULL,
doc_type TEXT,
correspondent TEXT,
storage_path TEXT NOT NULL,
content_hash TEXT NOT NULL,
ocr_text TEXT,
retain_until DATE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_documents_tenant ON documents(tenant_id);
`)
if err != nil {
return fmt.Errorf("storage: create documents table: %w", err)
}
// Duplicate protection at the DB level, on top of the filesystem-level
// collision check performed by the upload handler (see
// migrations/003_documents_unique_hash.sql).
_, err = s.db.Exec(ctx, `
CREATE UNIQUE INDEX IF NOT EXISTS idx_documents_tenant_hash ON documents(tenant_id, content_hash);
`)
if err != nil {
return fmt.Errorf("storage: create documents unique hash index: %w", err)
}
// Space for a later, optional archivmail-pull importer (see Document doc
// comment). Added as nullable columns now, idempotently, no importer yet.
_, err = s.db.Exec(ctx, `
ALTER TABLE documents ADD COLUMN IF NOT EXISTS source TEXT;
ALTER TABLE documents ADD COLUMN IF NOT EXISTS source_ref TEXT;
`)
if err != nil {
return fmt.Errorf("storage: alter documents table: %w", err)
}
// created_by: nullable, no FK (users can be deleted independently of
// their historical uploads, same Bestandsschutz reasoning as elsewhere
// in this schema). Lets ListDocuments always show the uploader their own
// document even with zero document_visibility rows (see CreateDocumentRequest).
_, err = s.db.Exec(ctx, `
ALTER TABLE documents ADD COLUMN IF NOT EXISTS created_by BIGINT;
`)
if err != nil {
return fmt.Errorf("storage: alter documents add created_by: %w", err)
}
// title_manually_set: tracks whether a user has explicitly renamed the
// document (via PATCH .../title) versus the title still being whatever was
// auto-derived from OCR text at upload time. Reprocess only re-derives the
// title from fresh OCR when this is false, so a user's manual rename is
// never silently overwritten. Defaults to false for all existing rows —
// deliberate: we cannot distinguish old manual renames from old
// auto-derived titles, and false is the safe choice that lets reprocess
// improve stale/garbled auto-derived titles from before the OCR
// rotation/deskew fix (see DEVLOG).
_, err = s.db.Exec(ctx, `
ALTER TABLE documents ADD COLUMN IF NOT EXISTS title_manually_set BOOLEAN NOT NULL DEFAULT false;
`)
if err != nil {
return fmt.Errorf("storage: alter documents add title_manually_set: %w", err)
}
// document_date: the belegdatum (invoice/document date) recognised from the
// OCR text, separate from created_at (the immutable scan/upload timestamp).
// Nullable — nil for existing rows and for documents where no plausible date
// was found. Drives the store/<tenant>/<yyyy>/<mm>/ archival path at upload
// time (see internal/api storeUploadedFile); reprocess refreshes the column
// but never moves the already-archived WORM file. See
// migrations/019_document_date.sql.
_, err = s.db.Exec(ctx, `
ALTER TABLE documents ADD COLUMN IF NOT EXISTS document_date DATE;
`)
if err != nil {
return fmt.Errorf("storage: alter documents add document_date: %w", err)
}
// document_date_score: confidence (0.4-0.9 automatic keyword-proximity
// heuristic, 1.0 once a user manually confirms/overrides the date via PUT
// .../document-date) for document_date. Nullable, no backfill for existing
// rows — NULL means "score unknown", not "score 0". Reserved as a quality
// gate for the future Buchhaltungs-Pull-API (only Score >= 0.75 pullable).
// See migrations/025_document_date_score.sql.
_, err = s.db.Exec(ctx, `
ALTER TABLE documents ADD COLUMN IF NOT EXISTS document_date_score NUMERIC;
`)
if err != nil {
return fmt.Errorf("storage: alter documents add document_date_score: %w", err)
}
// Structured taxonomy entities (tags/document_types/correspondents) plus
// the barcode-recognition columns on documents (see
// migrations/005_taxonomy.sql / lazy-splashing-puppy plan).
if err := s.initTaxonomySchema(ctx); err != nil {
return err
}
// Custom fields (custom_field_defs / document_type_fields /
// document_field_values — see migrations/006_custom_fields.sql).
if err := s.initCustomFieldsSchema(ctx); err != nil {
return err
}
// Papierkorb + gestaffeltes Löschkonzept (soft-delete + Vier-Augen-Prinzip
// für finales Löschen — see migrations/007_trash.sql).
if err := s.initTrashSchema(ctx); err != nil {
return err
}
// Klassifizierungsvorlagen (classification templates — see
// migrations/011_classification_templates.sql). Depends on document_types
// (initTaxonomySchema) and custom_field_defs (initCustomFieldsSchema)
// created above, so it is wired in AFTER them.
if err := s.initClassificationTemplatesSchema(ctx); err != nil {
return err
}
// Workflows / Consumption-Regeln (see migrations/012_workflows.sql). Wired
// in AFTER classification templates because a workflow action can reference
// a classification template (apply_classification_template).
if err := s.initWorkflowsSchema(ctx); err != nil {
return err
}
// Heuristische Metadaten-Vorschläge (metadata_suggestions — see
// migrations/012_metadata_suggestions.sql). Wired in AFTER documents/taxonomy
// exist, since suggestions score taxonomy entities against a document.
if err := s.initMetadataSuggestionsSchema(ctx); err != nil {
return err
}
// Pro-Mandant konfigurierbare Anbindung an einen EXTERNEN Ollama-Server
// (tenant_ollama_config — see migrations/017_tenant_ollama_config.sql).
// Gate für den optionalen 'ollama'-Provider der Metadaten-Vorschläge.
if err := s.initOllamaConfigSchema(ctx); err != nil {
return err
}
// Freitext-Notizen pro Dokument (document_notes — see
// migrations/013_document_notes.sql). Wired in AFTER documents exists since
// it FK-references documents(id).
if err := s.initDocumentNotesSchema(ctx); err != nil {
return err
}
// Gespeicherte Suchansichten (saved_views — SavedViews, Paperless-ngx
// inspiriert). Tenant-/user-scoped, hängt an keiner anderen Tabelle, daher
// zuletzt eingehängt.
if err := s.initSavedViewsSchema(ctx); err != nil {
return err
}
// OCR-Wortpositionen (ocr_words — see migrations/024_ocr_words.sql), Phase
// 2 of the OCR text-highlight/overlay feature. Wired in AFTER documents
// exists since it FK-references documents(id) ON DELETE CASCADE.
if err := s.initOCRWordsSchema(ctx); err != nil {
return err
}
// Digitale Akten (digitaler Aktenordner — see
// migrations/018_akten.sql / project_akte_konzept_plan.md). Wired in AFTER
// documents + correspondents exist: initAktenSchema creates the akten table
// (FK to correspondents) and then ALTERs documents to add akte_id (FK to
// akten), so both referenced tables must already exist.
if err := s.initAktenSchema(ctx); err != nil {
return err
}
// ML-Retraining-Klassifizierung Phase 1 (Naive-Bayes, ergänzt die
// Regel-Engine — see migrations/020_ml_classifier.sql). Wired in AFTER
// initTaxonomySchema since it FK-references nothing directly but ALTERs
// documents/document_tags which must already exist.
if err := s.initMLClassifierSchema(ctx); err != nil {
return err
}
// has_thumbnail: tracks whether an eager preview thumbnail was rendered at
// upload/reprocess time (see storeUploadedFile/ReprocessDocument in
// internal/api). Defaults to false for all existing rows — deliberate:
// GET /api/documents/{id}/thumbnail already falls back to on-demand lazy
// generation on a cache miss regardless of this flag (see
// internal/thumbnail), so no backfill migration is needed here, this
// column is purely a "was it pre-rendered" hint for the frontend.
_, err = s.db.Exec(ctx, `
ALTER TABLE documents ADD COLUMN IF NOT EXISTS has_thumbnail BOOLEAN NOT NULL DEFAULT false;
`)
if err != nil {
return fmt.Errorf("storage: alter documents add has_thumbnail: %w", err)
}
// GoBD-Aufbewahrungsregeln (retention rules / "Disposition Schedules" —
// see migrations/022_retention_rules.sql). Wired in AFTER initTaxonomySchema
// (FK to document_types) and AFTER initTrashSchema (retain_until/deleted_at
// on documents must already exist — the rules engine only ever FEEDS
// retain_until, the existing trash + Vier-Augen flow performs the actual
// disposition).
if err := s.initRetentionRulesSchema(ctx); err != nil {
return err
}
// Mandanten-Job-Queue für die asynchrone Nachverarbeitung (processing_jobs
// + documents.processing_status — see migrations/023_processing_jobs.sql).
// Zuletzt eingehängt: die Tabelle FK-referenziert documents(id), das muss
// also bereits existieren.
if err := s.initProcessingJobsSchema(ctx); err != nil {
return err
}
return nil
}
// CreateDocument inserts a new document and returns it.
func (s *Store) CreateDocument(ctx context.Context, req CreateDocumentRequest) (*Document, error) {
var d Document
err := s.db.QueryRow(ctx, `
INSERT INTO documents (tenant_id, title, doc_type, correspondent, storage_path, content_hash, ocr_text, retain_until, document_date, document_date_score, source, source_ref, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING 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, document_date_score, COALESCE(source, ''), COALESCE(source_ref, ''), created_by, title_manually_set, has_thumbnail, processing_status, created_at, updated_at
`, req.TenantID, req.Title, nullIfEmpty(req.DocType), nullIfEmpty(req.Correspondent), req.StoragePath, req.ContentHash,
nullIfEmpty(req.OCRText), req.RetainUntil, req.DocumentDate, req.DocumentDateScore, nullIfEmpty(req.Source), nullIfEmpty(req.SourceRef), req.CreatedBy,
).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.DocumentDateScore, &d.Source, &d.SourceRef, &d.CreatedBy, &d.TitleManuallySet, &d.HasThumbnail, &d.ProcessingStatus, &d.CreatedAt, &d.UpdatedAt)
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return nil, ErrDuplicateContentHash
}
return nil, fmt.Errorf("storage: create document: %w", err)
}
return &d, nil
}
// GetDocument retrieves a document by ID, scoped to tenant.
func (s *Store) GetDocument(ctx context.Context, id, tenantID int64) (*Document, error) {
var d Document
err := s.db.QueryRow(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, document_date_score, COALESCE(source, ''), COALESCE(source_ref, ''), created_by, title_manually_set, has_thumbnail, processing_status, created_at, updated_at
FROM documents WHERE id = $1 AND tenant_id = $2
`, id, tenantID).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.DocumentDateScore, &d.Source, &d.SourceRef, &d.CreatedBy, &d.TitleManuallySet, &d.HasThumbnail, &d.ProcessingStatus, &d.CreatedAt, &d.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("storage: get document: %w", err)
}
return &d, nil
}
// DocumentExistsByHash reports whether the tenant already has a document with
// the given content_hash. This mirrors the (tenant_id, content_hash) unique
// index (deleted_at is intentionally NOT filtered, so the result matches what
// CreateDocument's INSERT would enforce) and lets the upload pipeline detect a
// duplicate BEFORE spending OCR/Tesseract cycles on the file.
func (s *Store) DocumentExistsByHash(ctx context.Context, tenantID int64, contentHash string) (bool, error) {
var exists bool
err := s.db.QueryRow(ctx, `
SELECT EXISTS (SELECT 1 FROM documents WHERE tenant_id = $1 AND content_hash = $2)
`, tenantID, contentHash).Scan(&exists)
if err != nil {
return false, fmt.Errorf("storage: check document hash: %w", err)
}
return exists, nil
}
// ListDocuments returns all documents for a tenant, newest first.
//
// aclUserID applies the group-resolved document ACL: when non-nil, only
// documents visible to that user via document_visibility (through their
// permission group memberships) are returned. Callers pass a non-nil value
// only for role 'user'; domain_admin/superadmin pass nil to see every document
// in the tenant unfiltered (roles remain the outer boundary — see
// permissions.go).
func (s *Store) ListDocuments(ctx context.Context, tenantID int64, aclUserID *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, document_date_score, COALESCE(source, ''), COALESCE(source_ref, ''), created_by, title_manually_set, has_thumbnail, processing_status, created_at, updated_at
FROM documents
WHERE tenant_id = $1 AND deleted_at IS NULL
AND ($2::bigint IS NULL OR documents.created_by = $2 OR EXISTS (
SELECT 1 FROM document_visibility dv
JOIN permission_group_members pgm ON pgm.group_id = dv.group_id
WHERE dv.document_id = documents.id AND pgm.user_id = $2
))
ORDER BY created_at DESC
`, tenantID, aclUserID)
if err != nil {
return nil, fmt.Errorf("storage: list documents: %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.DocumentDateScore, &d.Source, &d.SourceRef, &d.CreatedBy, &d.TitleManuallySet, &d.HasThumbnail, &d.ProcessingStatus, &d.CreatedAt, &d.UpdatedAt); err != nil {
return nil, fmt.Errorf("storage: scan document: %w", err)
}
out = append(out, d)
}
return out, rows.Err()
}
// IsDocumentVisible reports whether the given user may see the document, using
// exactly the same rule as ListDocuments' ACL predicate (own upload OR a
// document_visibility row resolved through the user's permission groups).
// Always tenant-scoped: a document of another tenant is never visible, even to
// its own uploader. Callers pass userID only for role 'user'; domain_admin /
// superadmin skip this check (roles remain the outer ACL boundary).
func (s *Store) IsDocumentVisible(ctx context.Context, id, tenantID, userID int64) (bool, error) {
var visible bool
err := s.db.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM documents d
WHERE d.id = $1 AND d.tenant_id = $2 AND d.deleted_at IS NULL
AND (d.created_by = $3 OR EXISTS (
SELECT 1 FROM document_visibility dv
JOIN permission_group_members pgm ON pgm.group_id = dv.group_id
WHERE dv.document_id = d.id AND pgm.user_id = $3
))
)
`, id, tenantID, userID).Scan(&visible)
if err != nil {
return false, fmt.Errorf("storage: check document visibility: %w", err)
}
return visible, nil
}
// DocumentTaxonomyNames resolves the structured document-type and correspondent
// names of a document, tenant-scoped. Empty strings when unassigned. Used by
// the single-document export (metadata.json) so the ZIP carries readable names
// instead of raw IDs.
func (s *Store) DocumentTaxonomyNames(ctx context.Context, id, tenantID int64) (docType, correspondent string, err error) {
err = s.db.QueryRow(ctx, `
SELECT COALESCE(dt.name, ''), COALESCE(c.name, '')
FROM documents d
LEFT JOIN document_types dt ON dt.id = d.doc_type_id AND dt.tenant_id = d.tenant_id
LEFT JOIN correspondents c ON c.id = d.correspondent_id AND c.tenant_id = d.tenant_id
WHERE d.id = $1 AND d.tenant_id = $2
`, id, tenantID).Scan(&docType, &correspondent)
if err != nil {
return "", "", fmt.Errorf("storage: document taxonomy names: %w", err)
}
return docType, correspondent, nil
}
// UpdateDocumentTitle renames a document, scoped to tenant, and marks the
// title as manually set (title_manually_set = true). Use this for the
// user-facing PATCH .../title endpoint only — it permanently opts the
// document out of automatic title re-derivation by POST .../reprocess. Title
// is not part of the ACL, so this only needs an index re-sync (mirrors
// SetDocumentCorrespondent in taxonomy.go), not a visibility recompute.
func (s *Store) UpdateDocumentTitle(ctx context.Context, id, tenantID int64, title string) error {
tag, err := s.db.Exec(ctx, `UPDATE documents SET title = $1, title_manually_set = true, updated_at = now() WHERE id = $2 AND tenant_id = $3`, title, id, tenantID)
if err != nil {
return fmt.Errorf("storage: update document title: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("storage: update document title: %w", ErrDocumentNotFound)
}
s.SyncIndex(ctx, id)
return nil
}
// UpdateDocumentTitleAuto renames a document without touching
// title_manually_set. Used only by POST .../reprocess to re-derive a title
// from freshly re-extracted OCR text — callers must already have checked
// doc.TitleManuallySet is false before calling this, otherwise a user's
// manual rename would be silently overwritten.
func (s *Store) UpdateDocumentTitleAuto(ctx context.Context, id, tenantID int64, title string) error {
tag, err := s.db.Exec(ctx, `UPDATE documents SET title = $1, updated_at = now() WHERE id = $2 AND tenant_id = $3`, title, id, tenantID)
if err != nil {
return fmt.Errorf("storage: update document title (auto): %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("storage: update document title (auto): %w", ErrDocumentNotFound)
}
s.SyncIndex(ctx, id)
return nil
}
// UpdateDocumentOCRText replaces a document's ocr_text, scoped to tenant.
// Used by the re-processing endpoint (POST /api/documents/{id}/reprocess)
// after re-running OCR on an already-archived file — the WORM file itself is
// never touched, only this derived metadata column is refreshed. OCR text is
// not part of the ACL, so this only needs an index re-sync (mirrors
// UpdateDocumentTitle), not a visibility recompute.
func (s *Store) UpdateDocumentOCRText(ctx context.Context, id, tenantID int64, ocrText string) error {
tag, err := s.db.Exec(ctx, `UPDATE documents SET ocr_text = $1, updated_at = now() WHERE id = $2 AND tenant_id = $3`, nullIfEmpty(ocrText), id, tenantID)
if err != nil {
return fmt.Errorf("storage: update document ocr_text: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("storage: update document ocr_text: %w", ErrDocumentNotFound)
}
s.SyncIndex(ctx, id)
return nil
}
// UpdateDocumentDate replaces a document's recognised belegdatum
// (document_date) and its confidence (document_date_score), scoped to tenant.
// Used by the re-processing endpoint after re-running OCR so the metadata
// reflects a freshly recognised date — the WORM file and its
// store/<yyyy>/<mm>/ path are NEVER moved, only this derived column is
// refreshed. Passing a nil date clears both date and score. score should be
// 1.0 when the caller is a manual user confirmation (PUT .../document-date),
// or the heuristic's own confidence (0.4-0.9) for automatic
// extract/reprocess/job-queue callers — never carry over a stale automatic
// score after a manual override. document_date is not part of the ACL, so
// this only needs an index re-sync (mirrors UpdateDocumentOCRText), not a
// visibility recompute.
func (s *Store) UpdateDocumentDate(ctx context.Context, id, tenantID int64, date *time.Time, score *float64) error {
if date == nil {
score = nil
}
tag, err := s.db.Exec(ctx, `UPDATE documents SET document_date = $1, document_date_score = $2, updated_at = now() WHERE id = $3 AND tenant_id = $4`, date, score, id, tenantID)
if err != nil {
return fmt.Errorf("storage: update document date: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("storage: update document date: %w", ErrDocumentNotFound)
}
s.SyncIndex(ctx, id)
return nil
}
// SetDocumentHasThumbnail records whether an eager preview thumbnail was
// successfully rendered for this document, scoped to tenant. Used by
// storeUploadedFile (after upload) and ReprocessDocument (to backfill/repair
// a previously missing thumbnail) — see internal/api/document_handlers.go.
// has_thumbnail is a pure UI hint (not indexed content, not part of the ACL),
// so unlike UpdateDocumentTitle/OCRText/Date this deliberately does NOT call
// SyncIndex — no Manticore reindex is needed for a thumbnail flag.
func (s *Store) SetDocumentHasThumbnail(ctx context.Context, id, tenantID int64, hasThumbnail bool) error {
tag, err := s.db.Exec(ctx, `UPDATE documents SET has_thumbnail = $1, updated_at = now() WHERE id = $2 AND tenant_id = $3`, hasThumbnail, id, tenantID)
if err != nil {
return fmt.Errorf("storage: set document has_thumbnail: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("storage: set document has_thumbnail: %w", ErrDocumentNotFound)
}
return nil
}
// DeleteDocument removes a document, scoped to tenant.
func (s *Store) DeleteDocument(ctx context.Context, id, tenantID int64) error {
tag, err := s.db.Exec(ctx, `DELETE FROM documents WHERE id = $1 AND tenant_id = $2`, id, tenantID)
if err != nil {
return fmt.Errorf("storage: delete document: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("storage: document not found or not owned by tenant")
}
return nil
}
func nullIfEmpty(s string) any {
if s == "" {
return nil
}
return s
}
// nullIfEmptyPtr maps a *string to a NULL-able SQL argument: nil or a
// whitespace-only value becomes NULL, otherwise the trimmed string is stored.
func nullIfEmptyPtr(s *string) any {
if s == nil {
return nil
}
if strings.TrimSpace(*s) == "" {
return nil
}
return strings.TrimSpace(*s)
}