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