FDN-01: repository & projektgerüst

Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
This commit is contained in:
2026-08-11 21:27:53 +02:00
parent 40ed80da71
commit 9a24ea29e1
274 changed files with 53708 additions and 0 deletions
@@ -0,0 +1,60 @@
-- Dokumentation, siehe README.md. Wird zur Laufzeit idempotent von
-- internal/tenantstore, internal/userstore, internal/storage und
-- internal/audit initSchema()-Funktionen erzeugt.
CREATE TABLE IF NOT EXISTS tenants (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
slug VARCHAR(100) UNIQUE NOT NULL,
domain VARCHAR(255),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_tenants_domain ON tenants (domain) WHERE domain IS NOT NULL;
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(100) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL DEFAULT '',
role VARCHAR(20) NOT NULL DEFAULT 'user',
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_login_at TIMESTAMPTZ,
tenant_id BIGINT
);
CREATE INDEX IF NOT EXISTS idx_users_tenant ON users (tenant_id);
CREATE TABLE IF NOT EXISTS token_blacklist (
jti VARCHAR(255) PRIMARY KEY,
expires_at TIMESTAMPTZ NOT NULL
);
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,
source TEXT, -- z.B. 'upload' | 'archivmail_import' (kein Importer implementiert)
source_ref TEXT, -- externe Referenz (z.B. archivmail Mail-ID via API-Pull)
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);
CREATE TABLE IF NOT EXISTS audit_log (
id BIGSERIAL PRIMARY KEY,
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
event_type VARCHAR(50) NOT NULL,
username VARCHAR(255) NOT NULL DEFAULT '',
ip_address VARCHAR(45) NOT NULL DEFAULT '',
document_id VARCHAR(64) NOT NULL DEFAULT '',
success BOOLEAN NOT NULL DEFAULT true,
detail TEXT NOT NULL DEFAULT '',
tenant_id BIGINT
);
-- append-only: BEFORE UPDATE OR DELETE trigger raises, see internal/audit/audit.go
@@ -0,0 +1,17 @@
-- Dokumentation, siehe README.md. Wird zur Laufzeit idempotent von
-- internal/storage/reminders.go initReminderSchema() erzeugt.
CREATE TABLE IF NOT EXISTS reminders (
id BIGSERIAL PRIMARY KEY,
document_id BIGINT NOT NULL REFERENCES documents(id),
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
due_date TIMESTAMPTZ NOT NULL,
note TEXT,
status TEXT NOT NULL DEFAULT 'open', -- open|done|dismissed
notified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_reminders_tenant_due ON reminders(tenant_id, due_date) WHERE status = 'open';
CREATE INDEX IF NOT EXISTS idx_reminders_document ON reminders(document_id);
@@ -0,0 +1,8 @@
-- Dokumentation, siehe README.md. Wird zur Laufzeit idempotent von
-- internal/storage/documents.go initSchema() erzeugt.
-- Zusätzlicher Duplikatschutz auf DB-Ebene (neben dem Kollisionscheck auf
-- Dateisystemebene im Upload-Handler, siehe internal/api/document_handlers.go
-- handleUploadDocument): derselbe Mandant darf denselben Dateiinhalt
-- (content_hash) nicht zweimal als aktives Dokument anlegen.
CREATE UNIQUE INDEX IF NOT EXISTS idx_documents_tenant_hash ON documents(tenant_id, content_hash);
@@ -0,0 +1,17 @@
-- Dokumentation, siehe README.md. Wird zur Laufzeit idempotent von
-- internal/storage/sftp_credentials.go initSFTPCredentialsSchema() erzeugt.
-- Per-Mandant-Zugangsdaten fuer den eingebetteten SFTP-Server
-- (internal/sftpserver). Bewusst getrennt von `users`: ein SFTP-Zugang ist
-- ein eigenstaendiges, jederzeit widerrufbares Credential, kein volles
-- Login-Konto.
CREATE TABLE IF NOT EXISTS sftp_credentials (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL, -- bcrypt, analog users.password_hash
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_login_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_sftp_credentials_tenant ON sftp_credentials(tenant_id);
@@ -0,0 +1,68 @@
-- Doku-only (siehe README.md in diesem Verzeichnis) — die tatsächliche
-- Ausführung passiert idempotent über internal/storage/taxonomy.go
-- initTaxonomySchema(), aufgerufen aus (*Store).initSchema().
--
-- Strukturierte Entitäten (Tags/Dokumenttypen/Korrespondenten) statt der
-- bisherigen documents.doc_type/correspondent-Freitextfelder (die bleiben
-- unangetastet, Bestandsschutz), plus Barcode-Erkennung fuer automatische
-- Zuordnung beim Ingest (siehe internal/matching, internal/barcode).
CREATE TABLE IF NOT EXISTS tags (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
name TEXT NOT NULL,
color TEXT,
match_algorithm TEXT NOT NULL DEFAULT 'none' CHECK (match_algorithm IN ('none','any','all','exact','regex','fuzzy')),
match_pattern TEXT,
case_sensitive BOOLEAN NOT NULL DEFAULT false,
barcode_value TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(tenant_id, name)
);
CREATE TABLE IF NOT EXISTS document_types (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
name TEXT NOT NULL,
color TEXT,
match_algorithm TEXT NOT NULL DEFAULT 'none' CHECK (match_algorithm IN ('none','any','all','exact','regex','fuzzy')),
match_pattern TEXT,
case_sensitive BOOLEAN NOT NULL DEFAULT false,
barcode_value TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(tenant_id, name)
);
CREATE TABLE IF NOT EXISTS correspondents (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
name TEXT NOT NULL,
color TEXT,
match_algorithm TEXT NOT NULL DEFAULT 'none' CHECK (match_algorithm IN ('none','any','all','exact','regex','fuzzy')),
match_pattern TEXT,
case_sensitive BOOLEAN NOT NULL DEFAULT false,
barcode_value TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(tenant_id, name)
);
CREATE TABLE IF NOT EXISTS document_tags (
document_id BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
tag_id BIGINT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (document_id, tag_id)
);
CREATE INDEX IF NOT EXISTS idx_tags_tenant ON tags(tenant_id);
CREATE INDEX IF NOT EXISTS idx_document_types_tenant ON document_types(tenant_id);
CREATE INDEX IF NOT EXISTS idx_correspondents_tenant ON correspondents(tenant_id);
CREATE INDEX IF NOT EXISTS idx_document_tags_tag ON document_tags(tag_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_tags_tenant_barcode ON tags(tenant_id, barcode_value) WHERE barcode_value IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_document_types_tenant_barcode ON document_types(tenant_id, barcode_value) WHERE barcode_value IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_correspondents_tenant_barcode ON correspondents(tenant_id, barcode_value) WHERE barcode_value IS NOT NULL;
-- documents: neue Spalten, alte doc_type/correspondent-Textspalten bleiben
-- unveraendert (Bestandsschutz fuer vorhandene GoBD-Metadaten).
ALTER TABLE documents ADD COLUMN IF NOT EXISTS doc_type_id BIGINT REFERENCES document_types(id);
ALTER TABLE documents ADD COLUMN IF NOT EXISTS correspondent_id BIGINT REFERENCES correspondents(id);
ALTER TABLE documents ADD COLUMN IF NOT EXISTS barcode_values JSONB;
@@ -0,0 +1,44 @@
-- PROJ: custom-fields
-- Doku-only (siehe README.md in diesem Verzeichnis) — die tatsächliche
-- Ausführung passiert idempotent über internal/storage/custom_fields.go
-- initCustomFieldsSchema(), aufgerufen aus (*Store).initSchema().
--
-- Benutzerdefinierte Felder (Custom Fields): tenant-skopierte Feld-
-- Definitionen, Zuordnung pro Dokumenttyp (required/visible/sort_order) und
-- die eigentlichen Werte pro Dokument. Regeln: enum-Wert liegt in value_text,
-- monetary in value_number (NUMERIC(14,2) semantisch).
CREATE TABLE IF NOT EXISTS custom_field_defs (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
name TEXT NOT NULL,
label TEXT NOT NULL,
field_type TEXT NOT NULL CHECK (field_type IN ('text','number','date','boolean','enum','monetary')),
enum_options JSONB,
currency TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(tenant_id, name)
);
CREATE TABLE IF NOT EXISTS document_type_fields (
doc_type_id BIGINT NOT NULL REFERENCES document_types(id) ON DELETE CASCADE,
field_id BIGINT NOT NULL REFERENCES custom_field_defs(id) ON DELETE CASCADE,
required BOOLEAN NOT NULL DEFAULT false,
visible BOOLEAN NOT NULL DEFAULT true,
sort_order INT NOT NULL DEFAULT 0,
PRIMARY KEY (doc_type_id, field_id)
);
CREATE TABLE IF NOT EXISTS document_field_values (
document_id BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
field_id BIGINT NOT NULL REFERENCES custom_field_defs(id) ON DELETE CASCADE,
tenant_id BIGINT NOT NULL,
value_text TEXT,
value_number NUMERIC,
value_date DATE,
value_bool BOOLEAN,
PRIMARY KEY (document_id, field_id)
);
CREATE INDEX IF NOT EXISTS idx_dfv_tenant_field ON document_field_values(tenant_id, field_id);
CREATE INDEX IF NOT EXISTS idx_dfv_field_text ON document_field_values(field_id, value_text);
CREATE INDEX IF NOT EXISTS idx_dfv_field_number ON document_field_values(field_id, value_number);
+36
View File
@@ -0,0 +1,36 @@
-- PROJ: trash-staged-deletion
-- Doku-only (siehe README.md in diesem Verzeichnis) — die tatsächliche
-- Ausführung passiert idempotent über internal/storage/trash.go
-- initTrashSchema(), aufgerufen aus (*Store).initSchema().
--
-- Papierkorb + gestaffeltes Löschkonzept (GoBD):
-- * documents.deleted_at/deleted_by = Soft-Delete (Papierkorb). Die WORM-Datei
-- bleibt physisch unangetastet (chmod 0440) bis ein Löschantrag den Status
-- 'executed' erreicht.
-- * document_delete_requests = Vier-/Zwei-Augen-Workflow für finales Löschen:
-- User A stellt Antrag ('pending'), ein anderer domain_admin (User B)
-- bestätigt ('confirmed'->'executed'). Retention (retain_until) wird bei
-- Antrag UND Bestätigung geprüft; ein blockierter Versuch wird als
-- 'blocked_retention' protokolliert (Nachvollziehbarkeit).
-- * Finales Löschen: physische Datei via os.Remove entfernt, DB-Row als
-- Tombstone behalten (storage_path/ocr_text geleert, content_hash bleibt).
-- * Das Vier-Augen-Prinzip (confirmed_by != requested_by) wird im Go-Store
-- erzwungen, NICHT per CHECK-Constraint (confirmed_by wird erst später
-- gesetzt).
ALTER TABLE documents ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
ALTER TABLE documents ADD COLUMN IF NOT EXISTS deleted_by BIGINT;
CREATE TABLE IF NOT EXISTS document_delete_requests (
id BIGSERIAL PRIMARY KEY,
document_id BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
tenant_id BIGINT NOT NULL,
requested_by BIGINT NOT NULL,
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
confirmed_by BIGINT,
confirmed_at TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','confirmed','executed','cancelled','blocked_retention')),
UNIQUE(document_id, status)
);
CREATE INDEX IF NOT EXISTS idx_ddr_tenant_status ON document_delete_requests(tenant_id, status);
@@ -0,0 +1,78 @@
-- PROJ: permission-model-group-acl
-- Doku-only (siehe README.md in diesem Verzeichnis) — die tatsächliche
-- Ausführung passiert idempotent über internal/storage/permissions.go
-- initPermissionsSchema(), aufgerufen aus (*Store).New().
--
-- Berechtigungsmodell (gruppenaufgelöste, geschichtete Dokument-ACL):
-- Zugriff wird NIE direkt pro User vergeben, sondern über permission_groups.
-- Auflösung über drei Ebenen, spezifischer schlägt allgemeiner:
-- 1. document_grants (pro Dokument, 'deny' entfernt eine Gruppe komplett)
-- 2. tag_grants (über die Tags des Dokuments)
-- 3. document_type_grants (über documents.doc_type_id)
-- Ergebnis wird von RecomputeVisibility() nach document_visibility
-- materialisiert (DELETE+INSERT je Dokument in einer Transaktion).
--
-- Rollen bleiben Außengrenze: superadmin sieht alles tenant-übergreifend,
-- domain_admin sieht per Default alles im eigenen Tenant (bypasst ACL), nur
-- Rolle 'user' wird in ListDocuments gegen document_visibility gefiltert.
--
-- RecomputeVisibility MUSS neu laufen, wenn sich Grants, die Tags eines
-- Dokuments (AttachTag/DetachTag) oder der Dokumenttyp (SetDocumentDocType)
-- ändern — diese Aufrufe sind im Go-Code angehängt.
-- Hinweis: tenant_id / user_id / granted_by tragen bewusst KEINE FK auf
-- tenants(id) bzw. users(id) — konsistent mit documents/taxonomy (plain BIGINT
-- tenant_id) und weil tenants/users von anderen Stores NACH storage.New()
-- angelegt werden (Reihenfolge in cmd/archivdms/main.go). Tenant-Ownership wird
-- applikationsseitig geprüft (permissions.go).
CREATE TABLE IF NOT EXISTS permission_groups (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, name)
);
CREATE TABLE IF NOT EXISTS permission_group_members (
group_id BIGINT NOT NULL REFERENCES permission_groups(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL,
PRIMARY KEY (group_id, user_id)
);
CREATE TABLE IF NOT EXISTS document_type_grants (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
doc_type_id BIGINT NOT NULL REFERENCES document_types(id) ON DELETE CASCADE,
group_id BIGINT NOT NULL REFERENCES permission_groups(id) ON DELETE CASCADE,
access TEXT NOT NULL DEFAULT 'read' CHECK (access IN ('read','write')),
UNIQUE (doc_type_id, group_id)
);
CREATE TABLE IF NOT EXISTS tag_grants (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
tag_id BIGINT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
group_id BIGINT NOT NULL REFERENCES permission_groups(id) ON DELETE CASCADE,
access TEXT NOT NULL DEFAULT 'read' CHECK (access IN ('read','write')),
UNIQUE (tag_id, group_id)
);
CREATE TABLE IF NOT EXISTS document_grants (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
document_id BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
group_id BIGINT NOT NULL REFERENCES permission_groups(id) ON DELETE CASCADE,
access TEXT NOT NULL CHECK (access IN ('read','write','deny')),
granted_by BIGINT NOT NULL,
granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (document_id, group_id)
);
CREATE TABLE IF NOT EXISTS document_visibility (
document_id BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
group_id BIGINT NOT NULL REFERENCES permission_groups(id) ON DELETE CASCADE,
access TEXT NOT NULL CHECK (access IN ('read','write')),
PRIMARY KEY (document_id, group_id)
);
CREATE INDEX IF NOT EXISTS idx_doc_visibility_group ON document_visibility(group_id, document_id);
CREATE INDEX IF NOT EXISTS idx_pgm_user ON permission_group_members(user_id, group_id);
@@ -0,0 +1,53 @@
-- PROJ: external-share-links
-- Doku-only (siehe README.md in diesem Verzeichnis) — die tatsächliche
-- Ausführung passiert idempotent über internal/storage/shares.go
-- initSharesSchema(), aufgerufen aus (*Store).New().
--
-- Externe Share-Links: tenant-scoped, ablaufender, optional passwortgeschützter
-- öffentlicher Link auf genau EIN Dokument.
-- * Token: 32 Byte crypto/rand, base64url; wird beim Erzeugen genau EINMAL
-- zurückgegeben. Persistiert wird NUR der SHA-256-Hash (token_hash). Der
-- öffentliche Abruf sucht immer über token_hash, nie über id.
-- * expires_at ist Pflicht (kein unbegrenzter Share). password_hash optional
-- (bcrypt, Cost 12).
-- * Kein Hard-Delete: Revoke setzt nur revoked_at/revoked_by.
-- * Prüfreihenfolge beim öffentlichen Abruf: revoked -> expired ->
-- max_accesses erreicht -> Passwort -> ausliefern (access_count++ atomar
-- mit WHERE-Guard gegen Race). Datei wird serverseitig aus dem WORM-Store
-- gestreamt; storage_path/content_hash NIE im Response.
-- * Jeder Zugriffsversuch (Erfolg/Fehlschlag) landet in
-- document_share_accesses mit passendem result-Wert; die öffentlichen
-- Endpunkte sind zusätzlich per-IP rate-limited (Token-Bucket in-memory).
--
-- Hinweis: tenant_id / created_by / revoked_by tragen bewusst KEINE FK auf
-- tenants(id) bzw. users(id) — konsistent mit documents/taxonomy/permissions
-- (plain BIGINT) und weil tenants/users von anderen Stores NACH storage.New()
-- angelegt werden (Reihenfolge in cmd/archivdms/main.go). document_id behält
-- seine FK, da documents die eigene Tabelle dieses Stores ist.
CREATE TABLE IF NOT EXISTS document_shares (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
document_id BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
created_by BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
max_accesses INT,
access_count INT NOT NULL DEFAULT 0,
password_hash TEXT,
revoked_at TIMESTAMPTZ,
revoked_by BIGINT
);
CREATE INDEX IF NOT EXISTS idx_document_shares_document ON document_shares(document_id);
CREATE INDEX IF NOT EXISTS idx_document_shares_tenant ON document_shares(tenant_id);
CREATE TABLE IF NOT EXISTS document_share_accesses (
id BIGSERIAL PRIMARY KEY,
share_id BIGINT NOT NULL REFERENCES document_shares(id) ON DELETE CASCADE,
accessed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
ip_address INET,
user_agent TEXT,
result TEXT NOT NULL CHECK (result IN ('success','expired','revoked','max_reached','bad_password','rate_limited'))
);
CREATE INDEX IF NOT EXISTS idx_share_accesses_share ON document_share_accesses(share_id, accessed_at DESC);
@@ -0,0 +1,42 @@
-- PROJ: manticore-search-index-phase1
-- Doku-only. KEINE PostgreSQL-Schema-Änderung in dieser Phase — Postgres
-- (documents + Taxonomie + document_visibility) bleibt Source of Truth und
-- unverändert. Diese Datei dokumentiert nur den externen Volltext-Index.
--
-- Phase 1 der geplanten Manticore-Search-Integration (Hybrid BM25+Vektor
-- kommt später): NUR Schema + Sync-Layer, KEIN Such-Endpunkt.
--
-- Der Index läuft in Manticore Search (MySQL-Protokoll, Default Port 9306),
-- angesprochen über internal/index (github.com/go-sql-driver/mysql, CGO-frei).
-- Pro Mandant existiert eine RT-Tabelle documents_tenant_<tenant_id>, die
-- idempotent von ensureTable() angelegt wird:
--
-- CREATE TABLE documents_tenant_N (
-- doc_id string,
-- title text,
-- doc_type text,
-- correspondent text,
-- ocr_text text,
-- tags text,
-- tag_ids multi,
-- doc_type_id bigint,
-- correspondent_id bigint,
-- acl_group_ids multi,
-- retain_until_ts bigint,
-- created_ts bigint,
-- updated_ts bigint,
-- deleted uint
-- ) type='rt' morphology='lemmatize_de_all,stem_en'
--
-- Aktivierung nur wenn index.manticore_dsn in der config.yml gesetzt ist —
-- sonst ist der Indexer nil und alle Sync-Aufrufe sind No-ops.
--
-- Sync-Punkte (alle best-effort, Fehler werden nur geloggt, blockieren nie den
-- Haupt-Request — Postgres bleibt maßgeblich):
-- * Dokument-Upload/Create -> IndexSync
-- * RecomputeVisibility (ACL/Tags/DocType) -> IndexSync
-- * SetDocumentCorrespondent -> IndexSync
-- * Custom-Field-Werte setzen -> IndexSync
-- * SoftDeleteDocument (Papierkorb) -> Delete (nicht mehr auffindbar)
-- * RestoreDocument -> IndexSync (wieder auffindbar)
-- * ConfirmDeleteRequest (final/executed) -> Delete (GoBD: endgültig weg)
@@ -0,0 +1,52 @@
-- PROJ: classification-templates
-- Doku-only (siehe README.md in diesem Verzeichnis) — die tatsächliche
-- Ausführung passiert idempotent über
-- internal/storage/classification_templates.go
-- initClassificationTemplatesSchema(), aufgerufen aus (*Store).initSchema()
-- NACH initTaxonomySchema/initCustomFieldsSchema (FK auf document_types und
-- custom_field_defs).
--
-- Klassifizierungsvorlagen (classification templates): tenant-skopierte,
-- benannte Bündel aus Dokumenttyp, Tags, Custom-Field-Defaultwerten und einer
-- Aufbewahrungsdauer, die in einem Schritt auf ein Dokument angewendet werden
-- können. Bewusst KEINE persistente Kopplung template<->document (keine
-- template_id-Spalte auf documents): eine spätere Vorlagen-Änderung darf
-- Bestandsdokumente niemals rückwirkend verändern (GoBD-Nachvollziehbarkeit).
-- Die Anwendung wird ausschliesslich über einen Audit-Log-Eintrag festgehalten.
--
-- Retention-Regel (absolut, ohne Ausnahme): eine Vorlage kann retain_until nur
-- verlängern, niemals verkürzen — auch nicht mit overwrite=true. Ein
-- abgelehnter Verkürzungsversuch wird als Audit-Eintrag protokolliert.
CREATE TABLE IF NOT EXISTS classification_templates (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
name TEXT NOT NULL,
description TEXT,
doc_type_id BIGINT REFERENCES document_types(id) ON DELETE SET NULL,
retain_years INT,
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, name)
);
CREATE INDEX IF NOT EXISTS idx_classification_templates_tenant ON classification_templates(tenant_id);
CREATE INDEX IF NOT EXISTS idx_classification_templates_doc_type ON classification_templates(doc_type_id);
CREATE TABLE IF NOT EXISTS classification_template_tags (
template_id BIGINT NOT NULL REFERENCES classification_templates(id) ON DELETE CASCADE,
tag_id BIGINT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (template_id, tag_id)
);
CREATE TABLE IF NOT EXISTS classification_template_field_defaults (
template_id BIGINT NOT NULL REFERENCES classification_templates(id) ON DELETE CASCADE,
field_id BIGINT NOT NULL REFERENCES custom_field_defs(id) ON DELETE CASCADE,
value_text TEXT,
value_number NUMERIC,
value_date DATE,
value_bool BOOLEAN,
overwrite BOOLEAN NOT NULL DEFAULT false,
PRIMARY KEY (template_id, field_id)
);
@@ -0,0 +1,33 @@
-- PROJ: heuristische Metadaten-Vorschläge
-- Doku-only (siehe README.md in diesem Verzeichnis) — die tatsächliche
-- Ausführung passiert idempotent über
-- internal/storage/metadata_suggestions.go initMetadataSuggestionsSchema(),
-- aufgerufen aus (*Store).initSchema() NACH dem documents-/taxonomy-Schema
-- (die Vorschläge scoren Taxonomie-Entitäten gegen ein Dokument).
--
-- Metadaten-Vorschläge: regelbasiert (KEIN LLM). Pro Vorschlags-Lauf wird ein
-- Dokument gegen alle Taxonomie-Entitäten (Tags/Dokumenttypen/Korrespondenten)
-- fuzzy-gescored; Near-Misses oberhalb einer Schwelle (suggestionFloor), die
-- noch NICHT zugewiesen sind, werden als nicht-bindende Kandidaten vorgeschlagen.
-- Sieht der aktuelle Titel noch auto-generiert aus, wird zusätzlich ein
-- neu abgeleiteter Titel vorgeschlagen.
--
-- Diese Tabelle ist NUR ein Log/Cache dessen, was der heuristische Provider
-- vorgeschlagen hat. Das Anwenden eines akzeptierten Feldes läuft über die
-- normalen Edit-Endpunkte (PATCH title, tag-attach, ...), NIEMALS über diese
-- Zeile. status wird beim Review ('reviewed') gesetzt, unabhängig davon welche
-- Felder der Nutzer übernommen hat.
CREATE TABLE IF NOT EXISTS metadata_suggestions (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
document_id BIGINT NOT NULL,
provider TEXT NOT NULL DEFAULT 'heuristic',
requested_by BIGINT,
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
suggestion JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','reviewed')),
reviewed_at TIMESTAMPTZ,
reviewed_by BIGINT
);
CREATE INDEX IF NOT EXISTS idx_metadata_suggestions_document ON metadata_suggestions(document_id);
@@ -0,0 +1,57 @@
-- PROJ: workflows / Consumption-Regeln
-- Doku-only (siehe README.md in diesem Verzeichnis) — die tatsächliche
-- Ausführung passiert idempotent über internal/storage/workflows.go
-- initWorkflowsSchema(), aufgerufen aus (*Store).initSchema() NACH
-- initClassificationTemplatesSchema (eine Workflow-Action kann eine
-- Klassifizierungsvorlage referenzieren: apply_classification_template).
--
-- Workflows (Consumption-Regeln): tenant-skopierte Automatisierungsregeln, die
-- an einem Trigger-Punkt (MVP: on_upload) ausgewertet werden. Ein passender
-- Workflow führt seine geordneten Actions auf dem Dokument aus. Die
-- Bedingung ist ein JSONB-Condition-Tree (bool. Gruppen and/or + Leaf-Knoten
-- mit Feld/Algorithmus/Pattern, MVP-Verschachtelungstiefe 2). Jede Auswertung
-- wird dokument-skopiert in workflow_runs protokolliert (GoBD-Reproduzierbarkeit
-- parallel zum globalen internal/audit-Log).
--
-- Best-effort-Ausführung: eine fehlgeschlagene Action bricht den Upload niemals
-- ab — Fehler werden in workflow_runs.error festgehalten und die Schleife läuft
-- weiter (spiegelt die "never fail the upload"-Philosophie der OCR-/
-- Auto-Assign-Schritte in storeUploadedFile).
CREATE TABLE IF NOT EXISTS workflows (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
name TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT true,
trigger_type TEXT NOT NULL CHECK (trigger_type IN ('on_upload')),
condition_tree JSONB NOT NULL,
priority INT NOT NULL DEFAULT 100,
created_by BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(tenant_id, name)
);
CREATE INDEX IF NOT EXISTS idx_workflows_tenant ON workflows(tenant_id);
CREATE TABLE IF NOT EXISTS workflow_actions (
id BIGSERIAL PRIMARY KEY,
workflow_id BIGINT NOT NULL REFERENCES workflows(id) ON DELETE CASCADE,
step_order INT NOT NULL,
action_type TEXT NOT NULL CHECK (action_type IN
('add_tag','set_doc_type','set_correspondent','apply_classification_template','set_custom_field')),
action_config JSONB NOT NULL DEFAULT '{}'
);
CREATE INDEX IF NOT EXISTS idx_workflow_actions_workflow ON workflow_actions(workflow_id, step_order);
CREATE TABLE IF NOT EXISTS workflow_runs (
id BIGSERIAL PRIMARY KEY,
workflow_id BIGINT NOT NULL REFERENCES workflows(id) ON DELETE CASCADE,
tenant_id BIGINT NOT NULL,
document_id BIGINT,
triggered_at TIMESTAMPTZ NOT NULL DEFAULT now(),
matched BOOLEAN NOT NULL,
actions_applied JSONB,
error TEXT
);
CREATE INDEX IF NOT EXISTS idx_workflow_runs_workflow ON workflow_runs(workflow_id);
CREATE INDEX IF NOT EXISTS idx_workflow_runs_document ON workflow_runs(document_id);
@@ -0,0 +1,21 @@
-- PROJ: Freitext-Notizen pro Dokument (Paperless-ngx inspiriert)
-- Doku-only (siehe README.md in diesem Verzeichnis) — die tatsächliche
-- Ausführung passiert idempotent über
-- internal/storage/document_notes.go initDocumentNotesSchema(), aufgerufen aus
-- (*Store).initSchema() NACH dem documents-Schema (FK auf documents(id)).
--
-- Abgrenzung zu Custom-Fields: Custom-Fields sind strukturierte Metadaten;
-- eine Notiz ist reiner Freitext-Kommentar mit Autor + Zeitstempel. Notizen
-- sind KEINE GoBD-Belege, deshalb hartes DELETE (kein Soft-Delete). Create und
-- Delete werden dennoch im Audit-Log protokolliert (Nachvollziehbarkeit).
CREATE TABLE IF NOT EXISTS document_notes (
id BIGSERIAL PRIMARY KEY,
document_id BIGINT NOT NULL REFERENCES documents(id),
tenant_id BIGINT NOT NULL,
author_id BIGINT NOT NULL,
text TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_document_notes_document ON document_notes (document_id, tenant_id);
@@ -0,0 +1,25 @@
-- PROJ: Gespeicherte Suchansichten (SavedViews, Paperless-ngx inspiriert)
-- Doku-only (siehe README.md in diesem Verzeichnis) — die tatsächliche
-- Ausführung passiert idempotent über
-- internal/storage/saved_views.go initSavedViewsSchema(), aufgerufen aus
-- (*Store).initSchema() NACH initDocumentNotesSchema.
--
-- Nutzer speichern ihre aktuelle Such-/Filter-Query als benannte,
-- wiederverwendbare Ansicht. filters ist die serialisierte index.SearchQuery
-- (JSONB, 1:1 wieder einlesbar). Eine Ansicht ist privat für ihren Ersteller,
-- außer is_shared=true -> tenant-weit sichtbar, aber weiterhin nur vom
-- Ersteller änderbar/löschbar (WHERE id + tenant_id + user_id). Create/Update/
-- Delete werden im Audit-Log protokolliert (Nachvollziehbarkeit).
CREATE TABLE IF NOT EXISTS saved_views (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
name TEXT NOT NULL,
filters JSONB NOT NULL,
is_shared BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_saved_views_tenant_user ON saved_views (tenant_id, user_id);
CREATE INDEX IF NOT EXISTS idx_saved_views_tenant_shared ON saved_views (tenant_id, is_shared) WHERE is_shared;
@@ -0,0 +1,20 @@
-- PROJ: Pro-Mandant konfigurierbares Datumsformat für Platzhalter-Titel
-- Doku-only (siehe README.md in diesem Verzeichnis) — die tatsächliche
-- Ausführung passiert idempotent über
-- internal/tenantstore/store.go (*Store).initSchema().
--
-- Beim Upload wird, wenn kein Titel angegeben ist und OCR keine sinnvolle
-- Überschrift liefert, ein Platzhalter-Titel "Scan <Datum>" erzeugt
-- (titleFromOCRText in internal/api/document_handlers.go). Das Datumsformat
-- war bisher hart auf DD.MM.YYYY HH:mm codiert. Diese Spalte macht es pro
-- Mandant (nicht global, nicht pro Nutzer) konfigurierbar.
--
-- Gespeichert wird EINER von wenigen bekannten Format-Schlüsseln (kein roher
-- Go-Layout-String vom Nutzer), das Backend mappt den Schlüssel auf das
-- Go-Layout und validiert den Wert (400 bei Unbekannt). Erlaubte Schlüssel:
-- 'DD.MM.YYYY HH:mm' -> 02.01.2006 15:04 (deutsch, Default)
-- 'YYYY-MM-DD HH:mm' -> 2006-01-02 15:04 (ISO)
-- 'MM/DD/YYYY hh:mm AM/PM' -> 01/02/2006 03:04 PM (US)
ALTER TABLE tenants
ADD COLUMN IF NOT EXISTS scan_title_date_format TEXT NOT NULL DEFAULT 'DD.MM.YYYY HH:mm';
@@ -0,0 +1,19 @@
-- PROJ: Pro-Mandant konfigurierbares Präfix-Wort für Platzhalter-Titel
-- Doku-only (siehe README.md in diesem Verzeichnis) — die tatsächliche
-- Ausführung passiert idempotent über
-- internal/tenantstore/store.go (*Store).initSchema().
--
-- Beim Upload wird, wenn kein Titel angegeben ist und OCR keine sinnvolle
-- Überschrift liefert, ein Platzhalter-Titel "<Präfix> <Datum>" erzeugt
-- (titleFromOCRText in internal/api/document_handlers.go). Das Präfix-Wort
-- war bisher hart auf "Scan" codiert. Diese Spalte macht es pro Mandant
-- (nicht global, nicht pro Nutzer) konfigurierbar, z.B. "Beleg", "Import",
-- "Eingang".
--
-- Validierung im Backend (internal/tenantstore/store.go UpdateScanTitlePrefix):
-- nicht leer, maximal 40 Zeichen (400 bei Verstoß). Ergänzt Migration 015
-- (scan_title_date_format), beide Settings sind unabhängig änderbar über
-- GET/PUT /api/tenant-settings.
ALTER TABLE tenants
ADD COLUMN IF NOT EXISTS scan_title_prefix TEXT NOT NULL DEFAULT 'Scan';
@@ -0,0 +1,26 @@
-- PROJ: Pro-Mandant konfigurierbare Anbindung an einen EXTERNEN Ollama-Server
-- Doku-only (siehe README.md in diesem Verzeichnis) — die tatsächliche
-- Ausführung passiert idempotent über
-- internal/storage/documents.go (*Store).initSchema() -> initOllamaConfigSchema
-- (internal/storage/ollama_config.go). Source of Truth bleibt der Go-Code.
--
-- Ollama läuft NICHT lokal auf dem archivdms-Host, sondern als bereits
-- laufender externer Dienst; IP/Port kommt vom Mandanten-Admin. Diese Tabelle
-- speichert die Verbindung PRO Mandant (analog ldap_configs), nicht global.
-- Sie schaltet den optionalen 'ollama'-Provider der Metadaten-Vorschläge frei
-- (metadata_suggestions.provider), neben dem bestehenden 'heuristic'-Provider.
--
-- Validierung im Backend (UpsertOllamaConfig): bei enabled=true muss base_url
-- (http:// oder https:// Präfix) und model gesetzt sein, timeout_seconds in
-- [5,120]. base_url ist eine interne Netzwerk-URL, kein Secret, und wird von
-- GET/PUT /api/ollama-config normal zurückgegeben (kein Masking wie bei der
-- LDAP-Bind-Passwort-Spalte).
CREATE TABLE IF NOT EXISTS tenant_ollama_config (
tenant_id BIGINT PRIMARY KEY REFERENCES tenants(id),
enabled BOOLEAN NOT NULL DEFAULT false,
base_url TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '',
timeout_seconds INT NOT NULL DEFAULT 30,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
+31
View File
@@ -0,0 +1,31 @@
-- PROJ: Digitale Akte (digitaler Aktenordner) — Gruppierung von Dokumenten.
-- Doku-only (siehe README.md in diesem Verzeichnis) — die tatsächliche
-- Ausführung passiert idempotent über
-- internal/storage/documents.go (*Store).initSchema() -> initAktenSchema
-- (internal/storage/akten.go). Source of Truth bleibt der Go-Code.
--
-- Strikt 1:n zu Dokumenten via documents.akte_id (kein Join-Table): ein
-- Dokument gehört zu maximal einer Akte. ON DELETE SET NULL ist die
-- GoBD-Absicherung — eine Akte löschen kann strukturell nie Dokumente löschen,
-- sondern entkoppelt sie nur. Keine eigene ACL: die Sichtbarkeit einer Akte
-- erbt von den enthaltenen Dokumenten (EXISTS gegen document_visibility,
-- gleiches Pattern wie ListDocuments). Siehe project_akte_konzept_plan.md.
--
-- Reihenfolge: akten-Tabelle muss VOR der ALTER TABLE auf documents existieren,
-- da documents.akte_id auf akten(id) verweist. correspondent_id verweist auf
-- correspondents(id) (initTaxonomySchema läuft davor).
CREATE TABLE IF NOT EXISTS akten (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
titel TEXT NOT NULL,
beschreibung TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'offen' CHECK (status IN ('offen','geschlossen')),
correspondent_id BIGINT REFERENCES correspondents(id),
created_by BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
closed_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_akten_tenant ON akten (tenant_id);
ALTER TABLE documents ADD COLUMN IF NOT EXISTS akte_id BIGINT REFERENCES akten(id) ON DELETE SET NULL;
@@ -0,0 +1,23 @@
-- PROJ: Beleg-/Dokumentdatum (document_date) aus dem OCR-Text
-- Doku-only (siehe README.md in diesem Verzeichnis) — die tatsächliche
-- Ausführung passiert idempotent über
-- internal/storage/documents.go (*Store).initSchema().
--
-- Bisher wurde der WORM-Ablagepfad store/<tenant>/<yyyy>/<mm>/<hash>.<ext>
-- aus dem Scan-/Upload-Zeitpunkt (time.Now()) gebildet. Neu wird beim Upload
-- versucht, das echte Belegdatum (Rechnungs-/Dokumentdatum) aus dem OCR-Text
-- zu erkennen (extractDocumentDate in internal/api/date_extraction.go,
-- Regex-basiert: DD.MM.YYYY, DD.MM.YY, YYYY-MM-DD). Wird ein plausibles Datum
-- gefunden, bestimmt dessen Jahr/Monat den Ablagepfad; sonst Fallback auf den
-- Scan-Zeitpunkt wie bisher.
--
-- created_at bleibt unverändert der unveränderliche Scan-/Upload-Zeitstempel
-- (GoBD-Nachvollziehbarkeit). document_date ist ein SEPARATES, nullbares
-- Metadatenfeld: NULL bei Altbeständen und wenn kein Datum erkannt wurde.
--
-- Reprocess (POST /api/documents/{id}/reprocess) aktualisiert bei erneuter
-- OCR-Verarbeitung nur dieses Feld — die bereits WORM-gesperrte Datei und ihr
-- Ablagepfad werden NIEMALS nachträglich verschoben.
ALTER TABLE documents
ADD COLUMN IF NOT EXISTS document_date DATE;
@@ -0,0 +1,55 @@
-- PROJ: ML-Retraining-Klassifizierung Phase 1 (Naive-Bayes)
-- Doku-only (siehe README.md in diesem Verzeichnis) — die tatsächliche
-- Ausführung passiert idempotent über
-- internal/storage/ml_classifier.go (*Store).initMLClassifierSchema(),
-- eingehängt in internal/storage/documents.go (*Store).initSchema() NACH
-- initTaxonomySchema (setzt document_types/correspondents/tags/document_tags/
-- documents voraus).
--
-- Ergänzt die bestehende Regel-Engine (Taxonomie-Matching) um ein optionales,
-- pro Mandant trainiertes Naive-Bayes-Modell: aus bereits klassifizierten
-- Dokumenten werden Token-Häufigkeiten je Klasse (document_type/correspondent/
-- tag) gelernt (ml_classifier_tokens/ml_classifier_classes) und je Trainingslauf
-- protokolliert (ml_classifier_runs).
--
-- Provenienz-Spalten (assigned_via auf document_tags, doc_type_assigned_via/
-- correspondent_assigned_via auf documents) unterscheiden 'manual' (Nutzer
-- hat gesetzt), 'rule' (Regel-Engine) und 'ml_accepted' (vom Klassifizierer
-- vorgeschlagen und vom Nutzer akzeptiert). DEFAULT 'manual' bewusst konservativ
-- gewählt: bestehende Zeilen werden NICHT rückwirkend als 'rule' fehlklassifiziert
-- und fließen dadurch weiterhin normal als Trainingsdaten in den Klassifizierer ein.
CREATE TABLE IF NOT EXISTS ml_classifier_tokens (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('document_types','correspondents','tags')),
entity_id BIGINT NOT NULL,
token TEXT NOT NULL,
count BIGINT NOT NULL DEFAULT 0,
UNIQUE (tenant_id, kind, entity_id, token)
);
CREATE INDEX IF NOT EXISTS idx_ml_tokens_lookup ON ml_classifier_tokens(tenant_id, kind, token);
CREATE TABLE IF NOT EXISTS ml_classifier_classes (
tenant_id BIGINT NOT NULL,
kind TEXT NOT NULL,
entity_id BIGINT NOT NULL,
doc_count BIGINT NOT NULL DEFAULT 0,
total_tokens BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (tenant_id, kind, entity_id)
);
CREATE TABLE IF NOT EXISTS ml_classifier_runs (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ,
doc_count BIGINT NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'running' CHECK (status IN ('running','completed','failed','skipped_insufficient_data')),
error TEXT
);
CREATE INDEX IF NOT EXISTS idx_ml_classifier_runs_tenant ON ml_classifier_runs(tenant_id);
ALTER TABLE document_tags ADD COLUMN IF NOT EXISTS assigned_via TEXT NOT NULL DEFAULT 'manual' CHECK (assigned_via IN ('manual','rule','ml_accepted'));
ALTER TABLE documents ADD COLUMN IF NOT EXISTS doc_type_assigned_via TEXT NOT NULL DEFAULT 'manual' CHECK (doc_type_assigned_via IN ('manual','rule','ml_accepted'));
ALTER TABLE documents ADD COLUMN IF NOT EXISTS correspondent_assigned_via TEXT NOT NULL DEFAULT 'manual' CHECK (correspondent_assigned_via IN ('manual','rule','ml_accepted'));
@@ -0,0 +1,31 @@
-- PROJ: Titel-Vorlage für Klassifizierungsvorlagen + globaler Tenant-Default
-- Doku-only (siehe README.md in diesem Verzeichnis) — die tatsächliche
-- Ausführung passiert idempotent über die Go-initSchema-Aufrufe:
-- * classification_templates.title_template ->
-- internal/storage/classification_templates.go (*Store).initClassificationTemplatesSchema()
-- * tenants.default_title_template ->
-- internal/tenantstore/store.go (*Store).initSchema()
--
-- Beim Anwenden einer Klassifizierungsvorlage (POST /api/documents/{id}/apply-template
-- ODER Workflow-Aktion apply_classification_template, gemeinsamer Code-Pfad in
-- internal/storage/classification_templates_apply.go ApplyTemplate) wird der
-- Dokumenttitel aus einer Go-text/template-Vorlage abgeleitet:
-- 1. title_template der Vorlage (NULL = keine),
-- 2. sonst tenant-weites default_title_template (NULL = keins),
-- 3. sonst bleibt der bestehende Titel unangetastet.
-- Gesetzt wird NUR wenn documents.title_manually_set = false; title_manually_set
-- bleibt danach false (erneute Anwendung nach späterer Korrektur möglich). Bei
-- leerem/fehlerhaftem Render-Ergebnis Fallback auf den bestehenden Titel, nie
-- leerer String.
--
-- Platzhalter (Struct-Felder): {{.Correspondent}} {{.DocumentType}}
-- {{.Belegdatum}} {{.UploadDate}} {{.Tags}} {{.OCRTitle}}
-- Custom-Func dateFormat "02.01.2006" .Belegdatum (Go-Referenzdatum, leer bei
-- unbekanntem Datum). Validierung: internal/storage/classification_templates_title.go
-- ValidateTitleTemplate (Parse ohne Execute). Tenant-Default max. 500 Zeichen.
ALTER TABLE classification_templates
ADD COLUMN IF NOT EXISTS title_template TEXT;
ALTER TABLE tenants
ADD COLUMN IF NOT EXISTS default_title_template TEXT;
@@ -0,0 +1,53 @@
-- PROJ: retention-rules-engine
-- Doku-only (siehe README.md in diesem Verzeichnis) — die tatsächliche
-- Ausführung passiert idempotent über internal/storage/retention_rules.go
-- initRetentionRulesSchema(), aufgerufen aus (*Store).initSchema().
--
-- GoBD-Aufbewahrungsregeln (retention rules / "Disposition Schedules",
-- Namens-/Modellreferenz Alfresco, NICHT dessen Architektur):
-- * Eine Regel definiert pro Dokumenttyp (oder tenant-weit als Default mit
-- doc_type_id IS NULL) WIE LANGE ein Dokument aufbewahrt werden muss und
-- ab WELCHEM Stichtag (trigger_type) die Frist zählt.
-- * Der Batch-Job ApplyRetentionRules (CLI: `archivdms retention apply`)
-- berechnet retain_until und SETZT es auf documents — er löscht NIEMALS
-- und verkürzt eine bereits gesetzte Sperre NIE (GoBD: WORM nur
-- verlängerbar). Die eigentliche Vernichtung läuft weiter über den
-- Papierkorb + Vier-Augen-Workflow (007_trash.sql).
-- * trigger_type:
-- document_date -> documents.document_date, sonst created_at
-- upload_date -> documents.created_at
-- fixed_date -> trigger_reference als YYYY-MM-DD (einmaliger Stichtag)
-- event -> NICHT auto-berechnet (z.B. Geschäftsjahresende /
-- Vertragsende); Dokumente werden übersprungen, künftiger
-- Erweiterungspunkt.
-- * retain_until = Stichtag + retention_years Jahre + retention_days Tage.
-- Für non-event-Regeln muss mindestens eines von years/days > 0 sein
-- (im Go-Store validiert, nicht per CHECK).
-- * Präzedenz: doc-typ-spezifische Regel schlägt die tenant-weite Default-
-- Regel (doc_type_id IS NULL). UNIQUE(tenant_id, doc_type_id) erzwingt
-- höchstens eine Regel pro (Mandant, Dokumenttyp), daher keine
-- "strictest wins"-Logik nötig.
-- * requires_approval_for_destroy / dsgvo_conflict sind informative Flags für
-- das spätere Disposition-Frontend; die Vier-Augen-Pflicht selbst wird
-- bereits vom Trash-Flow erzwungen.
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;
@@ -0,0 +1,59 @@
-- PROJ: mandanten-job-queue (Phase 1+2)
-- Doku-only (siehe README.md in diesem Verzeichnis) — die tatsächliche
-- Ausführung passiert idempotent über
-- internal/storage/processing_jobs.go initProcessingJobsSchema(),
-- aufgerufen aus (*Store).initSchema() (zuletzt in der Kette, da die Tabelle
-- documents(id) FK-referenziert).
--
-- Mandanten-faire Verarbeitungs-Queue für die NACHGELAGERTE Dokument-
-- verarbeitung (OCR-Extraktion, Taxonomie-Autozuordnung, on_upload-Workflows).
-- Vorher lief das synchron im Upload-Request und erzeugte bei Batch-Scans /
-- SFTP-Massenuploads Lastspitzen.
--
-- * Kein Redis: die Queue ist eine ganz normale Postgres-Tabelle. Dokument-
-- INSERT und Job-INSERT laufen in EINER Transaktion
-- (CreateDocumentWithJob) — nie ein Dokument ohne Job, nie ein Job ohne
-- Dokument.
-- * WORM bleibt synchron im Request: Hash, Ablage unter
-- store/<tenant>/<yyyy>/<mm>/<sha256>.<ext> und chmod 0440 passieren VOR
-- dem INSERT. Der Job liest die archivierte Datei nur und schreibt
-- ausschließlich abgeleitete Metadaten.
-- * Dispatch: Round-Robin über die Mandanten (je Runde ein Job pro Mandant),
-- Locking über FOR UPDATE SKIP LOCKED.
-- * Retry: exponentielles Backoff über next_attempt_at (2^retry_count
-- Sekunden). Ab retry_count > max_retries (Default 5) bleibt der Job
-- dauerhaft 'failed' — kein Automatik-Retry mehr.
-- * derive_title: merkt sich, ob der Titel beim Staging nur ein Platzhalter
-- war und aus dem OCR-Text ersetzt werden darf. Ein vom Benutzer bzw. aus
-- dem SFTP-Dateinamen vorgegebener Titel wird nie überschrieben.
CREATE TABLE IF NOT EXISTS processing_jobs (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
document_id BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'queued', -- queued | processing | done | failed
retry_count INT NOT NULL DEFAULT 0,
derive_title BOOLEAN NOT NULL DEFAULT false,
error_message TEXT,
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
started_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE processing_jobs ADD COLUMN IF NOT EXISTS derive_title BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE processing_jobs ADD COLUMN IF NOT EXISTS next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now();
ALTER TABLE processing_jobs ADD COLUMN IF NOT EXISTS started_at TIMESTAMPTZ;
ALTER TABLE processing_jobs ADD COLUMN IF NOT EXISTS error_message TEXT;
CREATE INDEX IF NOT EXISTS idx_processing_jobs_dispatch
ON processing_jobs(tenant_id, status, next_attempt_at, created_at);
CREATE INDEX IF NOT EXISTS idx_processing_jobs_document
ON processing_jobs(document_id);
CREATE INDEX IF NOT EXISTS idx_processing_jobs_status
ON processing_jobs(status);
-- Anzeige-/Ablaufstatus am Dokument. Default 'done': der komplette Altbestand
-- wurde noch synchron verarbeitet und ist per Definition fertig — bewusst KEIN
-- Backfill-Skript, der Spalten-Default deckt alle Bestandszeilen ab.
ALTER TABLE documents ADD COLUMN IF NOT EXISTS processing_status TEXT NOT NULL DEFAULT 'done';
@@ -0,0 +1,46 @@
-- 024_ocr_words.sql
-- Documentation only — applied via internal/storage/ocr_words.go
-- (Store.initOCRWordsSchema), wired into Store.initSchema in documents.go.
--
-- Phase 2 of the OCR text-highlight/overlay feature (Phase 1: internal/ocr
-- gained Result.Words []WordBox, coordinates already mapped back into the
-- original uploaded file's coordinate space — see internal/ocr/coords.go).
-- This migration adds the table that persists those word boxes per
-- document, so a future Phase 3 read endpoint can serve them for a
-- search-highlight/overlay UI without re-running OCR.
--
-- No tenant_id column: access is always mediated through document_id (join
-- or verify against documents.tenant_id), never queried directly by tenant.
--
-- ON DELETE CASCADE on document_id: word boxes are a derived index over a
-- document's OCR text/original file, not a GoBD document themselves, so
-- cascading on hard-delete (trash workflow, see 007_trash.sql) is correct
-- and does not affect WORM retention of the document's own storage_path/
-- content_hash.
--
-- Every OCR (re)run (upload async job, manual reprocess endpoint, and the
-- `documents reprocess-all` CLI, which shares ReprocessDocument with the
-- endpoint) deletes this document's existing rows and re-inserts the fresh
-- set (storage.ReplaceOCRWords), so re-OCR never accumulates duplicates.
CREATE TABLE IF NOT EXISTS ocr_words (
id BIGSERIAL PRIMARY KEY,
document_id BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
page INTEGER NOT NULL DEFAULT 1,
block INTEGER NOT NULL DEFAULT 0,
par INTEGER NOT NULL DEFAULT 0,
line INTEGER NOT NULL DEFAULT 0,
word_text TEXT NOT NULL,
"left" INTEGER NOT NULL,
top INTEGER NOT NULL,
width INTEGER NOT NULL,
height INTEGER NOT NULL,
confidence DOUBLE PRECISION NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_ocr_words_document ON ocr_words (document_id);
-- Plain btree, not pg_trgm/GIN: initSchema cannot assume CREATE EXTENSION
-- privileges on the live server. Revisit if Phase 3 needs fuzzy/substring
-- prefiltering (would require enabling pg_trgm out-of-band first).
CREATE INDEX IF NOT EXISTS idx_ocr_words_word_text ON ocr_words (word_text);
@@ -0,0 +1,31 @@
-- 025_document_date_score.sql
-- Documentation only — applied via internal/storage/documents.go
-- (Store.initSchema), idempotent ALTER TABLE ADD COLUMN IF NOT EXISTS.
--
-- Persists the confidence score that internal/api's
-- extractDocumentDateWithScore (and its byte-synchronous storage-package
-- twin, documentDateFromTextWithScore in document_date.go) already computed
-- at runtime for documents.document_date, but previously discarded — only
-- the resulting date was ever written to the DB.
--
-- Purpose: quality gate for the planned Buchhaltungs-Pull-API (see
-- MEMORY.md project_belegdatum_und_buchhaltung) — only documents whose
-- document_date_score >= 0.75 should be automatically pullable.
--
-- Nullable, NUMERIC, no default and NO backfill run in this migration:
-- existing rows have an unknown (not zero) confidence for their existing
-- document_date, so NULL is the only correct value for them. A future
-- backfill pass, if ever needed, would have to re-run OCR-text scoring
-- against the stored ocr_text — deliberately out of scope here.
--
-- Value convention:
-- 0.4 - 0.9 automatic keyword-proximity heuristic (see
-- scoreForDatePosition / documentDateScoreForPosition)
-- 1.0 manually confirmed/overridden by a user via
-- PUT /api/documents/{id}/document-date — a manual override
-- always replaces any prior automatic score, it is never left
-- stale after the user's explicit correction.
-- NULL unknown / not yet computed (pre-existing rows, or
-- document_date itself cleared to NULL).
ALTER TABLE documents ADD COLUMN IF NOT EXISTS document_date_score NUMERIC;
@@ -0,0 +1,46 @@
-- 026_accounting_api_keys.sql
-- Documentation only — applied via
-- internal/storage/accounting_api_keys.go (Store.initAccountingAPIKeysSchema),
-- wired into storage.New() after initSharesSchema. Idempotent
-- (CREATE TABLE / CREATE INDEX IF NOT EXISTS).
--
-- Per-tenant API keys for the read-only Buchhaltungs-Pull-API
-- (GET /api/v1/accounting/documents[/{id}/file], siehe MEMORY.md
-- project_belegdatum_und_buchhaltung). A key is a tenant-level MACHINE
-- credential, not a user session: it grants read access to that tenant's
-- archived documents and to nothing else, which is why creating one requires
-- domain_admin.
--
-- Token handling mirrors document_shares (009_shares.sql) exactly:
-- * raw key = "adms_" + base64url(32 crypto/rand bytes), generated once
-- * returned to the caller EXACTLY once (POST response), never retrievable again
-- * only the hex SHA-256 hash is persisted (key_hash, UNIQUE)
-- * authentication always looks up by key_hash, never by id
--
-- Keys are never hard-deleted: revoking sets revoked_at, so the audit trail
-- (audit event accounting_pull, Detail carries "key:<id>") stays resolvable for
-- GoBD-Nachvollziehbarkeit. ResolveAccountingAPIKey filters revoked_at IS NULL
-- inside the same UPDATE ... RETURNING that refreshes last_used_at, so a
-- revoked key can never yield a tenant id.
--
-- No FK on tenant_id / created_by: consistent with the rest of the schema
-- (plain BIGINT), because tenants/users are owned by other stores that
-- initialise after storage.New().
CREATE TABLE IF NOT EXISTS accounting_api_keys (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
label TEXT NOT NULL DEFAULT '',
created_by BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
revoked_at TIMESTAMPTZ,
last_used_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_accounting_api_keys_tenant ON accounting_api_keys(tenant_id);
-- No new columns on documents: the pull query (internal/storage/accounting_pull.go)
-- reads existing columns only (document_date, document_date_score from
-- 025_document_date_score.sql, doc_type_id, correspondent_id, created_at) and
-- paginates by the (created_at, id) keyset, which idx_documents_tenant plus the
-- primary key already support.
+42
View File
@@ -0,0 +1,42 @@
# Migrations-Konvention
archivdms verwendet, wie archivmail, **kein externes Migrationstool**. Das
tatsächliche Schema wird idempotent zur Laufzeit von `initSchema()`-Funktionen
in den jeweiligen Store-Paketen angelegt/erweitert (`CREATE TABLE IF NOT
EXISTS`, `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`, `CREATE INDEX IF NOT
EXISTS`). Das ist sicher gegen mehrfaches Ausführen und gegen bereits
existierende Produktiv-Datenbanken.
Dieses Verzeichnis dokumentiert trotzdem jede Schemaänderung als nummerierte
`NNN_name.sql`-Datei — **rein informativ/Doku**, nicht ausführbar über ein
Migrationstool. Sie dient als:
- lesbare Historie, welche Änderung wann und warum kam
- Referenz für DBAs, die das Schema manuell nachvollziehen wollen
- Grundlage für Code-Review von Schemaänderungen (PR zeigt sowohl den Go-Diff
in `initSchema()` als auch die dazugehörige `NNN_name.sql`-Doku)
## Regeln
1. Jede neue Migration bekommt eine fortlaufende Nummer (`001`, `002`, ...).
2. Der Dateiname beschreibt die Änderung kurz (`002_add_reminders.sql`).
3. Der SQL-Inhalt muss exakt dem entsprechen, was `initSchema()` (oder das
jeweilige Store-Paket) zur Laufzeit ausführt.
4. Migrationen werden nie verändert oder gelöscht, nur ergänzt.
## Vorhandene Migrationen
- `001_initial.sql``tenants`, `users`, `token_blacklist`, `documents`,
`audit_log`
- `002_reminders.sql``reminders` (Wiedervorlage)
- `003_documents_unique_hash.sql``UNIQUE INDEX (tenant_id, content_hash)` auf `documents` (DB-seitiger Duplikatschutz für die Upload-Pipeline)
- `004_sftp_credentials.sql``sftp_credentials` (per-Mandant SFTP-Zugangsdaten für den eingebetteten SFTP-Server, `internal/sftpserver`)
- `005_taxonomy.sql``tags`/`document_types`/`correspondents`/`document_tags` (strukturierte Entitäten mit Matching-Algorithmus + Barcode-Wert) plus `documents.doc_type_id`/`correspondent_id`/`barcode_values`-ALTER
- `006_custom_fields.sql``custom_field_defs`/`document_type_fields`/`document_field_values` (benutzerdefinierte Felder pro Mandant/Dokumenttyp/Dokument)
- `007_trash.sql` — Papierkorb + gestaffeltes Löschkonzept: `documents.deleted_at`/`deleted_by`-ALTER (Soft-Delete) plus `document_delete_requests` (Vier-Augen-Workflow für finales WORM-Löschen, Retention-Prüfung, Tombstone)
- `011_classification_templates.sql``classification_templates`/`classification_template_tags`/`classification_template_field_defaults` (Klassifizierungsvorlagen: benannte Bündel aus Dokumenttyp/Tags/Custom-Field-Defaults/Aufbewahrungsdauer, anwendbar per Preview+Commit; keine persistente Kopplung an documents, Retention nur verlängerbar)
- `021_title_template.sql``classification_templates.title_template`-ALTER + `tenants.default_title_template`-ALTER (Titel-Vorlage pro Klassifizierungsvorlage mit tenant-weitem Default-Fallback; Go-text/template-Rendering in `classification_templates_title.go`, angewendet in `ApplyTemplate` für manuellen Endpoint UND Workflow-Trigger; setzt Titel nur wenn `title_manually_set=false` und lässt das Flag false)
- `022_retention_rules.sql``retention_rules` (GoBD-Aufbewahrungsregeln / "Disposition Schedules" pro Dokumenttyp bzw. tenant-weiter Default mit `doc_type_id IS NULL`): trigger_type (`document_date`/`upload_date`/`fixed_date`/`event`) + retention_years/days berechnen `documents.retain_until` über den Batch-Job `archivdms retention apply` (`initRetentionRulesSchema`/`ApplyRetentionRules`). Setzt nur `retain_until` (WORM-Sperre), löscht nie und verkürzt nie; die Vernichtung läuft weiter über 007_trash.sql. `event`-Regeln werden nicht auto-berechnet (künftiger Erweiterungspunkt)
- `024_ocr_words.sql``ocr_words` (word-level OCR bounding boxes per Dokument, Phase 2 des Text-Highlight/Overlay-Features, `internal/storage/ocr_words.go`/`initOCRWordsSchema`): `document_id` FK `ON DELETE CASCADE`, kein `tenant_id` (immer über `document_id` mediiert), `ReplaceOCRWords` löscht+re-inserted atomar bei jedem (Re-)OCR-Lauf (Upload-Job, `/reprocess`-Endpoint, `documents reprocess-all` CLI), damit kein Duplikat-Anhäufen entsteht
- `025_document_date_score.sql``documents.document_date_score` (NUMERIC, nullable, kein Backfill): persistiert die Konfidenz (0.4-0.9 automatische Keyword-Proximity-Heuristik, 1.0 = manuell bestätigt/überschrieben via `PUT /api/documents/{id}/document-date`) der bereits vorhandenen `document_date`-Erkennung, vorbereitend für die geplante Buchhaltungs-Pull-API (Score >= 0.75 als Pull-Filter)
- `020_ml_classifier.sql``ml_classifier_tokens`/`ml_classifier_classes`/`ml_classifier_runs` (Naive-Bayes-Retraining-Klassifizierung Phase 1, ergänzt die Regel-Engine) plus `document_tags.assigned_via`/`documents.doc_type_assigned_via`/`documents.correspondent_assigned_via`-ALTER (Provenienz: `manual`/`rule`/`ml_accepted`, Default `manual` zum Schutz bestehender Trainingsdaten)