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:
@@ -0,0 +1,4 @@
|
|||||||
|
# Prisma und Next.js brauchen ihre Postinstall-Skripte.
|
||||||
|
node-linker=isolated
|
||||||
|
strict-peer-dependencies=false
|
||||||
|
auto-install-peers=true
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "afis",
|
||||||
|
"private": true,
|
||||||
|
"packageManager": "pnpm@9.15.4",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "pnpm --filter @afis/web run dev",
|
||||||
|
"worker:dev": "pnpm --filter @afis/worker run dev",
|
||||||
|
"build": "pnpm --filter @afis/db run build && pnpm --filter @afis/web run build",
|
||||||
|
"db:generate": "pnpm --filter @afis/db run generate",
|
||||||
|
"db:migrate": "pnpm --filter @afis/db run migrate",
|
||||||
|
"db:deploy": "pnpm --filter @afis/db run migrate:deploy",
|
||||||
|
"db:seed": "pnpm --filter @afis/db run seed",
|
||||||
|
"db:studio": "pnpm --filter @afis/db run studio",
|
||||||
|
"test": "pnpm -r --sequential test",
|
||||||
|
"test:e2e": "pnpm --filter @afis/web run test:e2e",
|
||||||
|
"lint": "pnpm -r --sequential lint",
|
||||||
|
"typecheck": "pnpm -r --sequential typecheck",
|
||||||
|
"analyze": "pnpm --filter @afis/web run analyze"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ['dist/**', 'node_modules/**', 'src/generated/**'] },
|
||||||
|
js.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
)
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"name": "@afis/db",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "prisma generate && tsc -p tsconfig.build.json",
|
||||||
|
"dev": "tsc -p tsconfig.build.json --watch",
|
||||||
|
"generate": "prisma generate",
|
||||||
|
"lint": "eslint src prisma --max-warnings 0",
|
||||||
|
"migrate": "prisma migrate dev",
|
||||||
|
"migrate:deploy": "prisma migrate deploy",
|
||||||
|
"seed": "tsx prisma/seed.ts",
|
||||||
|
"studio": "prisma studio",
|
||||||
|
"test": "vitest run --passWithNoTests",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "tsx prisma/seed.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/adapter-pg": "7.10.0",
|
||||||
|
"@prisma/client": "7.10.0",
|
||||||
|
"pg": "8.13.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.0",
|
||||||
|
"@prisma/config": "7.10.0",
|
||||||
|
"@types/pg": "^8.11.10",
|
||||||
|
"eslint": "9.39.5",
|
||||||
|
"prisma": "7.10.0",
|
||||||
|
"tsx": "4.20.6",
|
||||||
|
"typescript": "5.9.3",
|
||||||
|
"typescript-eslint": "^8.20.0",
|
||||||
|
"vitest": "4.1.11"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"prisma"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { existsSync } from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { defineConfig, env } from '@prisma/config'
|
||||||
|
|
||||||
|
// Prisma 7 lädt die .env nicht mehr selbst. Im Betrieb stehen die Werte
|
||||||
|
// bereits in der Umgebung (Dockge-Reiter), lokal kommen sie aus der Datei.
|
||||||
|
for (const candidate of [path.resolve('../../.env'), path.resolve('.env')]) {
|
||||||
|
if (existsSync(candidate)) {
|
||||||
|
process.loadEnvFile(candidate)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrationen laufen bewusst über DIRECT_DATABASE_URL, also an pgbouncer vorbei:
|
||||||
|
* im Transaktionsmodus überstehen sie den Pool nicht. Siehe docs/11-betrieb.md.
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
schema: path.join('prisma', 'schema.prisma'),
|
||||||
|
migrations: {
|
||||||
|
path: path.join('prisma', 'migrations'),
|
||||||
|
seed: 'tsx prisma/seed.ts',
|
||||||
|
},
|
||||||
|
datasource: {
|
||||||
|
url: env('DIRECT_DATABASE_URL'),
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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"
|
||||||
@@ -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)])
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { PrismaClient } from './generated/client.js'
|
||||||
|
import { PrismaPg } from '@prisma/adapter-pg'
|
||||||
|
import { loadEnvFromRepoRoot } from './loadEnv.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Client wird von Prisma als ECMAScript-Module nach `src/generated`
|
||||||
|
* erzeugt (siehe prisma/schema.prisma) und von dort mitübersetzt. Dadurch
|
||||||
|
* lässt er sich sauber weiterreichen, Enums inklusive, und er überlebt das
|
||||||
|
* Bündeln der Container-Images.
|
||||||
|
*/
|
||||||
|
export * from './generated/client.js'
|
||||||
|
export { loadEnvFromRepoRoot }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt einen Prisma-Client.
|
||||||
|
*
|
||||||
|
* Prisma 7 spricht über einen Treiber-Adapter statt über eine eigene
|
||||||
|
* Rust-Engine. Das passt hier doppelt: der `pg`-Treiber wird für
|
||||||
|
* LISTEN/NOTIFY ohnehin gebraucht (siehe docs/06-chat-und-realtime.md), und
|
||||||
|
* die Container-Images kommen ohne nachzuladende Binärdatei aus.
|
||||||
|
*
|
||||||
|
* Im Betrieb zeigt DATABASE_URL auf pgbouncer im Transaktionsmodus, deshalb
|
||||||
|
* gehören `pgbouncer=true` und ein kleines Pool-Limit in die URL.
|
||||||
|
* Siehe docs/09-performance-und-ux.md und docs/11-betrieb.md.
|
||||||
|
*/
|
||||||
|
export function createPrismaClient(url?: string): PrismaClient {
|
||||||
|
if (!url) {
|
||||||
|
loadEnvFromRepoRoot()
|
||||||
|
url = process.env.DATABASE_URL
|
||||||
|
}
|
||||||
|
if (!url) throw new Error('Konfiguration unvollständig: DATABASE_URL fehlt')
|
||||||
|
|
||||||
|
const adapter = new PrismaPg({ connectionString: url })
|
||||||
|
|
||||||
|
return new PrismaClient({
|
||||||
|
adapter,
|
||||||
|
log: ['warn', 'error'],
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { existsSync } from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prisma 7 liest die .env nicht mehr von selbst, und der Seed läuft außerhalb
|
||||||
|
* von Next.js. Im Betrieb kommen die Werte aus dem .env-Reiter von Dockge und
|
||||||
|
* stehen längst in der Umgebung, dann passiert hier nichts.
|
||||||
|
* Node 22 bringt loadEnvFile mit, dafür braucht es kein Paket.
|
||||||
|
*/
|
||||||
|
export function loadEnvFromRepoRoot(): void {
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
for (const candidate of [
|
||||||
|
path.resolve(here, '../../../.env'),
|
||||||
|
path.resolve(here, '../../.env'),
|
||||||
|
path.resolve(process.cwd(), '.env'),
|
||||||
|
]) {
|
||||||
|
if (existsSync(candidate)) {
|
||||||
|
process.loadEnvFile(candidate)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||||
|
import { createPrismaClient, type PrismaClient } from '../src/index.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft die harten Regeln aus CLAUDE.md dort, wo sie wirklich gelten: in der
|
||||||
|
* Datenbank. Die Anwendung kann man umgehen, diese Regeln nicht.
|
||||||
|
*
|
||||||
|
* Läuft gegen eine echte Postgres-Instanz. Ohne DATABASE_URL wird übersprungen.
|
||||||
|
*/
|
||||||
|
const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL
|
||||||
|
const suite = url ? describe : describe.skip
|
||||||
|
|
||||||
|
let db: PrismaClient
|
||||||
|
const marke = `test-${Date.now()}`
|
||||||
|
|
||||||
|
suite('Datenbankregeln', () => {
|
||||||
|
let caseId: string
|
||||||
|
let entryId: string
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
db = createPrismaClient(url)
|
||||||
|
|
||||||
|
const template = await db.caseTemplate.create({
|
||||||
|
data: { key: `${marke}-vorlage`, version: 1, name: 'Prüfvorlage', fields: [] },
|
||||||
|
})
|
||||||
|
const user = await db.user.create({
|
||||||
|
data: { id: `${marke}-nutzer`, username: 'pruef', displayName: 'Prüfer' },
|
||||||
|
})
|
||||||
|
const fall = await db.case.create({
|
||||||
|
data: {
|
||||||
|
caseNumber: `AfiS-9999-${String(Date.now()).slice(-4)}`,
|
||||||
|
title: 'Prüffall',
|
||||||
|
templateId: template.id,
|
||||||
|
templateKey: template.key,
|
||||||
|
createdById: user.id,
|
||||||
|
classification: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
caseId = fall.id
|
||||||
|
|
||||||
|
const eintrag = await db.caseEntry.create({
|
||||||
|
data: {
|
||||||
|
id: `${marke}-eintrag`,
|
||||||
|
caseId,
|
||||||
|
type: 'NOTE',
|
||||||
|
body: 'Ursprünglicher Text',
|
||||||
|
authorId: user.id,
|
||||||
|
minClearance: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
entryId = eintrag.id
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// CaseEntry lässt sich nicht löschen, deshalb bleibt der Prüffall stehen.
|
||||||
|
// Aufgeräumt wird, was aufräumbar ist.
|
||||||
|
await db.caseSection.deleteMany({ where: { caseId } }).catch(() => undefined)
|
||||||
|
await db.$disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lässt einen Verlaufseintrag nicht löschen, auch nicht über rohes SQL', async () => {
|
||||||
|
await db.$executeRawUnsafe(`DELETE FROM "CaseEntry" WHERE id = $1`, entryId)
|
||||||
|
const danach = await db.caseEntry.findUnique({ where: { id: entryId } })
|
||||||
|
expect(danach).not.toBeNull()
|
||||||
|
expect(danach?.body).toBe('Ursprünglicher Text')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lässt Autor und Entstehungszeit eines Eintrags nicht ändern', async () => {
|
||||||
|
await expect(
|
||||||
|
db.$executeRawUnsafe(`UPDATE "CaseEntry" SET "authorId" = 'jemand-anders' WHERE id = $1`, entryId),
|
||||||
|
).rejects.toThrow(/unveränderlich/)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
db.$executeRawUnsafe(`UPDATE "CaseEntry" SET "createdAt" = now() WHERE id = $1`, entryId),
|
||||||
|
).rejects.toThrow(/unveränderlich/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('erlaubt an einer Eintragsversion nur das Anheben von minClearance', async () => {
|
||||||
|
const version = await db.caseEntryVersion.create({
|
||||||
|
data: { entryId, version: 1, body: 'Vorheriger Stand', minClearance: 0, editedById: `${marke}-nutzer` },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Umklassifizierung wirkt rückwirkend: das muss durchgehen.
|
||||||
|
await db.caseEntryVersion.update({ where: { id: version.id }, data: { minClearance: 4 } })
|
||||||
|
const angehoben = await db.caseEntryVersion.findUnique({ where: { id: version.id } })
|
||||||
|
expect(angehoben?.minClearance).toBe(4)
|
||||||
|
|
||||||
|
// Absenken nicht, sonst leckt der alte Text über die Historie.
|
||||||
|
await expect(
|
||||||
|
db.caseEntryVersion.update({ where: { id: version.id }, data: { minClearance: 1 } }),
|
||||||
|
).rejects.toThrow(/nur angehoben/)
|
||||||
|
|
||||||
|
// Der Text bleibt unantastbar.
|
||||||
|
await expect(
|
||||||
|
db.caseEntryVersion.update({ where: { id: version.id }, data: { body: 'umgeschrieben' } }),
|
||||||
|
).rejects.toThrow(/anhängend/)
|
||||||
|
|
||||||
|
// Und gelöscht wird sie auch nicht.
|
||||||
|
await db.$executeRawUnsafe(`DELETE FROM "CaseEntryVersion" WHERE id = $1`, version.id)
|
||||||
|
expect(await db.caseEntryVersion.findUnique({ where: { id: version.id } })).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lässt einen Protokolleintrag weder ändern noch löschen', async () => {
|
||||||
|
const eintrag = await db.auditLog.create({
|
||||||
|
data: {
|
||||||
|
actorId: `${marke}-nutzer`,
|
||||||
|
action: 'case.read',
|
||||||
|
targetType: 'Case',
|
||||||
|
targetId: caseId,
|
||||||
|
clearanceAtTime: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await db.$executeRawUnsafe(`DELETE FROM "AuditLog" WHERE id = ${eintrag.id}`)
|
||||||
|
await db.$executeRawUnsafe(`UPDATE "AuditLog" SET action = 'gefälscht' WHERE id = ${eintrag.id}`)
|
||||||
|
const danach = await db.auditLog.findUnique({ where: { id: eintrag.id } })
|
||||||
|
expect(danach?.action).toBe('case.read')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('weist Freigabestufen ausserhalb von 0 bis 5 ab', async () => {
|
||||||
|
await expect(
|
||||||
|
db.$executeRawUnsafe(`UPDATE "Case" SET classification = 9 WHERE id = $1`, caseId),
|
||||||
|
).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verlangt für einen Beteiligten mindestens eine Zuordnung', async () => {
|
||||||
|
await expect(
|
||||||
|
db.$executeRawUnsafe(
|
||||||
|
`INSERT INTO "CaseParticipant" (id, "caseId", role) VALUES ('x-${marke}', $1, 'WITNESS')`,
|
||||||
|
caseId,
|
||||||
|
),
|
||||||
|
).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lässt eine Chatnachricht nicht zugleich an Akte und Kanal hängen', async () => {
|
||||||
|
const kanal = await db.channel.create({
|
||||||
|
data: { key: `${marke}-kanal`, name: 'Prüfkanal', createdById: `${marke}-nutzer` },
|
||||||
|
})
|
||||||
|
await expect(
|
||||||
|
db.chatMessage.create({
|
||||||
|
data: {
|
||||||
|
id: `${marke}-nachricht`,
|
||||||
|
caseId,
|
||||||
|
channelId: kanal.id,
|
||||||
|
authorId: `${marke}-nutzer`,
|
||||||
|
body: 'gehört nirgends hin',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nimmt geschwärzte Bereiche nicht in den Suchvektor auf', async () => {
|
||||||
|
await db.caseEntry.create({
|
||||||
|
data: {
|
||||||
|
id: `${marke}-geschwaerzt`,
|
||||||
|
caseId,
|
||||||
|
type: 'NOTE',
|
||||||
|
body: 'Offener Teil [[c4]]Zeugenname Perreira[[/c4]] Schluss',
|
||||||
|
authorId: `${marke}-nutzer`,
|
||||||
|
minClearance: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const [treffer] = await db.$queryRawUnsafe<{ offen: boolean; geheim: boolean }[]>(
|
||||||
|
`SELECT "searchVector" @@ websearch_to_tsquery('german', 'Offener') AS offen,
|
||||||
|
"searchVector" @@ websearch_to_tsquery('german', 'Perreira') AS geheim
|
||||||
|
FROM "Case" WHERE id = $1`,
|
||||||
|
caseId,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(treffer?.offen).toBe(true)
|
||||||
|
expect(treffer?.geheim).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { loadEnvFromRepoRoot } from '../src/loadEnv.js'
|
||||||
|
|
||||||
|
// Damit die Regeltests die lokale Entwicklungsdatenbank finden. In der CI
|
||||||
|
// stehen die Werte bereits in der Umgebung.
|
||||||
|
loadEnvFromRepoRoot()
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"noEmit": false,
|
||||||
|
"declaration": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "."
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "prisma/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ['tests/**/*.test.ts'],
|
||||||
|
// Die Regeln werden gegen eine echte Postgres-Instanz geprüft. Ohne
|
||||||
|
// DATABASE_URL überspringen die Tests sich selbst statt fehlzuschlagen.
|
||||||
|
setupFiles: ['./tests/setup.ts'],
|
||||||
|
fileParallelism: false,
|
||||||
|
testTimeout: 20_000,
|
||||||
|
},
|
||||||
|
})
|
||||||
Generated
+7286
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
|||||||
|
packages:
|
||||||
|
- 'web'
|
||||||
|
- 'worker'
|
||||||
|
- 'pdf'
|
||||||
|
- 'packages/*'
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"allowJs": false,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"exactOptionalPropertyTypes": false,
|
||||||
|
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"verbatimModuleSyntax": false
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user