From 9d4c2bae4a6787c3f00ad19b899d0aec347b2390 Mon Sep 17 00:00:00 2001 From: sysops Date: Sat, 29 Aug 2026 19:07:02 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ --- dms/docs/FDN-02-PRUEFPROTOKOLL.md | 71 ++++++ dms/go.mod | 11 + dms/go.sum | 28 +++ dms/internal/migrate/migrate.go | 165 ++++++++++++++ dms/internal/migrate/migrate_test.go | 204 ++++++++++++++++++ dms/migrations/tenant/0001_documents.down.sql | 8 + dms/migrations/tenant/0001_documents.up.sql | 88 ++++++++ dms/migrations/tenant/seed/dev_seed.sql | 43 ++++ 8 files changed, 618 insertions(+) create mode 100644 dms/docs/FDN-02-PRUEFPROTOKOLL.md create mode 100644 dms/go.sum create mode 100644 dms/internal/migrate/migrate.go create mode 100644 dms/internal/migrate/migrate_test.go create mode 100644 dms/migrations/tenant/0001_documents.down.sql create mode 100644 dms/migrations/tenant/0001_documents.up.sql create mode 100644 dms/migrations/tenant/seed/dev_seed.sql diff --git a/dms/docs/FDN-02-PRUEFPROTOKOLL.md b/dms/docs/FDN-02-PRUEFPROTOKOLL.md new file mode 100644 index 0000000..ff9e6e0 --- /dev/null +++ b/dms/docs/FDN-02-PRUEFPROTOKOLL.md @@ -0,0 +1,71 @@ +# FDN-02 – Prüfprotokoll: Datenmodell & Migrationen + +Welle 2. Voraussetzung: FDN-01 (Status "Fertig"). + +## Datenmodell + +`migrations/tenant/0001_documents.up.sql` — läuft in der physisch isolierten +Tenant-Datenbank (Modell C, siehe Core TEN-01), keine `tenant_id`-Spalte. + +| Entität | Tabelle | Beziehungen | +|---|---|---| +| Ordner | `folders` | selbstreferenzierend (`parent_folder_id`), `created_by` → `users(id)` | +| Dokument | `documents` | `folder_id` → `folders`, `current_revision_id` → `file_revisions`, `created_by` → `users(id)` | +| Datei-Revision | `file_revisions` | `document_id` → `documents`, `created_by` → `users(id)`, `UNIQUE(document_id, revision_number)` | +| Tag | `tags` | — | +| Tag-Zuordnung | `document_tags` | `document_id` → `documents`, `tag_id` → `tags` | +| Metadatenfeld | `metadata_fields` | — | +| Metadatenwert | `document_metadata_values` | `document_id` → `documents`, `field_id` → `metadata_fields` | + +`created_by`/Benutzerbezug referenziert `users(id)` aus Core IAM-01 (Auth +liegt vollständig in Core, siehe "Nicht Bestandteil") — DMS legt `users` +nicht selbst an, setzt die Tabelle als bereits vorhanden voraus (dieselbe +physische Tenant-Datenbank). + +**Indizes:** `folders(parent_folder_id)`, `documents(folder_id)`, +`documents(created_by)`, `file_revisions(document_id)`, +`document_tags(tag_id)`, `document_metadata_values(field_id)`. + +## Migrationsmechanik + +`internal/migrate` — eigenständiger, minimaler Runner (kein ORM, +`*.up.sql`/`*.down.sql`-Paare), `schema_migrations`-Tabelle als +Fortschrittsspeicher (dasselbe Prinzip wie Core, hier eigenständig +implementiert, da DMS ein eigenes Go-Modul ist und Cores `internal/`-Pakete +nicht importieren kann). + +## Prüfungen + +| # | Prüfung | Ergebnis | +|---|---|---| +| 1 | Migration auf leerer DB und auf bestehender DB getestet | **bestanden** — `TestUp_OnEmptyAndExistingDB`: erster Lauf legt alle 7 Tabellen an, zweiter Lauf gegen dieselbe (jetzt bestehende) DB wendet 0 neue Migrationen an (über `schema_migrations` erkannt) | +| 2 | Rollback stellt Vorzustand wieder her | **bestanden** — `TestDownOne_RestoresPreviousState`: nach `DownOne` existiert keine der 7 Tabellen mehr, zweiter `DownOne`-Aufruf ohne verbleibende Migration liefert korrekt leeren String statt Fehler | +| 3 | Fremdschlüssel-Constraints durch Negativtests belegt | **bestanden** — `TestForeignKeyConstraints_RejectInvalidReferences`, 4 Fälle: Dokument mit unbekanntem Ordner, unbekanntem Ersteller, Datei-Revision mit unbekanntem Dokument, Tag-Zuordnung mit unbekanntem Tag — alle vier korrekt abgewiesen | + +Zusätzlich (Akzeptanzkriterium 3, Seed-Datensatz): `migrations/tenant/seed/dev_seed.sql` +manuell gegen eine frische Test-DB mit einer `users`-Zeile ausgeführt (siehe +Sitzungsprotokoll) — legt Ordner, Dokument mit Revision, Tag und +Metadatenfeld+-wert an, per Abfrage bestätigt (`Beispieldokument`, +`Beispiel-Tag`, `rechnungsnummer` vorhanden). Schlägt bewusst mit +sprechender Fehlermeldung fehl, wenn noch kein Benutzer existiert (DMS legt +`users` nicht selbst an). + +## Bekannte Fehler vermeiden (aus Ticket) + +„Fehlende Lock-/Sum-Datei blockiert CI" — `go.sum` ist committet (siehe +`git status`/Commit-Diff), `go mod tidy` auf 192.168.1.131 ausgeführt und +Ergebnis übernommen. + +## Build/Test-Ergebnis (192.168.1.131) + +``` +go build ./... -> clean +go vet ./... -> clean +make lint -> clean (golangci-lint) +go test ./... -v -count=1 -> 3/3 Pakete mit Tests ok (cmd/app, internal/migrate), 0 Fehlschläge +``` + +## Gesamtergebnis + +**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen +erfüllt und belegt. diff --git a/dms/go.mod b/dms/go.mod index 717bba7..a1222a6 100644 --- a/dms/go.mod +++ b/dms/go.mod @@ -1,3 +1,14 @@ module gitea.perlbach24.de/scripte/nexarch/dms go 1.22 + +require github.com/jackc/pgx/v5 v5.6.0 + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + golang.org/x/crypto v0.17.0 // indirect + golang.org/x/sync v0.1.0 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/dms/go.sum b/dms/go.sum new file mode 100644 index 0000000..5c39671 --- /dev/null +++ b/dms/go.sum @@ -0,0 +1,28 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/dms/internal/migrate/migrate.go b/dms/internal/migrate/migrate.go new file mode 100644 index 0000000..da643a9 --- /dev/null +++ b/dms/internal/migrate/migrate.go @@ -0,0 +1,165 @@ +// Package migrate implementiert FDN-02s Migrationsmechanik: versionierte, +// rueckrollbare SQL-Migrationsdateien (kein ORM), mit einer +// schema_migrations-Tabelle als Fortschrittsspeicher — dasselbe Prinzip wie +// NEXARCH Core (internal/migrate), hier eigenstaendig implementiert, da DMS +// ein eigenes Go-Modul ist und Cores internal/-Pakete nicht importieren +// kann. +package migrate + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// Migration ist eine einzelne versionierte Migrationsdatei. +type Migration struct { + Version string // Dateiname ohne .up.sql/.down.sql, z.B. "0001_documents" + UpSQL string + DownSQL string +} + +// Load liest alle *.up.sql/*.down.sql-Paare aus dir, sortiert nach +// Dateiname (Akzeptanzkriterium: Migrationen sind versioniert). +func Load(dir string) ([]Migration, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("migrationsverzeichnis %q lesen: %w", dir, err) + } + var versions []string + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".up.sql") { + continue + } + versions = append(versions, strings.TrimSuffix(e.Name(), ".up.sql")) + } + sort.Strings(versions) + + migrations := make([]Migration, 0, len(versions)) + for _, v := range versions { + up, err := os.ReadFile(filepath.Join(dir, v+".up.sql")) + if err != nil { + return nil, fmt.Errorf("migration %q: up.sql lesen: %w", v, err) + } + down, err := os.ReadFile(filepath.Join(dir, v+".down.sql")) + if err != nil { + return nil, fmt.Errorf("migration %q: down.sql lesen (jede Migration braucht ein Rollback): %w", v, err) + } + migrations = append(migrations, Migration{Version: v, UpSQL: string(up), DownSQL: string(down)}) + } + return migrations, nil +} + +func ensureTrackingTable(ctx context.Context, pool *pgxpool.Pool) error { + _, err := pool.Exec(ctx, ` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version TEXT PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `) + if err != nil { + return fmt.Errorf("schema_migrations anlegen: %w", err) + } + return nil +} + +func appliedVersions(ctx context.Context, pool *pgxpool.Pool) (map[string]bool, error) { + rows, err := pool.Query(ctx, `SELECT version FROM schema_migrations`) + if err != nil { + return nil, fmt.Errorf("angewendete migrationen lesen: %w", err) + } + defer rows.Close() + + applied := map[string]bool{} + for rows.Next() { + var v string + if err := rows.Scan(&v); err != nil { + return nil, fmt.Errorf("migrationsversion lesen: %w", err) + } + applied[v] = true + } + return applied, rows.Err() +} + +// Up wendet alle noch nicht angewendeten Migrationen in Reihenfolge an +// (Akzeptanzkriterium 2: vorwaerts ausfuehrbar) — bereits angewendete +// werden uebersprungen, damit Up auf einer leeren UND auf einer bestehenden +// DB funktioniert (Pruefung 1). +func Up(ctx context.Context, pool *pgxpool.Pool, migrations []Migration) (applied []string, err error) { + if err := ensureTrackingTable(ctx, pool); err != nil { + return nil, err + } + already, err := appliedVersions(ctx, pool) + if err != nil { + return nil, err + } + + for _, m := range migrations { + if already[m.Version] { + continue + } + tx, err := pool.Begin(ctx) + if err != nil { + return applied, fmt.Errorf("transaktion fuer %q starten: %w", m.Version, err) + } + if _, err := tx.Exec(ctx, m.UpSQL); err != nil { + _ = tx.Rollback(ctx) + return applied, fmt.Errorf("migration %q anwenden: %w", m.Version, err) + } + if _, err := tx.Exec(ctx, `INSERT INTO schema_migrations (version) VALUES ($1)`, m.Version); err != nil { + _ = tx.Rollback(ctx) + return applied, fmt.Errorf("migration %q als angewendet markieren: %w", m.Version, err) + } + if err := tx.Commit(ctx); err != nil { + return applied, fmt.Errorf("migration %q committen: %w", m.Version, err) + } + applied = append(applied, m.Version) + } + return applied, nil +} + +// DownOne macht die zuletzt angewendete Migration rueckgaengig +// (Akzeptanzkriterium 2: rueckwaerts ausfuehrbar) und liefert deren Version, +// oder "" falls keine Migration angewendet war. +func DownOne(ctx context.Context, pool *pgxpool.Pool, migrations []Migration) (version string, err error) { + if err := ensureTrackingTable(ctx, pool); err != nil { + return "", err + } + already, err := appliedVersions(ctx, pool) + if err != nil { + return "", err + } + + var last *Migration + for i := len(migrations) - 1; i >= 0; i-- { + if already[migrations[i].Version] { + last = &migrations[i] + break + } + } + if last == nil { + return "", nil + } + + tx, err := pool.Begin(ctx) + if err != nil { + return "", fmt.Errorf("transaktion fuer rollback von %q starten: %w", last.Version, err) + } + if _, err := tx.Exec(ctx, last.DownSQL); err != nil { + _ = tx.Rollback(ctx) + return "", fmt.Errorf("migration %q zurueckrollen: %w", last.Version, err) + } + if _, err := tx.Exec(ctx, `DELETE FROM schema_migrations WHERE version = $1`, last.Version); err != nil { + _ = tx.Rollback(ctx) + return "", fmt.Errorf("migration %q aus schema_migrations entfernen: %w", last.Version, err) + } + if err := tx.Commit(ctx); err != nil { + return "", fmt.Errorf("rollback von %q committen: %w", last.Version, err) + } + return last.Version, nil +} diff --git a/dms/internal/migrate/migrate_test.go b/dms/internal/migrate/migrate_test.go new file mode 100644 index 0000000..b9ab83f --- /dev/null +++ b/dms/internal/migrate/migrate_test.go @@ -0,0 +1,204 @@ +package migrate + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// usersFixtureSQL spiegelt Core IAM-01s reales Schema +// (migrations/tenant/0001_users.up.sql im NEXARCH-Core-Modul) - DMS ist ein +// eigenes Go-Modul und kann Cores Migrationsdateien nicht importieren, daher +// hier als Testfixture kopiert, NICHT als Produktionsmigration (DMS legt +// users nicht selbst an, siehe "Nicht Bestandteil" in FDN-02). +const usersFixtureSQL = ` +CREATE EXTENSION IF NOT EXISTS pgcrypto; +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +` + +func setupTest(t *testing.T) (*pgxpool.Pool, []Migration) { + t.Helper() + dsn := os.Getenv("TEST_TENANT_DSN") + if dsn == "" { + t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen") + } + ctx := context.Background() + + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("pool: %v", err) + } + t.Cleanup(func() { pool.Close() }) + + if _, err := pool.Exec(ctx, usersFixtureSQL); err != nil { + t.Fatalf("users-fixture anlegen: %v", err) + } + if _, err := pool.Exec(ctx, ` + INSERT INTO users (email, name) VALUES ('seed@example.test', 'Seed-Benutzer') + ON CONFLICT (email) DO NOTHING + `); err != nil { + t.Fatalf("seed-benutzer anlegen: %v", err) + } + + migrations, err := Load(migrationsDir(t)) + if err != nil { + t.Fatalf("migrationen laden: %v", err) + } + return pool, migrations +} + +func migrationsDir(t *testing.T) string { + t.Helper() + wd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + return filepath.Join(wd, "..", "..", "migrations", "tenant") +} + +func tableExists(t *testing.T, ctx context.Context, pool *pgxpool.Pool, name string) bool { + t.Helper() + var exists bool + if err := pool.QueryRow(ctx, ` + SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = $1) + `, name).Scan(&exists); err != nil { + t.Fatalf("tabellenexistenz von %q pruefen: %v", name, err) + } + return exists +} + +// TestUp_OnEmptyAndExistingDB ist Pruefung 1: Migration auf leerer DB und +// auf bestehender DB getestet. +func TestUp_OnEmptyAndExistingDB(t *testing.T) { + pool, migrations := setupTest(t) + ctx := context.Background() + + applied, err := Up(ctx, pool, migrations) + if err != nil { + t.Fatalf("up (leere db): %v", err) + } + if len(applied) == 0 { + t.Fatal("erwartet mindestens 1 angewendete migration auf leerer db") + } + for _, table := range []string{"folders", "documents", "file_revisions", "tags", "document_tags", "metadata_fields", "document_metadata_values"} { + if !tableExists(t, ctx, pool, table) { + t.Fatalf("tabelle %q existiert nach Up nicht", table) + } + } + + // Zweiter Up-Lauf gegen die JETZT BESTEHENDE db - muss ohne Fehler + // durchlaufen und darf nichts erneut anwenden (idempotent ueber + // schema_migrations). + appliedAgain, err := Up(ctx, pool, migrations) + if err != nil { + t.Fatalf("up (bestehende db, zweiter lauf): %v", err) + } + if len(appliedAgain) != 0 { + t.Fatalf("zweiter Up-Lauf haette 0 neue migrationen anwenden sollen, hat %d", len(appliedAgain)) + } +} + +// TestDownOne_RestoresPreviousState ist Pruefung 2: Rollback stellt den +// Vorzustand wieder her. +func TestDownOne_RestoresPreviousState(t *testing.T) { + pool, migrations := setupTest(t) + ctx := context.Background() + + if _, err := Up(ctx, pool, migrations); err != nil { + t.Fatalf("up: %v", err) + } + if !tableExists(t, ctx, pool, "documents") { + t.Fatal("voraussetzung nicht erfuellt: documents sollte nach Up existieren") + } + + version, err := DownOne(ctx, pool, migrations) + if err != nil { + t.Fatalf("downone: %v", err) + } + if version == "" { + t.Fatal("erwartet zurueckgerollte version, habe leeren string") + } + + for _, table := range []string{"folders", "documents", "file_revisions", "tags", "document_tags", "metadata_fields", "document_metadata_values"} { + if tableExists(t, ctx, pool, table) { + t.Fatalf("tabelle %q existiert nach Rollback noch - Vorzustand nicht wiederhergestellt", table) + } + } + + // Erneutes DownOne ohne verbleibende angewendete Migration liefert "". + version2, err := DownOne(ctx, pool, migrations) + if err != nil { + t.Fatalf("downone (nichts mehr anzuwenden): %v", err) + } + if version2 != "" { + t.Fatalf("erwartet leeren string bei leerer schema_migrations, habe %q", version2) + } +} + +// TestForeignKeyConstraints_RejectInvalidReferences ist Pruefung 3: +// Fremdschluessel-Constraints durch Negativtests belegt. +func TestForeignKeyConstraints_RejectInvalidReferences(t *testing.T) { + pool, migrations := setupTest(t) + ctx := context.Background() + if _, err := Up(ctx, pool, migrations); err != nil { + t.Fatalf("up: %v", err) + } + + var userID string + if err := pool.QueryRow(ctx, `SELECT id FROM users LIMIT 1`).Scan(&userID); err != nil { + t.Fatalf("seed-benutzer lesen: %v", err) + } + + t.Run("dokument mit unbekanntem ordner wird abgewiesen", func(t *testing.T) { + _, err := pool.Exec(ctx, ` + INSERT INTO documents (folder_id, title, created_by) VALUES (gen_random_uuid(), 'x', $1) + `, userID) + if err == nil { + t.Fatal("insert mit unbekanntem folder_id haette scheitern muessen") + } + }) + + t.Run("dokument mit unbekanntem ersteller wird abgewiesen", func(t *testing.T) { + _, err := pool.Exec(ctx, ` + INSERT INTO documents (title, created_by) VALUES ('x', gen_random_uuid()) + `) + if err == nil { + t.Fatal("insert mit unbekanntem created_by haette scheitern muessen") + } + }) + + t.Run("datei-revision mit unbekanntem dokument wird abgewiesen", func(t *testing.T) { + _, err := pool.Exec(ctx, ` + INSERT INTO file_revisions (document_id, revision_number, storage_key, checksum_sha256, size_bytes, mime_type, created_by) + VALUES (gen_random_uuid(), 1, 'k', repeat('0',64), 1, 'text/plain', $1) + `, userID) + if err == nil { + t.Fatal("insert mit unbekanntem document_id haette scheitern muessen") + } + }) + + t.Run("tag-zuordnung mit unbekanntem tag wird abgewiesen", func(t *testing.T) { + var docID string + if err := pool.QueryRow(ctx, ` + INSERT INTO documents (title, created_by) VALUES ('fk-test-doc', $1) RETURNING id + `, userID).Scan(&docID); err != nil { + t.Fatalf("testdokument anlegen: %v", err) + } + _, err := pool.Exec(ctx, ` + INSERT INTO document_tags (document_id, tag_id) VALUES ($1, gen_random_uuid()) + `, docID) + if err == nil { + t.Fatal("insert mit unbekanntem tag_id haette scheitern muessen") + } + }) +} diff --git a/dms/migrations/tenant/0001_documents.down.sql b/dms/migrations/tenant/0001_documents.down.sql new file mode 100644 index 0000000..beca7bb --- /dev/null +++ b/dms/migrations/tenant/0001_documents.down.sql @@ -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; diff --git a/dms/migrations/tenant/0001_documents.up.sql b/dms/migrations/tenant/0001_documents.up.sql new file mode 100644 index 0000000..7159631 --- /dev/null +++ b/dms/migrations/tenant/0001_documents.up.sql @@ -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); diff --git a/dms/migrations/tenant/seed/dev_seed.sql b/dms/migrations/tenant/seed/dev_seed.sql new file mode 100644 index 0000000..08a00ed --- /dev/null +++ b/dms/migrations/tenant/seed/dev_seed.sql @@ -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 $$;