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:
@@ -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);
|
||||
@@ -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()
|
||||
);
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user