Workspace und Datenmodell

pnpm-Workspace mit web/, worker/, pdf/ und packages/db. Das Datenbankpaket ist
eigenständig, weil Web-App und Worker denselben Prisma-Client brauchen.

Schema nach docs/04-datenmodell.md, mit drei Abweichungen, die dort begründet
sind: CaseParticipant bekommt einen eigenen Schlüssel statt eines
zusammengesetzten mit NULL-Spalten, die Aktenzeichen laufen über CaseCounter
statt über eine Sequenz pro Jahr, und ServiceHeartbeat kommt dazu, damit
/api/health den Gateway-Zustand kennt, ohne Discord zu fragen.

Die zweite Migration enthält, was Prisma nicht ausdrücken kann:
- kein DELETE auf CaseEntry, CaseEntryVersion und AuditLog
- authorId, createdAt, caseId und type eines Eintrags sind unveränderlich
- an einer Eintragsversion darf nur minClearance angehoben werden
- Volltextsuche mit deutscher Konfiguration; geschwärzte Bereiche werden vor
  dem Indizieren entfernt, sonst verrät ein Treffer den Inhalt
- pg_notify bei Rollen- und Mapping-Änderungen

Acht Tests prüfen diese Regeln gegen eine echte Postgres-Instanz, nicht gegen
die Anwendungslogik.
This commit is contained in:
Harlekin
2026-09-01 00:01:29 +02:00
parent 72c47e74a6
commit 8f0644e053
20 changed files with 9085 additions and 0 deletions
@@ -0,0 +1,557 @@
-- CreateExtension
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
-- CreateEnum
CREATE TYPE "NotifyMode" AS ENUM ('ALL', 'OWN_CASES', 'MENTIONS', 'OFF');
-- CreateEnum
CREATE TYPE "CaseStatus" AS ENUM ('DRAFT', 'OPEN', 'DORMANT', 'REVIEW', 'CLOSED', 'ARCHIVED', 'SEALED');
-- CreateEnum
CREATE TYPE "EntryType" AS ENUM ('NOTE', 'INTERROGATION', 'EVIDENCE', 'STATUS_CHANGE', 'CORRECTION', 'SYSTEM');
-- CreateEnum
CREATE TYPE "EditPolicy" AS ENUM ('APPEND_ONLY', 'GRACE_WINDOW', 'OPEN');
-- CreateEnum
CREATE TYPE "ParticipantRole" AS ENUM ('LEAD', 'INVESTIGATOR', 'WITNESS', 'SUBJECT', 'OBSERVER');
-- CreateEnum
CREATE TYPE "LinkKind" AS ENUM ('RELATED', 'SUCCESSOR', 'MERGED_INTO');
-- CreateEnum
CREATE TYPE "ScanStatus" AS ENUM ('PENDING', 'CLEAN', 'BLOCKED', 'FAILED');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"username" TEXT NOT NULL,
"displayName" TEXT NOT NULL,
"avatarHash" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"firstSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"notifyMode" "NotifyMode" NOT NULL DEFAULT 'OWN_CASES',
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "UserRole" (
"userId" TEXT NOT NULL,
"discordRoleId" TEXT NOT NULL,
CONSTRAINT "UserRole_pkey" PRIMARY KEY ("userId","discordRoleId")
);
-- CreateTable
CREATE TABLE "RoleMapping" (
"discordRoleId" TEXT NOT NULL,
"label" TEXT NOT NULL,
"clearanceLevel" INTEGER NOT NULL DEFAULT 0,
"permissions" TEXT[],
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RoleMapping_pkey" PRIMARY KEY ("discordRoleId")
);
-- CreateTable
CREATE TABLE "ClearanceLevelConfig" (
"level" INTEGER NOT NULL,
"name" TEXT NOT NULL,
"hiddenBelow" BOOLEAN NOT NULL DEFAULT false,
"colorToken" TEXT NOT NULL,
CONSTRAINT "ClearanceLevelConfig_pkey" PRIMARY KEY ("level")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"userAgent" TEXT,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CaseTemplate" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"version" INTEGER NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"fields" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdById" TEXT,
CONSTRAINT "CaseTemplate_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Case" (
"id" TEXT NOT NULL,
"caseNumber" TEXT NOT NULL,
"title" TEXT NOT NULL,
"status" "CaseStatus" NOT NULL DEFAULT 'DRAFT',
"classification" INTEGER NOT NULL DEFAULT 1,
"templateId" TEXT NOT NULL,
"templateKey" TEXT NOT NULL,
"dueAt" TIMESTAMP(3),
"closedAt" TIMESTAMP(3),
"outcome" TEXT,
"createdById" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"searchVector" tsvector,
CONSTRAINT "Case_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CaseCounter" (
"year" INTEGER NOT NULL,
"lastNumber" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "CaseCounter_pkey" PRIMARY KEY ("year")
);
-- CreateTable
CREATE TABLE "CaseSection" (
"id" TEXT NOT NULL,
"caseId" TEXT NOT NULL,
"fieldKey" TEXT NOT NULL,
"minClearance" INTEGER NOT NULL DEFAULT 0,
"currentVersion" INTEGER NOT NULL DEFAULT 1,
"value" JSONB NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
"updatedById" TEXT NOT NULL,
CONSTRAINT "CaseSection_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CaseSectionVersion" (
"id" TEXT NOT NULL,
"sectionId" TEXT NOT NULL,
"version" INTEGER NOT NULL,
"value" JSONB NOT NULL,
"changeReason" TEXT,
"authorId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CaseSectionVersion_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CaseEntry" (
"id" TEXT NOT NULL,
"caseId" TEXT NOT NULL,
"type" "EntryType" NOT NULL,
"body" TEXT NOT NULL,
"minClearance" INTEGER NOT NULL DEFAULT 0,
"authorId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"correctsId" TEXT,
"version" INTEGER NOT NULL DEFAULT 1,
"editedAt" TIMESTAMP(3),
"editedById" TEXT,
"editVisible" BOOLEAN NOT NULL DEFAULT false,
"retractedAt" TIMESTAMP(3),
"retractedById" TEXT,
"retractionReason" TEXT,
CONSTRAINT "CaseEntry_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CaseEntryVersion" (
"id" TEXT NOT NULL,
"entryId" TEXT NOT NULL,
"version" INTEGER NOT NULL,
"body" TEXT NOT NULL,
"minClearance" INTEGER NOT NULL,
"changeReason" TEXT,
"editedById" TEXT NOT NULL,
"editedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CaseEntryVersion_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AppSetting" (
"id" INTEGER NOT NULL DEFAULT 1,
"entryEditPolicy" "EditPolicy" NOT NULL DEFAULT 'GRACE_WINDOW',
"entryEditGraceMinutes" INTEGER NOT NULL DEFAULT 15,
"entryEditMarkGrace" BOOLEAN NOT NULL DEFAULT false,
"updatedAt" TIMESTAMP(3) NOT NULL,
"updatedById" TEXT,
CONSTRAINT "AppSetting_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CaseParticipant" (
"id" TEXT NOT NULL,
"caseId" TEXT NOT NULL,
"userId" TEXT,
"personnelFileId" TEXT,
"role" "ParticipantRole" NOT NULL,
"addedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CaseParticipant_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CaseExclusion" (
"caseId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"reason" TEXT,
"addedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CaseExclusion_pkey" PRIMARY KEY ("caseId","userId")
);
-- CreateTable
CREATE TABLE "CaseLink" (
"fromId" TEXT NOT NULL,
"toId" TEXT NOT NULL,
"kind" "LinkKind" NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CaseLink_pkey" PRIMARY KEY ("fromId","toId","kind")
);
-- CreateTable
CREATE TABLE "PersonnelFile" (
"id" TEXT NOT NULL,
"userId" TEXT,
"displayName" TEXT NOT NULL,
"serviceNumber" TEXT,
"rank" TEXT,
"department" TEXT,
"status" TEXT,
"classification" INTEGER NOT NULL DEFAULT 1,
"fields" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"searchVector" tsvector,
CONSTRAINT "PersonnelFile_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Attachment" (
"id" TEXT NOT NULL,
"entryId" TEXT,
"caseId" TEXT,
"originalName" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"sizeBytes" INTEGER NOT NULL,
"sha256" TEXT NOT NULL,
"scanStatus" "ScanStatus" NOT NULL DEFAULT 'PENDING',
"scanMessage" TEXT,
"storageKey" TEXT NOT NULL,
"thumbKey" TEXT,
"blurhash" TEXT,
"uploadedById" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Attachment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Channel" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"minClearance" INTEGER NOT NULL DEFAULT 0,
"isArchived" BOOLEAN NOT NULL DEFAULT false,
"createdById" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Channel_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ChatMessage" (
"id" TEXT NOT NULL,
"caseId" TEXT,
"channelId" TEXT,
"authorId" TEXT NOT NULL,
"body" TEXT NOT NULL,
"replyToId" TEXT,
"editedAt" TIMESTAMP(3),
"withdrawnAt" TIMESTAMP(3),
"adoptedEntryId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ChatMessage_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ChatReaction" (
"messageId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"key" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ChatReaction_pkey" PRIMARY KEY ("messageId","userId","key")
);
-- CreateTable
CREATE TABLE "ReadMarker" (
"userId" TEXT NOT NULL,
"scopeType" TEXT NOT NULL,
"scopeId" TEXT NOT NULL,
"lastReadAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ReadMarker_pkey" PRIMARY KEY ("userId","scopeType","scopeId")
);
-- CreateTable
CREATE TABLE "Watch" (
"userId" TEXT NOT NULL,
"caseId" TEXT NOT NULL,
CONSTRAINT "Watch_pkey" PRIMARY KEY ("userId","caseId")
);
-- CreateTable
CREATE TABLE "ServiceHeartbeat" (
"service" TEXT NOT NULL,
"status" TEXT NOT NULL,
"detail" TEXT,
"at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ServiceHeartbeat_pkey" PRIMARY KEY ("service")
);
-- CreateTable
CREATE TABLE "AuditLog" (
"id" BIGSERIAL NOT NULL,
"at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"actorId" TEXT NOT NULL,
"action" TEXT NOT NULL,
"targetType" TEXT NOT NULL,
"targetId" TEXT NOT NULL,
"clearanceAtTime" INTEGER NOT NULL,
"meta" JSONB,
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "User_isActive_displayName_idx" ON "User"("isActive", "displayName");
-- CreateIndex
CREATE INDEX "UserRole_discordRoleId_idx" ON "UserRole"("discordRoleId");
-- CreateIndex
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
-- CreateIndex
CREATE INDEX "Session_expiresAt_idx" ON "Session"("expiresAt");
-- CreateIndex
CREATE INDEX "CaseTemplate_key_isActive_idx" ON "CaseTemplate"("key", "isActive");
-- CreateIndex
CREATE UNIQUE INDEX "CaseTemplate_key_version_key" ON "CaseTemplate"("key", "version");
-- CreateIndex
CREATE UNIQUE INDEX "Case_caseNumber_key" ON "Case"("caseNumber");
-- CreateIndex
CREATE INDEX "Case_status_updatedAt_idx" ON "Case"("status", "updatedAt" DESC);
-- CreateIndex
CREATE INDEX "Case_classification_idx" ON "Case"("classification");
-- CreateIndex
CREATE INDEX "Case_dueAt_idx" ON "Case"("dueAt");
-- CreateIndex
CREATE INDEX "Case_createdById_idx" ON "Case"("createdById");
-- CreateIndex
CREATE UNIQUE INDEX "CaseSection_caseId_fieldKey_key" ON "CaseSection"("caseId", "fieldKey");
-- CreateIndex
CREATE INDEX "CaseSectionVersion_sectionId_version_idx" ON "CaseSectionVersion"("sectionId", "version" DESC);
-- CreateIndex
CREATE UNIQUE INDEX "CaseSectionVersion_sectionId_version_key" ON "CaseSectionVersion"("sectionId", "version");
-- CreateIndex
CREATE INDEX "CaseEntry_caseId_createdAt_idx" ON "CaseEntry"("caseId", "createdAt" DESC);
-- CreateIndex
CREATE INDEX "CaseEntry_caseId_editedAt_idx" ON "CaseEntry"("caseId", "editedAt" DESC);
-- CreateIndex
CREATE INDEX "CaseEntry_authorId_idx" ON "CaseEntry"("authorId");
-- CreateIndex
CREATE INDEX "CaseEntry_correctsId_idx" ON "CaseEntry"("correctsId");
-- CreateIndex
CREATE INDEX "CaseEntryVersion_entryId_version_idx" ON "CaseEntryVersion"("entryId", "version" DESC);
-- CreateIndex
CREATE UNIQUE INDEX "CaseEntryVersion_entryId_version_key" ON "CaseEntryVersion"("entryId", "version");
-- CreateIndex
CREATE INDEX "CaseParticipant_caseId_idx" ON "CaseParticipant"("caseId");
-- CreateIndex
CREATE INDEX "CaseParticipant_userId_idx" ON "CaseParticipant"("userId");
-- CreateIndex
CREATE INDEX "CaseParticipant_personnelFileId_idx" ON "CaseParticipant"("personnelFileId");
-- CreateIndex
CREATE INDEX "CaseExclusion_userId_idx" ON "CaseExclusion"("userId");
-- CreateIndex
CREATE INDEX "CaseLink_toId_idx" ON "CaseLink"("toId");
-- CreateIndex
CREATE UNIQUE INDEX "PersonnelFile_serviceNumber_key" ON "PersonnelFile"("serviceNumber");
-- CreateIndex
CREATE INDEX "PersonnelFile_displayName_idx" ON "PersonnelFile"("displayName");
-- CreateIndex
CREATE INDEX "PersonnelFile_userId_idx" ON "PersonnelFile"("userId");
-- CreateIndex
CREATE INDEX "PersonnelFile_classification_idx" ON "PersonnelFile"("classification");
-- CreateIndex
CREATE INDEX "Attachment_sha256_idx" ON "Attachment"("sha256");
-- CreateIndex
CREATE INDEX "Attachment_scanStatus_idx" ON "Attachment"("scanStatus");
-- CreateIndex
CREATE INDEX "Attachment_caseId_idx" ON "Attachment"("caseId");
-- CreateIndex
CREATE INDEX "Attachment_entryId_idx" ON "Attachment"("entryId");
-- CreateIndex
CREATE INDEX "Attachment_uploadedById_createdAt_idx" ON "Attachment"("uploadedById", "createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "Channel_key_key" ON "Channel"("key");
-- CreateIndex
CREATE INDEX "Channel_isArchived_name_idx" ON "Channel"("isArchived", "name");
-- CreateIndex
CREATE INDEX "ChatMessage_caseId_createdAt_idx" ON "ChatMessage"("caseId", "createdAt" DESC);
-- CreateIndex
CREATE INDEX "ChatMessage_channelId_createdAt_idx" ON "ChatMessage"("channelId", "createdAt" DESC);
-- CreateIndex
CREATE INDEX "ChatMessage_authorId_idx" ON "ChatMessage"("authorId");
-- CreateIndex
CREATE INDEX "ChatMessage_replyToId_idx" ON "ChatMessage"("replyToId");
-- CreateIndex
CREATE INDEX "Watch_caseId_idx" ON "Watch"("caseId");
-- CreateIndex
CREATE INDEX "AuditLog_targetType_targetId_at_idx" ON "AuditLog"("targetType", "targetId", "at" DESC);
-- CreateIndex
CREATE INDEX "AuditLog_actorId_at_idx" ON "AuditLog"("actorId", "at" DESC);
-- CreateIndex
CREATE INDEX "AuditLog_action_at_idx" ON "AuditLog"("action", "at" DESC);
-- AddForeignKey
ALTER TABLE "UserRole" ADD CONSTRAINT "UserRole_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Case" ADD CONSTRAINT "Case_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "CaseTemplate"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CaseSection" ADD CONSTRAINT "CaseSection_caseId_fkey" FOREIGN KEY ("caseId") REFERENCES "Case"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CaseSectionVersion" ADD CONSTRAINT "CaseSectionVersion_sectionId_fkey" FOREIGN KEY ("sectionId") REFERENCES "CaseSection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CaseEntry" ADD CONSTRAINT "CaseEntry_caseId_fkey" FOREIGN KEY ("caseId") REFERENCES "Case"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CaseEntry" ADD CONSTRAINT "CaseEntry_correctsId_fkey" FOREIGN KEY ("correctsId") REFERENCES "CaseEntry"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CaseEntryVersion" ADD CONSTRAINT "CaseEntryVersion_entryId_fkey" FOREIGN KEY ("entryId") REFERENCES "CaseEntry"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CaseParticipant" ADD CONSTRAINT "CaseParticipant_caseId_fkey" FOREIGN KEY ("caseId") REFERENCES "Case"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CaseParticipant" ADD CONSTRAINT "CaseParticipant_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CaseParticipant" ADD CONSTRAINT "CaseParticipant_personnelFileId_fkey" FOREIGN KEY ("personnelFileId") REFERENCES "PersonnelFile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CaseExclusion" ADD CONSTRAINT "CaseExclusion_caseId_fkey" FOREIGN KEY ("caseId") REFERENCES "Case"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CaseExclusion" ADD CONSTRAINT "CaseExclusion_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CaseLink" ADD CONSTRAINT "CaseLink_fromId_fkey" FOREIGN KEY ("fromId") REFERENCES "Case"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CaseLink" ADD CONSTRAINT "CaseLink_toId_fkey" FOREIGN KEY ("toId") REFERENCES "Case"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PersonnelFile" ADD CONSTRAINT "PersonnelFile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Attachment" ADD CONSTRAINT "Attachment_entryId_fkey" FOREIGN KEY ("entryId") REFERENCES "CaseEntry"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Attachment" ADD CONSTRAINT "Attachment_caseId_fkey" FOREIGN KEY ("caseId") REFERENCES "Case"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChatMessage" ADD CONSTRAINT "ChatMessage_caseId_fkey" FOREIGN KEY ("caseId") REFERENCES "Case"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChatMessage" ADD CONSTRAINT "ChatMessage_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "Channel"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChatMessage" ADD CONSTRAINT "ChatMessage_replyToId_fkey" FOREIGN KEY ("replyToId") REFERENCES "ChatMessage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChatReaction" ADD CONSTRAINT "ChatReaction_messageId_fkey" FOREIGN KEY ("messageId") REFERENCES "ChatMessage"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ReadMarker" ADD CONSTRAINT "ReadMarker_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Watch" ADD CONSTRAINT "Watch_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Watch" ADD CONSTRAINT "Watch_caseId_fkey" FOREIGN KEY ("caseId") REFERENCES "Case"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,231 @@
-- Was Prisma nicht ausdrücken kann. Grundlage: docs/04-datenmodell.md.
-- Diese Datei wird beim Anlegen der Migration an die generierte SQL angehängt.
-- ===========================================================================
-- 1. Verlaufseinträge sind anhängend. Zweite Sicherung neben der Anwendung,
-- falls jemand direkt an der Datenbank hantiert.
-- ===========================================================================
CREATE RULE entry_no_delete AS ON DELETE TO "CaseEntry" DO INSTEAD NOTHING;
CREATE RULE entry_version_no_delete AS ON DELETE TO "CaseEntryVersion" DO INSTEAD NOTHING;
-- Die Versionstabelle darf nicht umgeschrieben werden. Einzige Ausnahme ist das
-- Anheben von minClearance: wird ein Eintrag höher klassifiziert, erben alle
-- bestehenden Versionen den neuen Wert, sonst liest jemand mit niedrigerer Stufe
-- den ursprünglichen Text einfach in der Historie. Siehe docs/05-akten-und-vorlagen.md.
-- Ein pauschales DO INSTEAD NOTHING auf UPDATE würde genau diese Nachführung
-- verhindern und damit ein Leck offen lassen.
CREATE OR REPLACE FUNCTION entry_version_guard() RETURNS trigger AS $$
BEGIN
IF NEW."id" IS DISTINCT FROM OLD."id"
OR NEW."entryId" IS DISTINCT FROM OLD."entryId"
OR NEW."version" IS DISTINCT FROM OLD."version"
OR NEW."body" IS DISTINCT FROM OLD."body"
OR NEW."changeReason" IS DISTINCT FROM OLD."changeReason"
OR NEW."editedById" IS DISTINCT FROM OLD."editedById"
OR NEW."editedAt" IS DISTINCT FROM OLD."editedAt" THEN
RAISE EXCEPTION 'CaseEntryVersion ist anhängend, nur minClearance darf nachgeführt werden';
END IF;
IF NEW."minClearance" < OLD."minClearance" THEN
RAISE EXCEPTION 'minClearance einer Version darf nur angehoben werden';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER entry_version_guard_trg
BEFORE UPDATE ON "CaseEntryVersion"
FOR EACH ROW EXECUTE FUNCTION entry_version_guard();
-- Autor und Entstehungszeit eines Eintrags sind unantastbar, ebenso Typ und Akte.
-- Wer das umgehen will, muss zurückziehen und neu schreiben, und genau das ist
-- dann auch sichtbar.
CREATE OR REPLACE FUNCTION entry_immutable_guard() RETURNS trigger AS $$
BEGIN
IF NEW."authorId" IS DISTINCT FROM OLD."authorId"
OR NEW."createdAt" IS DISTINCT FROM OLD."createdAt"
OR NEW."caseId" IS DISTINCT FROM OLD."caseId"
OR NEW."type" IS DISTINCT FROM OLD."type" THEN
RAISE EXCEPTION 'authorId, createdAt, caseId und type eines CaseEntry sind unveränderlich';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER entry_immutable_guard_trg
BEFORE UPDATE ON "CaseEntry"
FOR EACH ROW EXECUTE FUNCTION entry_immutable_guard();
-- Der Zugriffsprotokoll wird nie gelöscht.
CREATE RULE audit_no_delete AS ON DELETE TO "AuditLog" DO INSTEAD NOTHING;
CREATE RULE audit_no_update AS ON UPDATE TO "AuditLog" DO INSTEAD NOTHING;
-- ===========================================================================
-- 2. Beteiligte: Eindeutigkeit über partielle Indizes, weil userId und
-- personnelFileId beide NULL sein dürfen und Postgres NULL in einem
-- Primärschlüssel nicht zulässt.
-- ===========================================================================
CREATE UNIQUE INDEX participant_user_uniq
ON "CaseParticipant" ("caseId", "role", "userId")
WHERE "userId" IS NOT NULL;
CREATE UNIQUE INDEX participant_file_uniq
ON "CaseParticipant" ("caseId", "role", "personnelFileId")
WHERE "personnelFileId" IS NOT NULL;
-- Ein Beteiligter ohne jede Zuordnung ergibt keinen Sinn.
ALTER TABLE "CaseParticipant"
ADD CONSTRAINT participant_has_target
CHECK ("userId" IS NOT NULL OR "personnelFileId" IS NOT NULL);
-- Eine Chatnachricht hängt entweder an einer Akte oder an einem Kanal.
ALTER TABLE "ChatMessage"
ADD CONSTRAINT chat_has_scope
CHECK (("caseId" IS NULL) <> ("channelId" IS NULL));
-- Freigabestufen laufen von 0 bis 5.
ALTER TABLE "Case" ADD CONSTRAINT case_classification_range CHECK ("classification" BETWEEN 0 AND 5);
ALTER TABLE "CaseSection" ADD CONSTRAINT section_clearance_range CHECK ("minClearance" BETWEEN 0 AND 5);
ALTER TABLE "CaseEntry" ADD CONSTRAINT entry_clearance_range CHECK ("minClearance" BETWEEN 0 AND 5);
ALTER TABLE "PersonnelFile" ADD CONSTRAINT personnel_clearance_range CHECK ("classification" BETWEEN 0 AND 5);
ALTER TABLE "RoleMapping" ADD CONSTRAINT rolemapping_clearance_range CHECK ("clearanceLevel" BETWEEN 0 AND 5);
ALTER TABLE "Channel" ADD CONSTRAINT channel_clearance_range CHECK ("minClearance" BETWEEN 0 AND 5);
-- Die Einstellungstabelle bleibt ein Einzeiler.
ALTER TABLE "AppSetting" ADD CONSTRAINT appsetting_single_row CHECK ("id" = 1);
-- ===========================================================================
-- 3. Volltextsuche, deutsche Konfiguration.
-- ===========================================================================
-- Schwärzungs-Auszeichnung aus dem Freitext entfernen, bevor indiziert wird.
-- Sonst findet ein Nutzer mit Stufe 1 einen Begriff, der nur in einem als
-- [[c4]]…[[/c4]] markierten Bereich steht, und schließt aus dem Treffer auf
-- den Inhalt. Siehe docs/05-akten-und-vorlagen.md.
CREATE OR REPLACE FUNCTION strip_redactions(input text) RETURNS text AS $$
SELECT regexp_replace(COALESCE(input, ''), '\[\[c[0-9]\]\].*?\[\[/c[0-9]\]\]', ' ', 'g');
$$ LANGUAGE sql IMMUTABLE;
-- Gewichtung: A = Aktenzeichen und Titel, B = Abschnitte, C = Verlaufseinträge.
--
-- Aufgenommen wird nur, was für jeden lesbar ist, der die Akte überhaupt sehen
-- darf: Abschnitte und Einträge mit minClearance = 0. Höher klassifizierte
-- Feldinhalte gehören nicht in einen gemeinsamen Vektor, sonst verrät die
-- Trefferliste sie an niedrigere Stufen. Phase 7 ergänzt getrennte Vektoren je
-- Stufe, siehe docs/08-suche-und-export.md.
CREATE OR REPLACE FUNCTION case_search_vector(target_id text) RETURNS tsvector AS $$
SELECT
setweight(to_tsvector('german',
coalesce((SELECT c."caseNumber" || ' ' || c."title" FROM "Case" c WHERE c."id" = target_id), '')
), 'A')
||
setweight(coalesce((
SELECT jsonb_to_tsvector('german', jsonb_agg(to_jsonb(strip_redactions(s."value" #>> '{}'))), '["string"]')
FROM "CaseSection" s
WHERE s."caseId" = target_id AND s."minClearance" = 0
), ''::tsvector), 'B')
||
setweight(coalesce((
SELECT to_tsvector('german', string_agg(strip_redactions(e."body"), ' '))
FROM "CaseEntry" e
WHERE e."caseId" = target_id AND e."minClearance" = 0 AND e."retractedAt" IS NULL
), ''::tsvector), 'C');
$$ LANGUAGE sql STABLE;
-- Verzweigt mit IF statt mit einem CASE-Ausdruck: PL/pgSQL wertet einen
-- CASE-Ausdruck als ein einziges SQL-Statement aus und verlangt dann auch im
-- nicht genommenen Zweig, dass NEW."caseId" existiert. Auf "Case" gibt es dieses
-- Feld nicht, der Insert schlüge fehl.
CREATE OR REPLACE FUNCTION case_refresh_search_vector() RETURNS trigger AS $$
DECLARE
target text;
BEGIN
IF TG_TABLE_NAME = 'Case' THEN
target := NEW."id";
ELSIF TG_OP = 'DELETE' THEN
target := OLD."caseId";
ELSE
target := NEW."caseId";
END IF;
IF target IS NOT NULL THEN
-- Die Rückwirkung auf "Case" setzt nur "searchVector"; der Trigger auf
-- "Case" hängt an UPDATE OF "title","caseNumber" und feuert deshalb nicht
-- erneut. Keine Rekursion.
UPDATE "Case" SET "searchVector" = case_search_vector(target) WHERE "id" = target;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER case_search_on_case
AFTER INSERT OR UPDATE OF "title", "caseNumber" ON "Case"
FOR EACH ROW EXECUTE FUNCTION case_refresh_search_vector();
CREATE TRIGGER case_search_on_section
AFTER INSERT OR UPDATE OR DELETE ON "CaseSection"
FOR EACH ROW EXECUTE FUNCTION case_refresh_search_vector();
-- Auch bei UPDATE, sonst findet die Suche nach einer nachträglichen Änderung
-- den alten Text. Siehe docs/05-akten-und-vorlagen.md.
CREATE TRIGGER case_search_on_entry
AFTER INSERT OR UPDATE ON "CaseEntry"
FOR EACH ROW EXECUTE FUNCTION case_refresh_search_vector();
CREATE OR REPLACE FUNCTION personnel_refresh_search_vector() RETURNS trigger AS $$
BEGIN
NEW."searchVector" :=
setweight(to_tsvector('german',
coalesce(NEW."displayName", '') || ' ' || coalesce(NEW."serviceNumber", '')), 'A')
||
setweight(to_tsvector('german',
coalesce(NEW."rank", '') || ' ' || coalesce(NEW."department", '')), 'B');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER personnel_search_trg
BEFORE INSERT OR UPDATE OF "displayName", "serviceNumber", "rank", "department"
ON "PersonnelFile"
FOR EACH ROW EXECUTE FUNCTION personnel_refresh_search_vector();
CREATE INDEX case_search_idx ON "Case" USING GIN ("searchVector");
CREATE INDEX personnel_search_idx ON "PersonnelFile" USING GIN ("searchVector");
CREATE INDEX entry_body_idx ON "CaseEntry" USING GIN (to_tsvector('german', "body"));
-- Trigram gegen Tippfehler im Aktenzeichen und in Namen.
CREATE INDEX case_number_trgm_idx ON "Case" USING GIN ("caseNumber" gin_trgm_ops);
CREATE INDEX personnel_name_trgm_idx ON "PersonnelFile" USING GIN ("displayName" gin_trgm_ops);
-- Häufigste Abfrage der Startseite: nur offene Fälle.
CREATE INDEX case_open_idx ON "Case" ("updatedAt" DESC) WHERE "status" IN ('OPEN', 'REVIEW');
-- ===========================================================================
-- 4. Echtzeit. Ein einziger LISTEN-Client verteilt im Prozess weiter,
-- siehe docs/06-chat-und-realtime.md.
-- ===========================================================================
CREATE OR REPLACE FUNCTION notify_member_changed() RETURNS trigger AS $$
BEGIN
PERFORM pg_notify('member_changed', COALESCE(NEW."userId", OLD."userId"));
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER user_role_notify
AFTER INSERT OR UPDATE OR DELETE ON "UserRole"
FOR EACH ROW EXECUTE FUNCTION notify_member_changed();
CREATE OR REPLACE FUNCTION notify_role_mapping_changed() RETURNS trigger AS $$
BEGIN
PERFORM pg_notify('role_mapping_changed', COALESCE(NEW."discordRoleId", OLD."discordRoleId"));
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER role_mapping_notify
AFTER INSERT OR UPDATE OR DELETE ON "RoleMapping"
FOR EACH ROW EXECUTE FUNCTION notify_role_mapping_changed();
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+523
View File
@@ -0,0 +1,523 @@
// AfiS-Aktensystem — Datenmodell
// Fachliche Grundlage: docs/04-datenmodell.md
//
// Konventionen:
// - Zeitstempel immer UTC, Anzeige in Europe/Berlin.
// - IDs sind UUIDv7, damit sie zeitlich sortierbar und clientseitig erzeugbar sind.
// Wo der Client die ID erzeugt (CaseEntry, ChatMessage), ist sie zugleich
// Idempotenzschluessel, siehe docs/09-performance-und-ux.md.
// - Tabellen bleiben in PascalCase, Spalten in camelCase. Rohes SQL in den Migrationen
// quotet entsprechend.
// Prisma 7 erzeugt den Client als ECMAScript-Module in den Paketordner statt
// nach node_modules/.prisma. Das ist hier zweimal wichtig: der Client überlebt
// `pnpm deploy` beim Bauen der Images, und es entfällt der CommonJS-Umweg.
generator client {
provider = "prisma-client"
output = "../src/generated"
moduleFormat = "esm"
// Node löst im ECMAScript-Modus keine endungslosen Importe auf, deshalb
// müssen die erzeugten Dateien schon mit .js aufeinander verweisen.
importFileExtension = "js"
previewFeatures = ["postgresqlExtensions"]
}
// Verbindungsdaten stehen in prisma.config.ts, seit Prisma 7 nicht mehr hier.
datasource db {
provider = "postgresql"
extensions = [pg_trgm]
}
// ---------------------------------------------------------------------------
// Identitaet und Rechte
// ---------------------------------------------------------------------------
model User {
id String @id // Discord-Snowflake
username String
displayName String
avatarHash String?
isActive Boolean @default(true)
firstSeenAt DateTime @default(now())
lastSeenAt DateTime @default(now())
notifyMode NotifyMode @default(OWN_CASES)
roles UserRole[]
sessions Session[]
personnelFiles PersonnelFile[]
participations CaseParticipant[]
exclusions CaseExclusion[]
watches Watch[]
readMarkers ReadMarker[]
@@index([isActive, displayName])
}
enum NotifyMode {
ALL
OWN_CASES
MENTIONS
OFF
}
model UserRole {
userId String
discordRoleId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([userId, discordRoleId])
@@index([discordRoleId])
}
/// Verbindet eine Discord-Rolle mit Freigabestufe und Rechten.
/// Gepflegt im Adminbereich, siehe docs/03-auth-und-rechte.md.
model RoleMapping {
discordRoleId String @id
label String // Anzeigename, aus Discord gespiegelt
clearanceLevel Int @default(0)
permissions String[]
sortOrder Int @default(0)
updatedAt DateTime @updatedAt
}
model ClearanceLevelConfig {
level Int @id // 0..5
name String
hiddenBelow Boolean @default(false) // Existenz fuer niedrigere Stufen verbergen?
colorToken String
}
model Session {
id String @id @default(uuid(7))
userId String
createdAt DateTime @default(now())
expiresAt DateTime
lastSeenAt DateTime @default(now())
userAgent String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([expiresAt])
}
// ---------------------------------------------------------------------------
// Vorlagen
// ---------------------------------------------------------------------------
model CaseTemplate {
id String @id @default(uuid(7))
key String // "korruptionsverdacht"
version Int
name String
description String?
isActive Boolean @default(true)
fields Json // TemplateField[]
createdAt DateTime @default(now())
createdById String?
cases Case[]
@@unique([key, version])
@@index([key, isActive])
}
// ---------------------------------------------------------------------------
// Fallakten
// ---------------------------------------------------------------------------
model Case {
id String @id @default(uuid(7))
caseNumber String @unique // AfiS-2026-0042
title String
status CaseStatus @default(DRAFT)
classification Int @default(1)
templateId String
templateKey String
dueAt DateTime?
closedAt DateTime?
outcome String?
createdById String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
/// Von einem Trigger gepflegt, siehe Migration. Gewichtung A=Aktenzeichen/Titel,
/// B=Abschnitte, C=Verlaufseintraege.
searchVector Unsupported("tsvector")?
template CaseTemplate @relation(fields: [templateId], references: [id])
sections CaseSection[]
entries CaseEntry[]
participants CaseParticipant[]
exclusions CaseExclusion[]
attachments Attachment[]
chat ChatMessage[]
watches Watch[]
linksFrom CaseLink[] @relation("from")
linksTo CaseLink[] @relation("to")
@@index([status, updatedAt(sort: Desc)])
@@index([classification])
@@index([dueAt])
@@index([createdById])
}
enum CaseStatus {
DRAFT
OPEN
DORMANT
REVIEW
CLOSED
ARCHIVED
SEALED
}
/// Kollisionsfreie Vergabe des Aktenzeichens. Gelesen mit SELECT ... FOR UPDATE
/// in derselben Transaktion wie das Anlegen des Falls.
model CaseCounter {
year Int @id
lastNumber Int @default(0)
}
model CaseSection {
id String @id @default(uuid(7))
caseId String
fieldKey String // Schluessel aus der Vorlage
minClearance Int @default(0)
currentVersion Int @default(1)
value Json
updatedAt DateTime @updatedAt
updatedById String
case Case @relation(fields: [caseId], references: [id], onDelete: Cascade)
versions CaseSectionVersion[]
@@unique([caseId, fieldKey])
}
model CaseSectionVersion {
id String @id @default(uuid(7))
sectionId String
version Int
value Json
changeReason String?
authorId String
createdAt DateTime @default(now())
section CaseSection @relation(fields: [sectionId], references: [id], onDelete: Cascade)
@@unique([sectionId, version])
@@index([sectionId, version(sort: Desc)])
}
/// Verlaufseintrag. Nur anhaengend: es gibt kein DELETE, weder ueber die Anwendung
/// noch ueber rohes SQL (Regel in der Migration). Aenderungen erzeugen eine Version,
/// Zuruecknahmen lassen einen Grabstein stehen. Siehe docs/05-akten-und-vorlagen.md.
model CaseEntry {
id String @id // clientseitig erzeugt, UUIDv7
caseId String
type EntryType
body String
minClearance Int @default(0)
authorId String // unveraenderlich
createdAt DateTime @default(now()) // unveraenderlich
correctsId String? // Korrektur eines frueheren Eintrags
version Int @default(1)
editedAt DateTime?
editedById String?
editVisible Boolean @default(false) // false = innerhalb der Karenzzeit geaendert
retractedAt DateTime?
retractedById String?
retractionReason String?
case Case @relation(fields: [caseId], references: [id], onDelete: Cascade)
corrects CaseEntry? @relation("corrections", fields: [correctsId], references: [id])
correctedBy CaseEntry[] @relation("corrections")
versions CaseEntryVersion[]
attachments Attachment[]
@@index([caseId, createdAt(sort: Desc)])
@@index([caseId, editedAt(sort: Desc)])
@@index([authorId])
@@index([correctsId])
}
/// Strikt anhaengend. Enthaelt jeweils den VORHERIGEN Stand. Wird nie geloescht,
/// auch nicht von Admins. Ein Trigger laesst als einzige Aenderung das Anheben von
/// minClearance zu, damit eine Umklassifizierung rueckwirkend wirkt und der alte
/// Text nicht ueber die Historie leckt.
model CaseEntryVersion {
id String @id @default(uuid(7))
entryId String
version Int
body String
minClearance Int // erbt Erhoehungen des Eintrags
changeReason String?
editedById String
editedAt DateTime @default(now())
entry CaseEntry @relation(fields: [entryId], references: [id])
@@unique([entryId, version])
@@index([entryId, version(sort: Desc)])
}
enum EntryType {
NOTE
INTERROGATION
EVIDENCE
STATUS_CHANGE
CORRECTION
SYSTEM
}
/// Einstellungen, die der Leiter im Adminbereich pflegt. Einzeiler-Tabelle.
model AppSetting {
id Int @id @default(1)
entryEditPolicy EditPolicy @default(GRACE_WINDOW)
entryEditGraceMinutes Int @default(15)
entryEditMarkGrace Boolean @default(false)
updatedAt DateTime @updatedAt
updatedById String?
}
enum EditPolicy {
APPEND_ONLY
GRACE_WINDOW
OPEN
}
/// userId und personnelFileId sind beide optional (Beteiligte ohne Discord-Konto),
/// deshalb ein eigener Schluessel statt eines zusammengesetzten Primaerschluessels:
/// Postgres laesst NULL in einem Primaerschluessel nicht zu. Die Eindeutigkeit
/// sichern zwei partielle Indizes in der Migration.
model CaseParticipant {
id String @id @default(uuid(7))
caseId String
userId String?
personnelFileId String?
role ParticipantRole
addedById String?
createdAt DateTime @default(now())
case Case @relation(fields: [caseId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
personnelFile PersonnelFile? @relation(fields: [personnelFileId], references: [id], onDelete: Cascade)
@@index([caseId])
@@index([userId])
@@index([personnelFileId])
}
enum ParticipantRole {
LEAD
INVESTIGATOR
WITNESS
SUBJECT
OBSERVER
}
/// Schlaegt jede Freigabestufe. Deckt den Fall ab, dass gegen einen hochrangigen
/// Mitarbeiter ermittelt wird.
model CaseExclusion {
caseId String
userId String
reason String?
addedById String?
createdAt DateTime @default(now())
case Case @relation(fields: [caseId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([caseId, userId])
@@index([userId])
}
model CaseLink {
fromId String
toId String
kind LinkKind
createdAt DateTime @default(now())
from Case @relation("from", fields: [fromId], references: [id], onDelete: Cascade)
to Case @relation("to", fields: [toId], references: [id], onDelete: Cascade)
@@id([fromId, toId, kind])
@@index([toId])
}
enum LinkKind {
RELATED
SUCCESSOR
MERGED_INTO
}
// ---------------------------------------------------------------------------
// Personalakten
// ---------------------------------------------------------------------------
model PersonnelFile {
id String @id @default(uuid(7))
userId String? // null bei Personen ohne Discord-Konto
displayName String
serviceNumber String? @unique
rank String?
department String?
status String?
classification Int @default(1)
fields Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
searchVector Unsupported("tsvector")?
user User? @relation(fields: [userId], references: [id])
participants CaseParticipant[]
@@index([displayName])
@@index([userId])
@@index([classification])
}
// ---------------------------------------------------------------------------
// Dateien
// ---------------------------------------------------------------------------
model Attachment {
id String @id @default(uuid(7))
entryId String?
caseId String?
originalName String
mimeType String
sizeBytes Int
sha256 String
scanStatus ScanStatus @default(PENDING)
scanMessage String?
storageKey String // UUID-Pfad in store/
thumbKey String?
blurhash String?
uploadedById String
createdAt DateTime @default(now())
entry CaseEntry? @relation(fields: [entryId], references: [id], onDelete: Cascade)
case Case? @relation(fields: [caseId], references: [id], onDelete: Cascade)
@@index([sha256])
@@index([scanStatus])
@@index([caseId])
@@index([entryId])
@@index([uploadedById, createdAt])
}
enum ScanStatus {
PENDING
CLEAN
BLOCKED
FAILED
}
// ---------------------------------------------------------------------------
// Chat
// ---------------------------------------------------------------------------
/// Frei angelegter Raum fuer allgemeine Absprachen, mit eigener Mindeststufe.
model Channel {
id String @id @default(uuid(7))
key String @unique
name String
description String?
minClearance Int @default(0)
isArchived Boolean @default(false)
createdById String
createdAt DateTime @default(now())
messages ChatMessage[]
@@index([isArchived, name])
}
model ChatMessage {
id String @id // clientseitig erzeugt, UUIDv7
caseId String?
channelId String?
authorId String
body String
replyToId String?
editedAt DateTime?
withdrawnAt DateTime?
/// Gesetzt, sobald die Nachricht als Vermerk uebernommen wurde.
adoptedEntryId String?
createdAt DateTime @default(now())
case Case? @relation(fields: [caseId], references: [id], onDelete: Cascade)
channel Channel? @relation(fields: [channelId], references: [id], onDelete: Cascade)
replyTo ChatMessage? @relation("replies", fields: [replyToId], references: [id])
replies ChatMessage[] @relation("replies")
reactions ChatReaction[]
@@index([caseId, createdAt(sort: Desc)])
@@index([channelId, createdAt(sort: Desc)])
@@index([authorId])
@@index([replyToId])
}
/// Kleiner fester Satz an Reaktionen, keine freie Emoji-Auswahl.
model ChatReaction {
messageId String
userId String
key String
createdAt DateTime @default(now())
message ChatMessage @relation(fields: [messageId], references: [id], onDelete: Cascade)
@@id([messageId, userId, key])
}
model ReadMarker {
userId String
scopeType String // "case" | "channel"
scopeId String
lastReadAt DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([userId, scopeType, scopeId])
}
model Watch {
userId String
caseId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
case Case @relation(fields: [caseId], references: [id], onDelete: Cascade)
@@id([userId, caseId])
@@index([caseId])
}
/// Lebenszeichen der Dienste, die nicht im Web-Container laufen. Der Worker
/// schreibt hier seinen Gateway-Zustand hinein, damit /api/health ihn pruefen
/// kann, ohne Discord zu befragen. Siehe docs/11-betrieb.md.
model ServiceHeartbeat {
service String @id // "worker"
status String // "up" | "degraded" | "down"
detail String?
at DateTime @default(now())
}
// ---------------------------------------------------------------------------
// Protokoll
// ---------------------------------------------------------------------------
/// Zweite, unabhaengige Spur neben der Versionstabelle. Wird nie geloescht.
model AuditLog {
id BigInt @id @default(autoincrement())
at DateTime @default(now())
actorId String
action String // "case.read", "entry.edit", "export.pdf", ...
targetType String
targetId String
clearanceAtTime Int
meta Json?
@@index([targetType, targetId, at(sort: Desc)])
@@index([actorId, at(sort: Desc)])
@@index([action, at(sort: Desc)])
}
+69
View File
@@ -0,0 +1,69 @@
import { createPrismaClient } from '../src/index.js'
/**
* Grundzustand der Anwendung. Alles hier ist Konfiguration, ohne die die App
* nicht sinnvoll läuft, kein Testinhalt.
*
* Die vier Startvorlagen (Korruptionsverdacht, Dienstvergehen,
* Sicherheitsvorfall, Routineprüfung) kommen in Phase 3 dazu, zusammen mit dem
* Vorlagen-Editor. Siehe TASKS.md.
*/
const db = createPrismaClient()
/**
* Auslieferungszustand nach docs/01-produkt.md. Ab Stufe 3 ist die Existenz
* einer Akte für niedrigere Stufen verborgen, darunter nur der Inhalt gesperrt.
* Der Leiter kann das im Adminbereich ändern.
*/
const CLEARANCE_LEVELS = [
{ level: 0, name: 'Offen', hiddenBelow: false, colorToken: 'stamp-0' },
{ level: 1, name: 'Intern', hiddenBelow: false, colorToken: 'stamp-1' },
{ level: 2, name: 'Vertraulich', hiddenBelow: false, colorToken: 'stamp-2' },
{ level: 3, name: 'Geheim', hiddenBelow: true, colorToken: 'stamp-3' },
{ level: 4, name: 'Streng geheim', hiddenBelow: true, colorToken: 'stamp-4' },
{ level: 5, name: 'Leitungssache', hiddenBelow: true, colorToken: 'stamp-5' },
]
async function main(): Promise<void> {
for (const config of CLEARANCE_LEVELS) {
await db.clearanceLevelConfig.upsert({
where: { level: config.level },
create: config,
update: { name: config.name, colorToken: config.colorToken },
})
}
console.log(`[seed] ${CLEARANCE_LEVELS.length} Freigabestufen`)
// Änderungsrichtlinie im Auslieferungszustand: eigene Einträge sind 15 Minuten
// frei änderbar, danach nur mit Recht. Eine Änderung in dieser Karenzzeit wird
// versioniert und protokolliert, aber nicht sichtbar markiert.
// Siehe docs/05-akten-und-vorlagen.md.
await db.appSetting.upsert({
where: { id: 1 },
create: { id: 1 },
update: {},
})
console.log('[seed] Änderungsrichtlinie GRACE_WINDOW, 15 Minuten')
const year = new Date().getFullYear()
await db.caseCounter.upsert({
where: { year },
create: { year, lastNumber: 0 },
update: {},
})
console.log(`[seed] Aktenzeichen-Zähler für ${year}`)
// entry.edit_own, entry.edit_any und entry.retract sind bewusst keiner Rolle
// zugeordnet. Der Leiter schaltet sie frei, wenn er sie will.
// Siehe docs/03-auth-und-rechte.md.
const mappings = await db.roleMapping.count()
console.log(`[seed] ${mappings} Rollen-Zuordnungen vorhanden, keine angelegt`)
}
main()
.then(() => db.$disconnect())
.catch(async (error: unknown) => {
console.error('[seed] fehlgeschlagen', error)
await db.$disconnect()
process.exit(1)
})