diff --git a/packages/db/prisma/migrations/20260902160000_personalakten_und_dokumente/migration.sql b/packages/db/prisma/migrations/20260902160000_personalakten_und_dokumente/migration.sql new file mode 100644 index 0000000..91c9018 --- /dev/null +++ b/packages/db/prisma/migrations/20260902160000_personalakten_und_dokumente/migration.sql @@ -0,0 +1,243 @@ +-- Phase 4: Personalakten, Dokumente, Beteiligte, Verknüpfungen. +-- +-- Von Hand geschrieben, weil zwei Tabellen **umbenannt** werden. Prisma würde +-- daraus DROP und CREATE machen und dabei die Vorlagen und die laufenden +-- Nummernkreise verlieren. Siehe docs/13-entscheidungen.md. + +-- =========================================================================== +-- 1. CaseTemplate -> DocumentTemplate +-- Eine Vorlage beschreibt jetzt auch Dokumente, nicht nur Fallarten. +-- =========================================================================== + +ALTER TABLE "CaseTemplate" RENAME TO "DocumentTemplate"; +ALTER TABLE "DocumentTemplate" RENAME CONSTRAINT "CaseTemplate_pkey" TO "DocumentTemplate_pkey"; +ALTER INDEX "CaseTemplate_key_version_key" RENAME TO "DocumentTemplate_key_version_key"; +ALTER INDEX "CaseTemplate_key_isActive_idx" RENAME TO "DocumentTemplate_key_isActive_idx"; + +CREATE TYPE "TemplateUse" AS ENUM ('CASE', 'CASE_DOCUMENT', 'PERSON_DOCUMENT', 'STANDALONE'); +CREATE TYPE "DocumentLayout" AS ENUM ('FORM', 'PROSE', 'NOTE'); +CREATE TYPE "DocumentStatus" AS ENUM ('DRAFT', 'FINAL', 'RETRACTED'); + +ALTER TABLE "DocumentTemplate" + ADD COLUMN "uses" "TemplateUse"[] NOT NULL DEFAULT ARRAY['CASE']::"TemplateUse"[], + ADD COLUMN "layout" "DocumentLayout" NOT NULL DEFAULT 'FORM'; + +-- =========================================================================== +-- 2. CaseCounter -> NumberSeries +-- Aktenzeichen und Dokumentnummern laufen über denselben Zähler, getrennt +-- durch das Präfix aus der Vorlage. Die bestehenden Stände bleiben stehen. +-- =========================================================================== + +ALTER TABLE "CaseCounter" RENAME TO "NumberSeries"; +ALTER TABLE "NumberSeries" RENAME CONSTRAINT "CaseCounter_pkey" TO "NumberSeries_pkey"; + +-- =========================================================================== +-- 3. Personalakte: Herkunft und Freitext im Kopf +-- =========================================================================== + +ALTER TABLE "PersonnelFile" + ADD COLUMN "note" TEXT, + ADD COLUMN "createdById" TEXT; + +-- =========================================================================== +-- 4. Verknüpfungen: wer sie gezogen hat +-- =========================================================================== + +ALTER TABLE "CaseLink" ADD COLUMN "createdById" TEXT; + +-- =========================================================================== +-- 5. Dokumente +-- =========================================================================== + +CREATE TABLE "Document" ( + "id" TEXT NOT NULL, + "documentNumber" TEXT NOT NULL, + "title" TEXT NOT NULL, + "templateId" TEXT NOT NULL, + "templateKey" TEXT NOT NULL, + "templateVersion" INTEGER NOT NULL, + "layout" "DocumentLayout" NOT NULL DEFAULT 'FORM', + "status" "DocumentStatus" NOT NULL DEFAULT 'DRAFT', + "classification" INTEGER NOT NULL DEFAULT 1, + "caseId" TEXT, + "personnelFileId" TEXT, + "createdById" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "finalizedAt" TIMESTAMP(3), + "finalizedById" TEXT, + "retractedAt" TIMESTAMP(3), + "retractedById" TEXT, + "retractionReason" TEXT, + "searchVector" tsvector, + + CONSTRAINT "Document_pkey" PRIMARY KEY ("id") +); + +-- Ein Dokument gehört zu höchstens einem Besitzer. Ein Bußgeld steht für sich, +-- ein Sonderarbeitsvertrag an einer Person, ein Vorfallsbericht an einer Akte. +-- Beides zugleich wäre eine Aussage über die Sichtbarkeit, die niemand prüfen +-- könnte: die Prüfung müsste sich für einen der beiden Besitzer entscheiden. +ALTER TABLE "Document" ADD CONSTRAINT "Document_one_owner" + CHECK (("caseId" IS NULL) OR ("personnelFileId" IS NULL)); + +CREATE UNIQUE INDEX "Document_documentNumber_key" ON "Document"("documentNumber"); +CREATE INDEX "Document_caseId_createdAt_idx" ON "Document"("caseId", "createdAt" DESC); +CREATE INDEX "Document_personnelFileId_createdAt_idx" ON "Document"("personnelFileId", "createdAt" DESC); +CREATE INDEX "Document_classification_idx" ON "Document"("classification"); +CREATE INDEX "Document_templateKey_idx" ON "Document"("templateKey"); +CREATE INDEX "Document_createdById_idx" ON "Document"("createdById"); + +ALTER TABLE "Document" ADD CONSTRAINT "Document_templateId_fkey" + FOREIGN KEY ("templateId") REFERENCES "DocumentTemplate"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "Document" ADD CONSTRAINT "Document_caseId_fkey" + FOREIGN KEY ("caseId") REFERENCES "Case"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "Document" ADD CONSTRAINT "Document_personnelFileId_fkey" + FOREIGN KEY ("personnelFileId") REFERENCES "PersonnelFile"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +CREATE TABLE "DocumentSection" ( + "id" TEXT NOT NULL, + "documentId" 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 "DocumentSection_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "DocumentSection_documentId_fieldKey_key" + ON "DocumentSection"("documentId", "fieldKey"); + +ALTER TABLE "DocumentSection" ADD CONSTRAINT "DocumentSection_documentId_fkey" + FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE TABLE "DocumentSectionVersion" ( + "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 "DocumentSectionVersion_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "DocumentSectionVersion_sectionId_version_key" + ON "DocumentSectionVersion"("sectionId", "version"); +CREATE INDEX "DocumentSectionVersion_sectionId_version_idx" + ON "DocumentSectionVersion"("sectionId", "version" DESC); + +ALTER TABLE "DocumentSectionVersion" ADD CONSTRAINT "DocumentSectionVersion_sectionId_fkey" + FOREIGN KEY ("sectionId") REFERENCES "DocumentSection"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- =========================================================================== +-- 6. Dokumente sind anhängend +-- +-- Dieselbe Zusicherung wie beim Verlaufseintrag: kein DELETE, weder über +-- die Anwendung noch über rohes SQL. Wer ein Dokument loswerden will, zieht +-- es zurück; der Grabstein bleibt sichtbar. Siehe CLAUDE.md, harte Regel 2. +-- =========================================================================== + +CREATE RULE document_no_delete AS ON DELETE TO "Document" DO INSTEAD NOTHING; +CREATE RULE document_version_no_delete AS ON DELETE TO "DocumentSectionVersion" DO INSTEAD NOTHING; + +-- Die Versionstabelle wird nicht umgeschrieben. Einzige Ausnahme wie beim +-- Verlaufseintrag: minClearance darf angehoben werden, damit eine +-- Höherstufung rückwirkend gilt und der alte Text nicht über die Historie +-- leckt. +CREATE OR REPLACE FUNCTION document_version_guard() RETURNS trigger AS $$ +BEGIN + IF NEW."id" IS DISTINCT FROM OLD."id" + OR NEW."sectionId" IS DISTINCT FROM OLD."sectionId" + OR NEW."version" IS DISTINCT FROM OLD."version" + OR NEW."value" IS DISTINCT FROM OLD."value" + OR NEW."changeReason" IS DISTINCT FROM OLD."changeReason" + OR NEW."authorId" IS DISTINCT FROM OLD."authorId" + OR NEW."createdAt" IS DISTINCT FROM OLD."createdAt" THEN + RAISE EXCEPTION 'DocumentSectionVersion ist anhängend'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER document_version_guard_trg + BEFORE UPDATE ON "DocumentSectionVersion" + FOR EACH ROW EXECUTE FUNCTION document_version_guard(); + +-- Herkunft, Vorlage und Besitzer eines Dokuments sind unveränderlich. Ein +-- Vertrag, der nachträglich an einer anderen Person hängt, wäre eine Fälschung. +CREATE OR REPLACE FUNCTION document_immutable_guard() RETURNS trigger AS $$ +BEGIN + IF NEW."createdById" IS DISTINCT FROM OLD."createdById" + OR NEW."createdAt" IS DISTINCT FROM OLD."createdAt" + OR NEW."caseId" IS DISTINCT FROM OLD."caseId" + OR NEW."personnelFileId" IS DISTINCT FROM OLD."personnelFileId" + OR NEW."documentNumber" IS DISTINCT FROM OLD."documentNumber" + OR NEW."templateId" IS DISTINCT FROM OLD."templateId" THEN + RAISE EXCEPTION 'Herkunft, Besitzer und Vorlage eines Document sind unveränderlich'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER document_immutable_guard_trg + BEFORE UPDATE ON "Document" + FOR EACH ROW EXECUTE FUNCTION document_immutable_guard(); + +-- =========================================================================== +-- 7. Volltextsuche für Dokumente +-- +-- Wie beim Fall: aufgenommen wird nur, was jeder lesen darf, der das +-- Dokument überhaupt sehen darf. Felder mit minClearance > 0 bleiben +-- draußen, sonst verrät die Trefferliste sie an niedrigere Stufen. +-- =========================================================================== + +CREATE OR REPLACE FUNCTION document_search_vector(target_id text) RETURNS tsvector AS $$ + SELECT + setweight(to_tsvector('german', + coalesce((SELECT d."documentNumber" || ' ' || d."title" FROM "Document" d WHERE d."id" = target_id), '') + ), 'A') + || + setweight(coalesce(( + SELECT jsonb_to_tsvector('german', jsonb_agg(to_jsonb(strip_redactions(s."value" #>> '{}'))), '["string"]') + FROM "DocumentSection" s + WHERE s."documentId" = target_id AND s."minClearance" = 0 + ), ''::tsvector), 'B'); +$$ LANGUAGE sql STABLE; + +-- Wie bei case_refresh_search_vector: mit IF verzweigt, nicht mit einem +-- CASE-Ausdruck. Der würde auch im nicht genommenen Zweig verlangen, dass +-- NEW."documentId" existiert — auf "Document" gibt es das Feld nicht. +CREATE OR REPLACE FUNCTION document_refresh_search_vector() RETURNS trigger AS $$ +DECLARE + target text; +BEGIN + IF TG_TABLE_NAME = 'Document' THEN + target := NEW."id"; + ELSIF TG_OP = 'DELETE' THEN + target := OLD."documentId"; + ELSE + target := NEW."documentId"; + END IF; + + IF target IS NOT NULL THEN + UPDATE "Document" SET "searchVector" = document_search_vector(target) WHERE "id" = target; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER document_search_on_document + AFTER INSERT OR UPDATE OF "title", "documentNumber" ON "Document" + FOR EACH ROW EXECUTE FUNCTION document_refresh_search_vector(); + +CREATE TRIGGER document_search_on_section + AFTER INSERT OR UPDATE OR DELETE ON "DocumentSection" + FOR EACH ROW EXECUTE FUNCTION document_refresh_search_vector(); + +CREATE INDEX document_search_idx ON "Document" USING GIN ("searchVector"); +CREATE INDEX document_number_trgm_idx ON "Document" USING GIN ("documentNumber" gin_trgm_ops); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 8c3bc05..712fe92 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -137,7 +137,11 @@ model Session { // Vorlagen // --------------------------------------------------------------------------- -model CaseTemplate { +/// Eine Vorlage beschreibt einen Bausteinsatz. Sie kann eine **Fallart** sein +/// (bestimmt die Abschnitte einer Fallakte) oder eine **Dokumentart** (bestimmt +/// den Aufbau eines Dokuments) oder beides. Was davon gilt, steht in `uses`. +/// Eine neue Dokumentart ist damit ein Datensatz, kein Deployment. +model DocumentTemplate { id String @id @default(uuid(7)) key String // "korruptionsverdacht" version Int @@ -146,6 +150,13 @@ model CaseTemplate { isActive Boolean @default(true) fields Json // TemplateField[] + /// Wofuer die Vorlage verwendet werden darf. Liste statt Einzelwert, weil + /// dieselbe Vorlage an mehreren Stellen sinnvoll sein kann. + uses TemplateUse[] @default([CASE]) + + /// Bestimmt allein das Aussehen, nicht den Zugriff. Siehe docs/08. + layout DocumentLayout @default(FORM) + /// Muster des Aktenzeichens, siehe docs/13-entscheidungen.md. Die Abteilung /// fuehrt gewachsene Praefixe wie "AKT-PA"; der Nummernkreis laeuft je /// Praefix und Jahr, damit zwei Fallarten sich nicht in die Quere kommen. @@ -154,12 +165,35 @@ model CaseTemplate { createdAt DateTime @default(now()) createdById String? - cases Case[] + cases Case[] + documents Document[] @@unique([key, version]) @@index([key, isActive]) } +/// Wofuer eine Vorlage taugt. +enum TemplateUse { + /// Fallart: bestimmt die Abschnitte einer Fallakte + CASE + /// Dokument in einer Fallakte + CASE_DOCUMENT + /// Dokument an einer Personalakte, etwa ein Sonderarbeitsvertrag + PERSON_DOCUMENT + /// Dokument ohne Besitzer, etwa ein Bussgeld + STANDALONE +} + +/// Bauform des Dokuments, siehe docs/13-entscheidungen.md. +enum DocumentLayout { + /// beschriftete Felder, ausgefuellt — der Vorfallsbericht + FORM + /// fester Wortlaut mit Platzhaltern, mehrseitig — der Sonderarbeitsvertrag + PROSE + /// ein einzelnes Freitextfeld, dargestellt wie ein Vermerk + NOTE +} + // --------------------------------------------------------------------------- // Fallakten // --------------------------------------------------------------------------- @@ -183,7 +217,7 @@ model Case { /// B=Abschnitte, C=Verlaufseintraege. searchVector Unsupported("tsvector")? - template CaseTemplate @relation(fields: [templateId], references: [id]) + template DocumentTemplate @relation(fields: [templateId], references: [id]) sections CaseSection[] entries CaseEntry[] participants CaseParticipant[] @@ -193,6 +227,7 @@ model Case { watches Watch[] linksFrom CaseLink[] @relation("from") linksTo CaseLink[] @relation("to") + documents Document[] @@index([status, updatedAt(sort: Desc)]) @@index([classification]) @@ -215,9 +250,10 @@ enum CaseStatus { SEALED } -/// Kollisionsfreie Vergabe des Aktenzeichens, ein Nummernkreis je Praefix und -/// Jahr. Hochgezaehlt in derselben Transaktion wie das Anlegen des Falls. -model CaseCounter { +/// Kollisionsfreie Vergabe der Geschaeftsnummern, ein Nummernkreis je Praefix +/// und Jahr. Hochgezaehlt in derselben Transaktion wie das Anlegen. Traegt +/// Aktenzeichen und Dokumentnummern, das Praefix kommt aus der Vorlage. +model NumberSeries { prefix String year Int lastNumber Int @default(0) @@ -378,11 +414,14 @@ model CaseExclusion { @@index([userId]) } +/// Verknuepfung zweier Akten. Umkehrbar loeschbar, siehe docs/09: kein +/// Bestaetigungsdialog, sondern ein Rueckgaengig-Hinweis. model CaseLink { - fromId String - toId String - kind LinkKind - createdAt DateTime @default(now()) + fromId String + toId String + kind LinkKind + createdById String? + createdAt DateTime @default(now()) from Case @relation("from", fields: [fromId], references: [id], onDelete: Cascade) to Case @relation("to", fields: [toId], references: [id], onDelete: Cascade) @@ -410,14 +449,18 @@ model PersonnelFile { status String? classification Int @default(1) fields Json @default("{}") + /// Freitext im Kopf der Akte, etwa "kein Discord-Konto, Aktenlage aus 2025". + note String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdById String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt searchVector Unsupported("tsvector")? user User? @relation(fields: [userId], references: [id]) participants CaseParticipant[] + documents Document[] @@index([displayName]) @@index([userId]) @@ -426,6 +469,102 @@ model PersonnelFile { @@index([displayName(ops: raw("gin_trgm_ops"))], map: "personnel_name_trgm_idx", type: Gin) } +// --------------------------------------------------------------------------- +// Dokumente +// --------------------------------------------------------------------------- + +/// Eine Ausfertigung aus einer Vorlage: Vorfallsbericht, Sonderarbeitsvertrag, +/// spaeter Bussgeld. Besitzer ist eine Fallakte, eine Personalakte oder nichts. +/// Hoechstens einer der beiden Fremdschluessel ist gesetzt, gesichert durch +/// eine CHECK-Bedingung in der Migration. +/// +/// Anhaengend wie ein Verlaufseintrag: es gibt kein DELETE, eine Zuruecknahme +/// laesst einen Grabstein stehen. Siehe CLAUDE.md, harte Regel 2. +model Document { + id String @id @default(uuid(7)) + documentNumber String @unique // SAV-2026-0007 + title String + templateId String + templateKey String + templateVersion Int + /// Kopie aus der Vorlage: das Aussehen darf sich nicht aendern, wenn jemand + /// spaeter die Vorlage umbaut. + layout DocumentLayout @default(FORM) + status DocumentStatus @default(DRAFT) + classification Int @default(1) + + caseId String? + personnelFileId String? + + createdById String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + finalizedAt DateTime? + finalizedById String? + + retractedAt DateTime? + retractedById String? + retractionReason String? + + /// Von einem Trigger gepflegt. Gewichtung A=Nummer/Titel, B=Abschnitte. + searchVector Unsupported("tsvector")? + + template DocumentTemplate @relation(fields: [templateId], references: [id]) + case Case? @relation(fields: [caseId], references: [id], onDelete: Restrict) + personnelFile PersonnelFile? @relation(fields: [personnelFileId], references: [id], onDelete: Restrict) + sections DocumentSection[] + + @@index([caseId, createdAt(sort: Desc)]) + @@index([personnelFileId, createdAt(sort: Desc)]) + @@index([classification]) + @@index([templateKey]) + @@index([createdById]) + // Von Hand in der Migration erzeugt, Prisma muss sie kennen. Siehe Case. + @@index([searchVector], map: "document_search_idx", type: Gin) + @@index([documentNumber(ops: raw("gin_trgm_ops"))], map: "document_number_trgm_idx", type: Gin) +} + +enum DocumentStatus { + /// in Arbeit, aenderbar + DRAFT + /// ausgefertigt, Aenderungen brauchen ein eigenes Recht + FINAL + /// zurueckgezogen, bleibt als Grabstein stehen + RETRACTED +} + +/// Ein ausgefuelltes Feld eines Dokuments. Dieselben Zusicherungen wie bei +/// `CaseSection`: versioniert, `minClearance` je Feld, Diff, Protokoll. +model DocumentSection { + id String @id @default(uuid(7)) + documentId String + fieldKey String + minClearance Int @default(0) + currentVersion Int @default(1) + value Json + updatedAt DateTime @updatedAt + updatedById String + + document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) + versions DocumentSectionVersion[] + + @@unique([documentId, fieldKey]) +} + +model DocumentSectionVersion { + id String @id @default(uuid(7)) + sectionId String + version Int + value Json + changeReason String? + authorId String + createdAt DateTime @default(now()) + section DocumentSection @relation(fields: [sectionId], references: [id], onDelete: Cascade) + + @@unique([sectionId, version]) + @@index([sectionId, version(sort: Desc)]) +} + // --------------------------------------------------------------------------- // Dateien // --------------------------------------------------------------------------- diff --git a/packages/db/prisma/seed.ts b/packages/db/prisma/seed.ts index ec2ca52..8b34313 100644 --- a/packages/db/prisma/seed.ts +++ b/packages/db/prisma/seed.ts @@ -4,8 +4,8 @@ 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 stehen hier, weil ohne Vorlage keine Akte angelegt - * werden kann. Den Vorlagen-Editor und die übrigen Feldtypen baut Phase 3. + * Die Startvorlagen stehen hier, weil ohne Vorlage weder eine Akte noch ein + * Dokument angelegt werden kann. Geändert werden sie im Vorlagen-Editor. */ const db = createPrismaClient() @@ -36,13 +36,19 @@ const CLEARANCE_LEVELS = [ * mit allen Feldtypen baut Phase 3, hier steht nur, was eine Akte zum Laufen * braucht. */ -const TEMPLATES: { +type Vorlage = { key: string name: string description: string + uses?: ('CASE' | 'CASE_DOCUMENT' | 'PERSON_DOCUMENT' | 'STANDALONE')[] + layout?: 'FORM' | 'PROSE' | 'NOTE' numberPrefix?: string numberPadding?: number -}[] = [ + fields?: unknown[] +} + +/** Fallarten: sie bestimmen die Abschnitte einer Fallakte. */ +const TEMPLATES: Vorlage[] = [ { key: 'korruptionsverdacht', name: 'Korruptionsverdacht', @@ -108,6 +114,104 @@ const TEMPLATE_FIELDS = [ }, ] +/** + * Dokumentarten. Sie hängen an einer Fallakte, an einer Personalakte oder an + * nichts — was davon, steht in `uses`. Eine weitere Art anzulegen ist ein + * Datensatz, kein Deployment: der Vorlagen-Editor reicht. + * Siehe docs/13-entscheidungen.md. + */ +const DOCUMENT_TEMPLATES: Vorlage[] = [ + { + key: 'vermerk', + name: 'Vermerk', + description: 'Kurze Notiz mit Datum und Verfasser, an einer Akte oder an einer Person', + uses: ['CASE_DOCUMENT', 'PERSON_DOCUMENT'], + layout: 'NOTE', + numberPrefix: 'VM', + fields: [{ key: 'text', label: 'Vermerk', type: 'longtext', required: true }], + }, + { + key: 'vorfallsbericht', + name: 'Vorfallsbericht', + description: 'Bericht über einen Vorfall, mit Gefahreneinschätzung und Maßnahmen', + uses: ['CASE_DOCUMENT', 'PERSON_DOCUMENT', 'STANDALONE'], + layout: 'FORM', + numberPrefix: 'VB', + fields: TEMPLATE_FIELDS.filter((field) => field.key !== 'bewertung'), + }, + { + key: 'sonderarbeitsvertrag', + name: 'Sonderarbeitsvertrag', + description: 'Vertrag über eine befristete Sondertätigkeit, mehrseitig mit Unterschriften', + uses: ['PERSON_DOCUMENT'], + layout: 'PROSE', + numberPrefix: 'SAV', + // Aufbau eines Vertragstexts: feste Bausteine, die Platzhalter ziehen die + // Werte der Eingabefelder darüber. Der Wortlaut gehört der Abteilung und + // wird im Vorlagen-Editor gepflegt, nicht hier festgeschrieben. + fields: [ + { key: 'vertragspartner', label: 'Vertragspartner', type: 'shorttext', required: true }, + { key: 'dienstgrad', label: 'Dienstgrad', type: 'shorttext' }, + { key: 'taetigkeit', label: 'Tätigkeit', type: 'shorttext', required: true }, + { key: 'beginn', label: 'Beginn', type: 'date', required: true }, + { key: 'ende', label: 'Ende', type: 'date' }, + { key: 'verguetung', label: 'Vergütung', type: 'shorttext' }, + { key: 'ueberschrift', label: 'Überschrift', type: 'heading', text: 'Sonderarbeitsvertrag' }, + { + key: 'praeambel', + label: 'Präambel', + type: 'paragraph', + text: + 'Zwischen der Abteilung für innere Sicherheit, vertreten durch die Abteilungsleitung, ' + + 'und {{vertragspartner}} ({{dienstgrad}}) wird der folgende Sonderarbeitsvertrag geschlossen.', + }, + { + key: 'paragraph_1', + label: '§ 1 Gegenstand', + type: 'paragraph', + text: + '§ 1 Gegenstand\n\nGegenstand dieses Vertrages ist die Tätigkeit als {{taetigkeit}}. ' + + 'Die Tätigkeit wird ab dem {{beginn}} ausgeübt und endet am {{ende}}.', + }, + { + key: 'paragraph_2', + label: '§ 2 Vergütung', + type: 'paragraph', + text: '§ 2 Vergütung\n\nDie Vergütung beträgt {{verguetung}} und wird monatlich abgerechnet.', + }, + { + key: 'paragraph_3', + label: '§ 3 Verschwiegenheit', + type: 'paragraph', + text: + '§ 3 Verschwiegenheit\n\nDer Vertragspartner verpflichtet sich, über alle im Rahmen ' + + 'der Tätigkeit bekannt gewordenen Vorgänge Stillschweigen zu bewahren. Die Pflicht ' + + 'besteht über das Ende des Vertrages hinaus fort.', + }, + { key: 'unterschrift_partner', label: '{{vertragspartner}}', type: 'signature' }, + { key: 'unterschrift_leitung', label: 'Abteilungsleitung', type: 'signature' }, + ], + }, + { + key: 'bussgeld', + name: 'Bußgeldbescheid', + description: 'Geldbuße wegen eines Dienstvergehens, an einer Person oder für sich stehend', + uses: ['PERSON_DOCUMENT', 'STANDALONE'], + layout: 'FORM', + numberPrefix: 'BG', + fields: [ + { key: 'betroffener', label: 'Betroffene Person', type: 'shorttext', required: true }, + { key: 'tatzeitpunkt', label: 'Tatzeitpunkt', type: 'date', required: true }, + { key: 'tatvorwurf', label: 'Tatvorwurf', type: 'longtext', required: true }, + { key: 'rechtsgrundlage', label: 'Rechtsgrundlage', type: 'shorttext' }, + { key: 'betrag', label: 'Betrag', type: 'number', required: true }, + { key: 'zahlungsfrist', label: 'Zahlungsfrist', type: 'date' }, + { key: 'rechtsmittel', label: 'Rechtsmittelbelehrung', type: 'longtext' }, + { key: 'unterschrift', label: 'Abteilungsleitung', type: 'signature' }, + ], + }, +] + async function main(): Promise { for (const config of CLEARANCE_LEVELS) { await db.clearanceLevelConfig.upsert({ @@ -129,15 +233,22 @@ async function main(): Promise { }) console.log('[seed] Änderungsrichtlinie GRACE_WINDOW, 15 Minuten') - for (const template of TEMPLATES) { - await db.caseTemplate.upsert({ + const alle = [...TEMPLATES, ...DOCUMENT_TEMPLATES] + for (const template of alle) { + const fields = (template.fields ?? TEMPLATE_FIELDS) as object[] + const uses = template.uses ?? ['CASE'] + await db.documentTemplate.upsert({ where: { key_version: { key: template.key, version: 1 } }, create: { - ...template, + key: template.key, + name: template.name, + description: template.description, + uses, + layout: template.layout ?? 'FORM', numberPrefix: template.numberPrefix ?? 'AfiS', numberPadding: template.numberPadding ?? 4, version: 1, - fields: TEMPLATE_FIELDS, + fields, isActive: true, }, // Version 1 gehört dem Seed und wird von ihm aktuell gehalten. Ändert @@ -146,24 +257,26 @@ async function main(): Promise { update: { name: template.name, description: template.description, - fields: TEMPLATE_FIELDS, + uses, + layout: template.layout ?? 'FORM', + fields, }, }) } - console.log(`[seed] ${TEMPLATES.length} Startvorlagen`) + console.log(`[seed] ${TEMPLATES.length} Fallarten, ${DOCUMENT_TEMPLATES.length} Dokumentarten`) // Ein Nummernkreis je Präfix und Jahr. Angelegt wird er beim ersten Fall // von selbst; hier steht er nur, damit ein frischer Bestand nachvollziehbar // bei null beginnt. const year = new Date().getFullYear() - for (const prefix of [...new Set(TEMPLATES.map((t) => t.numberPrefix ?? 'AfiS'))]) { - await db.caseCounter.upsert({ + for (const prefix of [...new Set(alle.map((t) => t.numberPrefix ?? 'AfiS'))]) { + await db.numberSeries.upsert({ where: { prefix_year: { prefix, year } }, create: { prefix, year, lastNumber: 0 }, update: {}, }) } - console.log(`[seed] Aktenzeichen-Zähler für ${year}`) + console.log(`[seed] Nummernkreise 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. diff --git a/packages/db/tests/regeln.test.ts b/packages/db/tests/regeln.test.ts index 0fe26ef..a8e3d03 100644 --- a/packages/db/tests/regeln.test.ts +++ b/packages/db/tests/regeln.test.ts @@ -20,7 +20,7 @@ suite('Datenbankregeln', () => { beforeAll(async () => { db = createPrismaClient(url) - const template = await db.caseTemplate.create({ + const template = await db.documentTemplate.create({ data: { key: `${marke}-vorlage`, version: 1, name: 'Prüfvorlage', fields: [] }, }) const user = await db.user.create({ @@ -148,6 +148,72 @@ suite('Datenbankregeln', () => { ).rejects.toThrow() }) + it('lässt ein Dokument nicht löschen', async () => { + const doc = await db.document.create({ + data: { + documentNumber: `PRF-9999-${String(Date.now()).slice(-6)}`, + title: 'Prüfdokument', + templateId: (await db.documentTemplate.findFirstOrThrow({ + where: { key: `${marke}-vorlage` }, + })).id, + templateKey: `${marke}-vorlage`, + templateVersion: 1, + caseId, + createdById: `${marke}-nutzer`, + }, + }) + + // Die Regel schluckt das DELETE, statt zu werfen: so verhält sich + // DO INSTEAD NOTHING. Entscheidend ist, dass die Zeile stehen bleibt. + await db.document.deleteMany({ where: { id: doc.id } }) + expect(await db.document.findUnique({ where: { id: doc.id } })).not.toBeNull() + }) + + it('lässt ein Dokument nicht an zwei Besitzern zugleich hängen', async () => { + const person = await db.personnelFile.create({ + data: { displayName: `${marke} Person`, classification: 1 }, + }) + + await expect( + db.document.create({ + data: { + documentNumber: `PRF-9998-${String(Date.now()).slice(-6)}`, + title: 'Zwei Besitzer', + templateId: (await db.documentTemplate.findFirstOrThrow({ + where: { key: `${marke}-vorlage` }, + })).id, + templateKey: `${marke}-vorlage`, + templateVersion: 1, + caseId, + personnelFileId: person.id, + createdById: `${marke}-nutzer`, + }, + }), + ).rejects.toThrow() + }) + + it('lässt den Besitzer eines Dokuments nicht nachträglich wechseln', async () => { + const doc = await db.document.create({ + data: { + documentNumber: `PRF-9997-${String(Date.now()).slice(-6)}`, + title: 'Unverrückbar', + templateId: (await db.documentTemplate.findFirstOrThrow({ + where: { key: `${marke}-vorlage` }, + })).id, + templateKey: `${marke}-vorlage`, + templateVersion: 1, + caseId, + createdById: `${marke}-nutzer`, + }, + }) + + // Ein Vertrag, der nachträglich an einer anderen Person hängt, wäre eine + // Fälschung. Der Trigger lässt das nicht zu. + await expect( + db.document.update({ where: { id: doc.id }, data: { caseId: null } }), + ).rejects.toThrow() + }) + it('nimmt geschwärzte Bereiche nicht in den Suchvektor auf', async () => { await db.caseEntry.create({ data: { diff --git a/web/app/(app)/admin/richtlinie/actions.ts b/web/app/(app)/admin/richtlinie/actions.ts index 8c8850c..9b979fa 100644 --- a/web/app/(app)/admin/richtlinie/actions.ts +++ b/web/app/(app)/admin/richtlinie/actions.ts @@ -24,7 +24,7 @@ export async function savePolicy(input: unknown): Promise { const user = await requirePermissionForAction('admin') const parsed = schema.safeParse(input) - if (!parsed.success) return { ok: false, error: de.case.errors.default! } + if (!parsed.success) return { ok: false, error: de.actionErrors.default! } const before = await db.appSetting.findUnique({ where: { id: 1 } }) diff --git a/web/app/(app)/admin/vorlagen/actions.ts b/web/app/(app)/admin/vorlagen/actions.ts index 01b2fa9..0849dcd 100644 --- a/web/app/(app)/admin/vorlagen/actions.ts +++ b/web/app/(app)/admin/vorlagen/actions.ts @@ -36,13 +36,13 @@ export async function saveTemplate(input: unknown): Promise { const erste = parsed.error.issues[0] return { ok: false, - error: erste ? `${erste.path.join('.')}: ${erste.message}` : de.case.errors.default!, + error: erste ? `${erste.path.join('.')}: ${erste.message}` : de.actionErrors.default!, } } const { key, fields, ...rest } = parsed.data - const neueste = await db.caseTemplate.findFirst({ + const neueste = await db.documentTemplate.findFirst({ where: { key }, orderBy: { version: 'desc' }, select: { version: true }, @@ -51,9 +51,9 @@ export async function saveTemplate(input: unknown): Promise { await db.$transaction(async (tx) => { // Ältere Versionen bleiben stehen, aber nur die neueste ist auswählbar. - await tx.caseTemplate.updateMany({ where: { key }, data: { isActive: false } }) + await tx.documentTemplate.updateMany({ where: { key }, data: { isActive: false } }) - await tx.caseTemplate.create({ + await tx.documentTemplate.create({ data: { key, version, @@ -68,7 +68,7 @@ export async function saveTemplate(input: unknown): Promise { await writeAudit(tx, { actorId: user.id, action: 'roles.update', - targetType: 'CaseTemplate', + targetType: 'DocumentTemplate', targetId: `${key}:${version}`, clearanceAtTime: user.permissions.clearanceLevel, meta: { key, version, fieldCount: fields.length }, diff --git a/web/app/(app)/admin/vorlagen/page.tsx b/web/app/(app)/admin/vorlagen/page.tsx index 032c5c9..19ff524 100644 --- a/web/app/(app)/admin/vorlagen/page.tsx +++ b/web/app/(app)/admin/vorlagen/page.tsx @@ -11,7 +11,7 @@ export default async function TemplateAdminPage() { await requirePermission('template.manage') // Je Schlüssel die neueste Version; ältere bleiben für bestehende Akten stehen. - const vorlagen = await db.caseTemplate.findMany({ + const vorlagen = await db.documentTemplate.findMany({ orderBy: [{ key: 'asc' }, { version: 'desc' }], distinct: ['key'], }) diff --git a/web/app/(app)/dokumente/[documentNumber]/DocumentActions.tsx b/web/app/(app)/dokumente/[documentNumber]/DocumentActions.tsx new file mode 100644 index 0000000..133af0a --- /dev/null +++ b/web/app/(app)/dokumente/[documentNumber]/DocumentActions.tsx @@ -0,0 +1,162 @@ +'use client' + +import { useState, useTransition } from 'react' +import { de } from '@/lib/i18n/de' +import { + finalizeDocumentAction, + reclassifyDocumentAction, + retractDocumentAction, +} from '../actions' + +/** + * Ausfertigen, Zurückziehen, Umklassifizieren. + * + * Wie beim Fall bewusst **nicht** optimistisch: alle drei sind folgenreich. + * Das Ausfertigen fragt nach, das Zurückziehen verlangt eine Begründung — es + * gibt kein Löschen, der Grabstein bleibt stehen. + */ +export function DocumentActions({ + documentNumber, + classification, + canFinalize, + canRetract, + canReclassify, + levels, +}: { + documentNumber: string + classification: number + canFinalize: boolean + canRetract: boolean + canReclassify: boolean + levels: { level: number; name: string }[] +}) { + const [reason, setReason] = useState('') + const [retracting, setRetracting] = useState(false) + const [nextLevel, setNextLevel] = useState(classification) + const [levelReason, setLevelReason] = useState('') + const [error, setError] = useState(null) + const [pending, startTransition] = useTransition() + + if (!canFinalize && !canRetract && !canReclassify) return null + + const lowering = nextLevel < classification + + function run(action: () => Promise<{ ok: boolean; error?: string }>) { + setError(null) + startTransition(async () => { + const result = await action() + if (!result.ok) setError(result.error ?? de.actionErrors.default!) + else setRetracting(false) + }) + } + + return ( +
+ {canFinalize ? ( +
+ +
+ ) : null} + + {canReclassify ? ( +
+

{de.document.reclassify}

+
+ + +
+ {lowering ? ( +