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