FDN-02: datenmodell & migrationen

Kern-Entitaeten (Dokument, Datei-Revision, Ordner, Tag, Metadatenfeld) als
tenant-scoped SQL-Migration (Modell C, keine tenant_id-Spalte), FK auf
users(id) aus Core IAM-01 (Auth bleibt vollstaendig in Core). Eigener,
minimaler Migrations-Runner (internal/migrate, kein ORM) mit
schema_migrations-Tracking fuer Idempotenz. Seed-Skript fuer Entwicklung.

Auf 192.168.1.131 verifiziert: Migration auf leerer+bestehender DB,
Rollback stellt Vorzustand wieder her, 4 FK-Negativtests, Seed-Skript
end-to-end gegen frische DB. go.sum committet (Lock-Datei-Lehre aus dem
Ticket). build/vet/lint/test clean.

Siehe dms/docs/FDN-02-PRUEFPROTOKOLL.md fuer alle Pruefungsergebnisse.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
This commit is contained in:
sysops
2026-08-29 19:07:02 +02:00
co-authored by Claude Sonnet 5
parent 08d54715d4
commit 9d4c2bae4a
8 changed files with 618 additions and 0 deletions
@@ -0,0 +1,8 @@
DROP TABLE IF EXISTS document_metadata_values;
DROP TABLE IF EXISTS metadata_fields;
DROP TABLE IF EXISTS document_tags;
DROP TABLE IF EXISTS tags;
ALTER TABLE documents DROP CONSTRAINT IF EXISTS fk_documents_current_revision;
DROP TABLE IF EXISTS file_revisions;
DROP TABLE IF EXISTS documents;
DROP TABLE IF EXISTS folders;
@@ -0,0 +1,88 @@
-- Kern-Entitaeten des DMS (FDN-02): Dokument, Datei-Revision, Ordner, Tag,
-- Metadatenfeld. Laeuft in der DB EINES Mandanten (Modell C, siehe Core
-- TEN-01) — keine tenant_id-Spalte, die Tenant-Zugehoerigkeit ist implizit
-- durch die Datenbankverbindung gegeben. FK auf users(id) spiegelt das
-- Benutzer-Datenmodell aus Core IAM-01 (migrations/tenant/0001_users.up.sql
-- im NEXARCH-Core-Modul) — Auth/Benutzerverwaltung liegt vollstaendig in
-- Core (siehe "Nicht Bestandteil" in FDN-02), diese Migration dupliziert sie
-- NICHT, sondern setzt sie als bereits vorhanden voraus (users-Tabelle wird
-- durch Cores eigene Migration in derselben physischen Tenant-Datenbank
-- angelegt, bevor DMS-Migrationen laufen).
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE folders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
parent_folder_id UUID REFERENCES folders(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_folders_parent_folder_id ON folders(parent_folder_id);
CREATE TABLE documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
folder_id UUID REFERENCES folders(id) ON DELETE SET NULL,
title TEXT NOT NULL,
-- current_revision_id verweist erst NACH der Anlage von file_revisions
-- auf eine Zeile (siehe ALTER TABLE unten) — beim INSERT eines Dokuments
-- existiert noch keine Revision, daher NULLable und zirkulaer per
-- nachtraeglichem FOREIGN KEY statt Inline-Referenz geloest.
current_revision_id UUID,
created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX idx_documents_folder_id ON documents(folder_id);
CREATE INDEX idx_documents_created_by ON documents(created_by);
CREATE TABLE file_revisions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
revision_number INT NOT NULL,
-- storage_key ist ein Platzhalter fuer die Objekt-Storage-Abstraktion
-- (FDN-03, "Nicht Bestandteil" dieser Kachel) — hier nur die Spalte, die
-- spaetere Kachel legt fest, was tatsaechlich dahinter liegt.
storage_key TEXT NOT NULL,
checksum_sha256 TEXT NOT NULL,
size_bytes BIGINT NOT NULL,
mime_type TEXT NOT NULL,
created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (document_id, revision_number)
);
CREATE INDEX idx_file_revisions_document_id ON file_revisions(document_id);
ALTER TABLE documents
ADD CONSTRAINT fk_documents_current_revision
FOREIGN KEY (current_revision_id) REFERENCES file_revisions(id) ON DELETE SET NULL;
CREATE TABLE tags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE document_tags (
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (document_id, tag_id)
);
CREATE INDEX idx_document_tags_tag_id ON document_tags(tag_id);
CREATE TABLE metadata_fields (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
field_key TEXT NOT NULL UNIQUE,
label TEXT NOT NULL,
field_type TEXT NOT NULL CHECK (field_type IN ('text', 'number', 'date', 'bool', 'select')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE document_metadata_values (
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
field_id UUID NOT NULL REFERENCES metadata_fields(id) ON DELETE CASCADE,
value TEXT NOT NULL,
PRIMARY KEY (document_id, field_id)
);
CREATE INDEX idx_document_metadata_values_field_id ON document_metadata_values(field_id);
+43
View File
@@ -0,0 +1,43 @@
-- Entwicklungs-Seed (Akzeptanzkriterium 3): legt einen Beispielordner, ein
-- Beispieldokument mit einer Revision, ein Tag und ein Metadatenfeld an.
-- Setzt voraus, dass mindestens ein Benutzer existiert (Core IAM-01 legt
-- users an, DMS tut das nicht selbst - siehe "Nicht Bestandteil" in
-- FDN-02) - schlaegt sonst absichtlich mit einer sprechenden Fehlermeldung
-- fehl statt einen Platzhalter-Benutzer anzulegen, den DMS gar nicht
-- verwalten darf.
DO $$
DECLARE
seed_user_id UUID;
seed_folder_id UUID;
seed_document_id UUID;
BEGIN
SELECT id INTO seed_user_id FROM users ORDER BY created_at LIMIT 1;
IF seed_user_id IS NULL THEN
RAISE EXCEPTION 'dev_seed.sql: keine Zeile in users gefunden - zuerst Core-Seed (IAM-01) ausfuehren';
END IF;
INSERT INTO folders (name, created_by) VALUES ('Beispielordner', seed_user_id)
RETURNING id INTO seed_folder_id;
INSERT INTO documents (folder_id, title, created_by) VALUES (seed_folder_id, 'Beispieldokument', seed_user_id)
RETURNING id INTO seed_document_id;
INSERT INTO file_revisions (document_id, revision_number, storage_key, checksum_sha256, size_bytes, mime_type, created_by)
VALUES (seed_document_id, 1, 'dev-seed/beispiel.pdf', repeat('0', 64), 12345, 'application/pdf', seed_user_id);
UPDATE documents SET current_revision_id = (
SELECT id FROM file_revisions WHERE document_id = seed_document_id AND revision_number = 1
) WHERE id = seed_document_id;
INSERT INTO tags (name) VALUES ('Beispiel-Tag')
ON CONFLICT (name) DO NOTHING;
INSERT INTO document_tags (document_id, tag_id)
SELECT seed_document_id, id FROM tags WHERE name = 'Beispiel-Tag';
INSERT INTO metadata_fields (field_key, label, field_type) VALUES ('rechnungsnummer', 'Rechnungsnummer', 'text')
ON CONFLICT (field_key) DO NOTHING;
INSERT INTO document_metadata_values (document_id, field_id, value)
SELECT seed_document_id, id, 'RE-2026-0001' FROM metadata_fields WHERE field_key = 'rechnungsnummer';
END $$;