From 5f9f5d99fb3ad212cc8b8272a954231d0807e1c1 Mon Sep 17 00:00:00 2001 From: Harlekin Date: Thu, 3 Sep 2026 15:16:10 +0200 Subject: [PATCH] =?UTF-8?q?Protokoll=20im=20Adminbereich,=20ein=20Knopf=20?= =?UTF-8?q?f=C3=BCr=20das=20ganze=20Blatt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die restlichen zwei Änderungswünsche. **Protokoll.** „Wer was wo macht" stand seit dem ersten Tag in AuditLog — harte Regel 3 sorgt dafür. Was fehlte, war die Ansicht darauf. /admin/protokoll ist genau das und keine zweite Aufzeichnung: filterbar nach Person, Aktion, Art und Zeitraum, rückwärts blätternd. Nur für die Leitung. Eine Zeile nennt Aktenzeichen und Namen, und schon die Existenz einer Zeile verrät, dass es einen Vorgang gibt; ein gefiltertes Protokoll für alle wäre ein Weg, die Sichtbarkeitsregeln von hinten aufzurollen. Aus demselben Grund werden Kennungen ohne Sichtbarkeitsfilter zu Aktenzeichen aufgelöst — ein Protokoll aus UUIDs liest niemand, und eines, das niemand liest, ist keine Kontrolle. **Speichern.** Jedes Feld trug seinen eigenen Knopf: ein Arbeitsvertrag mit zwölf Feldern hiess zwölfmal speichern. Der Knopf steht jetzt am Blatt. Geschrieben wird trotzdem je Feld und nicht als Transaktion, und das folgt aus dem Datenmodell: Abschnitte sind einzeln versioniert, weil mehrere Leute gleichzeitig an einer Akte arbeiten. Als Transaktion blockierte ein einziges zwischenzeitlich geändertes Feld das ganze Blatt — elf gute Änderungen verloren, weil jemand das zwölfte angefasst hat. So geht durch, was durchgeht, und die Rückmeldung sagt, was offen blieb und wo. Lichtbilder bleiben ausgenommen: ein Upload mit Prüfkette ist keine Feldänderung. lib/audit.ts liegt jetzt als lib/audit/write.ts neben lib/audit/query.ts. Co-Authored-By: Claude Opus 5 --- docs/13-entscheidungen.md | 42 +++ web/app/(app)/admin/protokoll/page.tsx | 242 ++++++++++++++++++ web/app/(app)/admin/richtlinie/actions.ts | 2 +- web/app/(app)/admin/vorlagen/actions.ts | 2 +- .../(app)/dokumente/[documentNumber]/page.tsx | 6 +- web/app/(app)/dokumente/actions.ts | 17 +- web/app/(app)/faelle/[caseNumber]/page.tsx | 5 +- .../faelle/[caseNumber]/sectionActions.ts | 26 +- web/app/(app)/personalakten/[id]/page.tsx | 2 +- web/app/api/admin/export/route.ts | 2 +- web/app/api/exports/[id]/route.ts | 2 +- web/app/api/files/[id]/route.ts | 2 +- web/components/sections/SectionEditor.tsx | 71 ++++- web/components/sections/Sections.tsx | 22 +- web/components/sections/SectionsForm.tsx | 127 +++++++++ web/components/sections/types.ts | 22 ++ web/lib/audit/query.ts | 183 +++++++++++++ web/lib/{audit.ts => audit/write.ts} | 0 web/lib/cases/entryService.ts | 2 +- web/lib/cases/links.ts | 2 +- web/lib/cases/participants.ts | 2 +- web/lib/cases/sectionService.ts | 2 +- web/lib/cases/service.ts | 2 +- web/lib/chat/service.ts | 2 +- web/lib/documents/service.ts | 2 +- web/lib/export/database.ts | 2 +- web/lib/export/service.ts | 2 +- web/lib/files/service.ts | 2 +- web/lib/i18n/admin.ts | 75 ++++++ web/lib/i18n/cases.ts | 7 + web/lib/i18n/chat.ts | 2 +- web/lib/i18n/de.ts | 2 + web/lib/i18n/documents.ts | 1 + web/lib/i18n/shell.ts | 2 + web/lib/notify/enqueue.ts | 2 +- web/lib/personnel/service.ts | 2 +- web/next-env.d.ts | 2 +- web/tests/chat.test.ts | 2 +- web/tests/protokoll.test.ts | 126 +++++++++ web/tsconfig.json | 29 ++- 40 files changed, 1005 insertions(+), 42 deletions(-) create mode 100644 web/app/(app)/admin/protokoll/page.tsx create mode 100644 web/components/sections/SectionsForm.tsx create mode 100644 web/lib/audit/query.ts rename web/lib/{audit.ts => audit/write.ts} (100%) create mode 100644 web/tests/protokoll.test.ts diff --git a/docs/13-entscheidungen.md b/docs/13-entscheidungen.md index 2bb4672..a50ad10 100644 --- a/docs/13-entscheidungen.md +++ b/docs/13-entscheidungen.md @@ -20,6 +20,8 @@ Wo eine Entscheidung fachlich anders ausfallen kann, steht das ausdrücklich dab | `04-datenmodell.md` nennt zugleich `CREATE SEQUENCE case_number_2026` und die Tabelle `CaseCounter(year, lastNumber)` mit `SELECT … FOR UPDATE` | `CaseCounter`. Eine Sequenz pro Jahr müsste jedes Jahr von Hand angelegt werden, und der Abteilungsleiter ist ausdrücklich nicht technisch | | Das rohe SQL benutzt `search_vector` und `updated_at` in Schlangenschrift, die Prisma-Modelle Höckerschrift | Prisma-Voreinstellung behalten: Tabellen in Höckerschrift-Pascal, Spalten in Höckerschrift. Das SQL quotet entsprechend (`"searchVector"`, `"updatedAt"`) | | Kein Modell für Kanäle, obwohl `ChatMessage.channelId` existiert und `06-chat-und-realtime.md` Kanäle mit eigener Mindeststufe verlangt | Modell `Channel` ergänzt, dazu `ChatReaction` für den festen Reaktionssatz | + +> Nachtrag: `Channel` ist inzwischen wieder entfernt, siehe „Freie Kanäle sind entfallen" weiter unten. | `/api/health` soll den Discord-Gateway prüfen, aber die Web-App fragt nie live bei Discord an | Modell `ServiceHeartbeat`. Der Worker schreibt alle 30 Sekunden seinen Zustand, `web` liest ihn | ## Der wichtigste Widerspruch @@ -1003,3 +1005,43 @@ Profilbild. Das Bild kommt von Discord — dort liegt es ohnehin, die Anmeldung dieselbe Stelle, und ein eigener Zwischenspeicher wäre ein Dienst mehr im Stack für ein Bild von 64 Pixeln. Dafür steht `cdn.discordapp.com` in der Inhaltsrichtlinie. Wer kein Bild hat, bekommt seine Initialen; ein Platzhaltergesicht lädt nichts nach und sagt nichts. + +### Das Protokoll ist eine Ansicht, keine neue Spur + +„Wer was wo macht" stand vom ersten Tag an in `AuditLog` — harte Regel 3 aus CLAUDE.md sorgt +dafür, dass jede schreibende Aktion und jeder Lesezugriff ab Stufe 3 eine Zeile erzeugt. Was +fehlte, war die Ansicht darauf. `/admin/protokoll` ist genau das und **nicht** eine zweite +Aufzeichnung. + +Zwei Festlegungen: + +**Nur die Leitung.** Eine Protokollzeile nennt Aktenzeichen und Namen, und schon die Existenz +einer Zeile verrät, dass es einen Vorgang gibt. Ein „gefiltertes Protokoll für alle" wäre ein +Weg, die Sichtbarkeitsregeln von hinten aufzurollen: aus den Lücken liesse sich lesen, was man +nicht sehen darf. Deshalb `requirePermission('admin')` und sonst nichts. + +**Kennungen werden aufgelöst, ohne Sichtbarkeitsfilter.** Aus `Case:0192…` wird +`AfiS-2026-0042`. Ohne das wäre die Ansicht eine Spalte UUIDs, die niemand liest — und ein +Protokoll, das niemand liest, ist keine Kontrolle. Das ist die Ausnahme, die es geben muss, +damit die Leitung prüfen kann, ob die Regeln eingehalten wurden. Sie ist genau deshalb auf die +Leitung beschränkt. + +Zeilen, deren Ziel sich nicht auflösen lässt — ein Exportauftrag etwa —, bleiben mit ihrer +rohen Kennung stehen. Ein Protokoll mit Lücken wäre keins. + +### Ein Knopf für das ganze Blatt + +Jedes Feld trug seinen eigenen Speichern-Knopf. Einen Arbeitsvertrag mit zwölf Feldern +auszufüllen hiess zwölfmal speichern, und wer eines vergass, merkte es erst beim Nachlesen. +Der Knopf steht jetzt am Blatt. + +Gespeichert wird trotzdem **je Feld und nicht als Transaktion**. Das ist kein halber Umbau, +sondern folgt aus dem Datenmodell: Abschnitte sind einzeln versioniert, weil an einer Akte +mehrere Leute gleichzeitig arbeiten. Wäre das Speichern eine Transaktion, blockierte ein +einziges zwischenzeitlich geändertes Feld das ganze Blatt — elf gute Änderungen gingen +verloren, weil jemand anderes das zwölfte angefasst hat. Stattdessen geht durch, was durchgeht, +und die Rückmeldung sagt, was offen blieb und wo. Der Fassungsvergleich steht weiterhin am +betroffenen Feld. + +Lichtbilder bleiben ausgenommen: ein Bild wird hochgeladen und geprüft, das ist ein eigener +Vorgang mit eigener Prüfkette und keine Feldänderung. diff --git a/web/app/(app)/admin/protokoll/page.tsx b/web/app/(app)/admin/protokoll/page.tsx new file mode 100644 index 0000000..98b87ea --- /dev/null +++ b/web/app/(app)/admin/protokoll/page.tsx @@ -0,0 +1,242 @@ +import type { Metadata } from 'next' +import Link from 'next/link' +import { requirePermission } from '@/lib/auth/current' +import { auditActions, auditActors, queryAuditLog, type AuditRow } from '@/lib/audit/query' +import { de } from '@/lib/i18n/de' + +export const metadata: Metadata = { title: de.audit.heading } +export const dynamic = 'force-dynamic' + +type Params = Record + +/** + * Das Zugriffsprotokoll. + * + * Es beantwortet die eine Frage, die eine Abteilungsleitung an ein Aktensystem + * hat: **wer hat was wo getan.** Nach harter Regel 3 aus CLAUDE.md entsteht + * die Spur bei jeder schreibenden Aktion und bei jedem Lesezugriff ab Stufe 3, + * ohne Ausnahme und ohne Möglichkeit, sie zu unterdrücken. + * + * Nur für die Leitung. Das folgt aus dem, was drinsteht: eine Zeile nennt + * Aktenzeichen und Namen, und schon die Existenz einer Zeile verrät, dass es + * einen Vorgang gibt. Ein „gefiltertes Protokoll für alle" wäre ein Weg, die + * Sichtbarkeitsregeln von hinten aufzurollen. + * + * Der Zustand steht in der Adresse, wie bei der Suche: teilbar, und der + * Zurück-Knopf tut das Richtige. + */ +export default async function AuditLogPage({ + searchParams, +}: { + searchParams: Promise +}) { + await requirePermission('admin') + const params = await searchParams + + const wert = (name: string): string | undefined => { + const roh = params[name] + const text = (Array.isArray(roh) ? roh[0] : roh)?.trim() + return text ? text : undefined + } + + const filter = { + actorId: wert('wer'), + action: wert('was'), + targetType: wert('art'), + targetId: wert('ziel'), + from: datum(wert('ab')), + to: datum(wert('bis'), true), + before: wert('vor') ? BigInt(wert('vor')!) : undefined, + } + + const [{ rows, more }, akteure, aktionen] = await Promise.all([ + queryAuditLog(filter), + auditActors(), + auditActions(), + ]) + + const arten = [...new Set(rows.map((zeile) => zeile.targetType))].sort() + const gefiltert = Boolean( + filter.actorId || filter.action || filter.targetType || filter.targetId || filter.from || filter.to, + ) + + return ( +
+

{de.audit.heading}

+

{de.audit.intro}

+ +
+ + + + + + + + + {filter.targetType ? ( + + ) : null} + {filter.targetId ? : null} + + + {gefiltert ? ( + + {de.audit.reset} + + ) : null} +
+ + {arten.length > 1 || filter.targetType ? ( +
+ + {de.audit.all} + + {arten.map((art) => ( + + {de.audit.targets[art] ?? art} + + ))} +
+ ) : null} + + {rows.length === 0 ? ( +

{de.audit.empty}

+ ) : ( +
+ + + + + + + + + + + + {rows.map((zeile) => ( + + ))} + +
{de.audit.when}{de.audit.who}{de.audit.what}{de.audit.where}{de.audit.clearance}
+
+ )} + + {more && rows.length > 0 ? ( +
+ + {de.audit.older} + +
+ ) : null} +
+ ) +} + +function Zeile({ zeile, params }: { zeile: AuditRow; params: Params }) { + return ( + + + {zeile.at.toLocaleString('de-DE')} + + + + {zeile.actorName} + + + {de.audit.actions[zeile.action] ?? zeile.action} + + + {de.audit.targets[zeile.targetType] ?? zeile.targetType} + {' '} + {zeile.targetHref ? ( + + {zeile.targetLabel} + + ) : ( + {zeile.targetLabel ?? de.audit.gone} + )} + + {zeile.clearanceAtTime} + + ) +} + +/** Datum aus dem Formular. „Bis" meint den ganzen Tag, nicht Mitternacht. */ +function datum(text: string | undefined, endeDesTages = false): Date | undefined { + if (!text) return undefined + const wert = new Date(text) + if (Number.isNaN(wert.getTime())) return undefined + if (endeDesTages) wert.setHours(23, 59, 59, 999) + return wert +} + +/** Die eigene Adresse mit geänderten Parametern. */ +function adresse(params: Params, aenderungen: Record): string { + const such = new URLSearchParams() + for (const [name, roh] of Object.entries(params)) { + const text = Array.isArray(roh) ? roh[0] : roh + if (text) such.set(name, text) + } + for (const [name, wert] of Object.entries(aenderungen)) { + if (wert === undefined) such.delete(name) + else such.set(name, wert) + } + const rest = such.toString() + return rest ? `/admin/protokoll?${rest}` : '/admin/protokoll' +} diff --git a/web/app/(app)/admin/richtlinie/actions.ts b/web/app/(app)/admin/richtlinie/actions.ts index 9b979fa..86a78e6 100644 --- a/web/app/(app)/admin/richtlinie/actions.ts +++ b/web/app/(app)/admin/richtlinie/actions.ts @@ -4,7 +4,7 @@ import { revalidatePath } from 'next/cache' import { z } from 'zod' import { db } from '@/lib/db' import { requirePermissionForAction } from '@/lib/auth/current' -import { writeAudit } from '@/lib/audit' +import { writeAudit } from '@/lib/audit/write' import { de } from '@/lib/i18n/de' const schema = z.object({ diff --git a/web/app/(app)/admin/vorlagen/actions.ts b/web/app/(app)/admin/vorlagen/actions.ts index 0849dcd..c3c43c8 100644 --- a/web/app/(app)/admin/vorlagen/actions.ts +++ b/web/app/(app)/admin/vorlagen/actions.ts @@ -4,7 +4,7 @@ import { revalidatePath } from 'next/cache' import { z } from 'zod' import { db } from '@/lib/db' import { requirePermissionForAction } from '@/lib/auth/current' -import { writeAudit } from '@/lib/audit' +import { writeAudit } from '@/lib/audit/write' import { templateFieldsSchema } from '@/lib/templates/schema' import { de } from '@/lib/i18n/de' diff --git a/web/app/(app)/dokumente/[documentNumber]/page.tsx b/web/app/(app)/dokumente/[documentNumber]/page.tsx index 964f0a1..67a85ee 100644 --- a/web/app/(app)/dokumente/[documentNumber]/page.tsx +++ b/web/app/(app)/dokumente/[documentNumber]/page.tsx @@ -4,7 +4,7 @@ import Link from 'next/link' import { notFound } from 'next/navigation' import { db } from '@/lib/db' import { requireUser } from '@/lib/auth/current' -import { recordRead, READ_AUDIT_FROM_LEVEL } from '@/lib/audit' +import { recordRead, READ_AUDIT_FROM_LEVEL } from '@/lib/audit/write' import { loadDocumentSections, loadVisibleDocument } from '@/lib/documents/service' import { canEditDocument, @@ -17,7 +17,7 @@ import { SkeletonLine } from '@/components/Skeleton' import { Sections } from '@/components/sections/Sections' import { Sheet } from '@/components/documents/Sheet' import { SheetSwitch } from '@/components/documents/SheetSwitch' -import { saveDocumentSectionAction } from '../actions' +import { saveDocumentSectionAction, saveDocumentSectionsAction } from '../actions' import { DocumentActions } from './DocumentActions' import { Attachments } from '@/components/files/Attachments' import { listAttachments, resolvePhotoUrls } from '@/lib/files/service' @@ -68,6 +68,8 @@ async function Body({ > { + const parsed = z.array(sectionSchema).max(200).safeParse(input) + if (!parsed.success) return {} + + const ergebnisse: Record = {} + for (const feld of parsed.data as SaveSectionsInput) { + ergebnisse[feld.fieldKey] = await saveDocumentSectionAction(documentNumber, feld) + } + return ergebnisse +} diff --git a/web/app/(app)/faelle/[caseNumber]/page.tsx b/web/app/(app)/faelle/[caseNumber]/page.tsx index 132bfa9..829e3e8 100644 --- a/web/app/(app)/faelle/[caseNumber]/page.tsx +++ b/web/app/(app)/faelle/[caseNumber]/page.tsx @@ -6,12 +6,12 @@ import { db } from '@/lib/db' import { requireUser } from '@/lib/auth/current' import { visibleEntryWhere } from '@/lib/access/caseFilter' import { loadVisibleCase } from '@/lib/cases/service' -import { recordRead, READ_AUDIT_FROM_LEVEL } from '@/lib/audit' +import { recordRead, READ_AUDIT_FROM_LEVEL } from '@/lib/audit/write' import { de } from '@/lib/i18n/de' import { SkeletonLine } from '@/components/Skeleton' import { EntryComposer } from './EntryComposer' import { Sections } from '@/components/sections/Sections' -import { saveSectionAction } from './sectionActions' +import { saveSectionAction, saveSectionsAction } from './sectionActions' import { loadSections } from '@/lib/cases/sectionService' import { canEditCase } from '@/lib/access/caseWrite' import { EntryActions } from './EntryActions' @@ -145,6 +145,7 @@ async function SectionBlock({ diff --git a/web/app/(app)/faelle/[caseNumber]/sectionActions.ts b/web/app/(app)/faelle/[caseNumber]/sectionActions.ts index 3cfcca6..af216dc 100644 --- a/web/app/(app)/faelle/[caseNumber]/sectionActions.ts +++ b/web/app/(app)/faelle/[caseNumber]/sectionActions.ts @@ -6,7 +6,7 @@ import { requireUser } from '@/lib/auth/current' import { AccessError, ConflictError } from '@/lib/errors' import { saveSection } from '@/lib/cases/sectionService' import { de } from '@/lib/i18n/de' -import type { SaveSectionResult } from '@/components/sections/types' +import type { SaveSectionResult, SaveSectionsInput } from '@/components/sections/types' const schema = z.object({ fieldKey: z.string().min(1), @@ -47,3 +47,27 @@ export async function saveSectionAction( return { ok: false, conflict: false, error: de.actionErrors.default! } } } + +/** + * Mehrere Abschnitte in einem Zug. + * + * Jedes Feld wird einzeln geschrieben und einzeln beantwortet — Abschnitte + * sind einzeln versioniert, und ein zwischenzeitlich geändertes Feld soll die + * übrigen nicht aufhalten. Wäre das eine Transaktion, blockierte ein einziger + * fremder Eingriff das ganze Blatt. + */ +export async function saveSectionsAction( + caseNumber: string, + input: unknown, +): Promise> { + const parsed = batchSchema.safeParse(input) + if (!parsed.success) return {} + + const ergebnisse: Record = {} + for (const feld of parsed.data as SaveSectionsInput) { + ergebnisse[feld.fieldKey] = await saveSectionAction(caseNumber, feld) + } + return ergebnisse +} + +const batchSchema = z.array(schema).max(200) diff --git a/web/app/(app)/personalakten/[id]/page.tsx b/web/app/(app)/personalakten/[id]/page.tsx index 3a1a299..f65df9d 100644 --- a/web/app/(app)/personalakten/[id]/page.tsx +++ b/web/app/(app)/personalakten/[id]/page.tsx @@ -4,7 +4,7 @@ import Link from 'next/link' import { notFound } from 'next/navigation' import { db } from '@/lib/db' import { requirePermission } from '@/lib/auth/current' -import { recordRead, READ_AUDIT_FROM_LEVEL } from '@/lib/audit' +import { recordRead, READ_AUDIT_FROM_LEVEL } from '@/lib/audit/write' import { listAssignments, listStatusTags, diff --git a/web/app/api/admin/export/route.ts b/web/app/api/admin/export/route.ts index f827560..921d91a 100644 --- a/web/app/api/admin/export/route.ts +++ b/web/app/api/admin/export/route.ts @@ -6,7 +6,7 @@ import { exportDatabase } from '@/lib/export/database' import { createZip } from '@/lib/export/zip' import { absolutePath } from '@/lib/files/paths' import { readFile } from 'node:fs/promises' -import { writeAudit } from '@/lib/audit' +import { writeAudit } from '@/lib/audit/write' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' diff --git a/web/app/api/exports/[id]/route.ts b/web/app/api/exports/[id]/route.ts index 74d5ab3..6cb8383 100644 --- a/web/app/api/exports/[id]/route.ts +++ b/web/app/api/exports/[id]/route.ts @@ -4,7 +4,7 @@ import { Readable } from 'node:stream' import { NextResponse } from 'next/server' import { db } from '@/lib/db' import { getCurrentUser } from '@/lib/auth/current' -import { recordRead } from '@/lib/audit' +import { recordRead } from '@/lib/audit/write' import { absolutePath } from '@/lib/files/paths' export const runtime = 'nodejs' diff --git a/web/app/api/files/[id]/route.ts b/web/app/api/files/[id]/route.ts index c973e8b..dfda898 100644 --- a/web/app/api/files/[id]/route.ts +++ b/web/app/api/files/[id]/route.ts @@ -4,7 +4,7 @@ import { Readable } from 'node:stream' import { NextResponse } from 'next/server' import { db } from '@/lib/db' import { getCurrentUser } from '@/lib/auth/current' -import { recordRead, READ_AUDIT_FROM_LEVEL } from '@/lib/audit' +import { recordRead, READ_AUDIT_FROM_LEVEL } from '@/lib/audit/write' import { loadDeliverable } from '@/lib/files/service' import { absolutePath } from '@/lib/files/paths' import { deliveryFileName } from '@/lib/files/delivery' diff --git a/web/components/sections/SectionEditor.tsx b/web/components/sections/SectionEditor.tsx index 387ce5f..5b1f8a0 100644 --- a/web/components/sections/SectionEditor.tsx +++ b/web/components/sections/SectionEditor.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState, useTransition } from 'react' import { sectionText } from '@/lib/i18n/cases' +import { useSectionsForm } from './SectionsForm' import type { SaveSection } from './types' type Props = { @@ -31,6 +32,10 @@ const AUTOSAVE_MS = 2000 * Knopfdruck: ein Abschnitt ist der Sachstand der Akte, nicht ein Chatbeitrag. * Siehe docs/09-performance-und-ux.md. * + * Der Knopf steht **am Blatt, nicht am Feld**: dieses Feld meldet sich beim + * umgebenden Formular an und wird mit allen anderen zusammen gespeichert. Nur + * wenn kein Formular da ist, trägt es seinen eigenen Knopf. + * * Beim Speichern geht die geladene Version mit. Weicht sie ab, gibt es kein * stilles Überschreiben, sondern beide Fassungen nebeneinander. */ @@ -44,6 +49,49 @@ export function SectionEditor(props: Props) { const [draftKept, setDraftKept] = useState(false) const [pending, startTransition] = useTransition() const timer = useRef | null>(null) + const formular = useSectionsForm() + + // Was zuletzt gespeichert wurde. Daran hängt, ob dieses Feld beim nächsten + // Speichern überhaupt mitgeschickt wird. + const gespeichert = useRef(normalise(props.initialValue, props.type)) + const aktuell = useRef(value) + aktuell.current = value + const stand = useRef(version) + stand.current = version + + useEffect(() => { + if (!formular) return + return formular.register({ + fieldKey: props.fieldKey, + collect: () => + aktuell.current === gespeichert.current + ? null + : { + fieldKey: props.fieldKey, + value: parse(aktuell.current, props.type), + baseVersion: stand.current, + }, + apply: (ergebnis) => { + if (ergebnis.ok) { + gespeichert.current = aktuell.current + setVersion(ergebnis.version) + setConflict(null) + setError(null) + setSaved(true) + setDraftKept(false) + formular.changed(props.fieldKey, false) + try { + localStorage.removeItem(speicherSchluessel) + } catch { + // egal + } + return + } + if (ergebnis.conflict) setConflict(ergebnis.current) + else setError(ergebnis.error) + }, + }) + }, [formular, props.fieldKey, props.type, speicherSchluessel]) // Entwurf aus dem letzten Besuch übernehmen, falls er neuer ist. useEffect(() => { @@ -61,6 +109,7 @@ export function SectionEditor(props: Props) { function change(next: string) { setValue(next) setSaved(false) + formular?.changed(props.fieldKey, next !== gespeichert.current) if (timer.current) clearTimeout(timer.current) timer.current = setTimeout(() => { try { @@ -82,10 +131,12 @@ export function SectionEditor(props: Props) { }) if (result.ok) { + gespeichert.current = override === undefined ? value : normalise(override, props.type) setVersion(result.version) setConflict(null) setSaved(true) setDraftKept(false) + formular?.changed(props.fieldKey, false) try { localStorage.removeItem(speicherSchluessel) } catch { @@ -139,14 +190,18 @@ export function SectionEditor(props: Props) { )}
- + {/* Ohne umgebendes Formular trägt das Feld seinen eigenen Knopf. */} + {formular ? null : ( + + )} {version > 0 ? ( {sectionText.version(version)} ) : null} diff --git a/web/components/sections/Sections.tsx b/web/components/sections/Sections.tsx index 00fab0b..b7fba9e 100644 --- a/web/components/sections/Sections.tsx +++ b/web/components/sections/Sections.tsx @@ -2,8 +2,9 @@ import { de } from '@/lib/i18n/de' import { fillPlaceholders, isInputField } from '@/lib/templates/fields' import { RedactedText } from '@/components/Redacted' import { SectionEditor } from './SectionEditor' +import { SectionsForm } from './SectionsForm' import { PhotoField } from '@/components/files/PhotoField' -import type { SaveSection } from './types' +import type { SaveSection, SaveSections } from './types' import type { RenderedSection } from '@/lib/sections/render' /** @@ -13,10 +14,16 @@ import type { RenderedSection } from '@/lib/sections/render' * Feste Textbausteine der Vorlage werden gerendert, nicht bearbeitet — ihre * Platzhalter ziehen die Werte der Eingabefelder. Damit trägt dieselbe * Darstellung ein Formular und einen Vertragstext. + * + * Gespeichert wird das Blatt als Ganzes: ein Knopf unten, nicht einer je Feld. + * Lichtbilder sind die Ausnahme — ein Bild wird hochgeladen und geprüft, das + * ist ein eigener Vorgang und keine Feldänderung. */ export function Sections({ scope, save, + saveAll, + saveAllLabel, sections, canEdit, documentNumber, @@ -25,6 +32,9 @@ export function Sections({ }: { scope: string save: SaveSection + /** Alle geänderten Felder in einem Zug. Fehlt sie, trägt jedes Feld seinen Knopf. */ + saveAll?: SaveSections + saveAllLabel?: string sections: RenderedSection[] canEdit: boolean /** Nur bei Dokumenten gesetzt: Ziel für das Lichtbild. */ @@ -42,7 +52,7 @@ export function Sections({ sections.filter((s) => s.visible).map((s) => [s.fieldKey, s.value]), ) - return ( + const blatt = (
{sections.map((section) => { const field = section.field @@ -148,4 +158,12 @@ export function Sections({ })}
) + + if (!canEdit || !saveAll) return blatt + + return ( + + {blatt} + + ) } diff --git a/web/components/sections/SectionsForm.tsx b/web/components/sections/SectionsForm.tsx new file mode 100644 index 0000000..50d7bad --- /dev/null +++ b/web/components/sections/SectionsForm.tsx @@ -0,0 +1,127 @@ +'use client' + +import { createContext, useContext, useRef, useState, type ReactNode } from 'react' +import { sectionText } from '@/lib/i18n/cases' +import type { SaveSectionResult, SaveSections } from './types' + +/** + * Das Formular um die Abschnitte herum: **ein** Knopf für das ganze Blatt. + * + * Vorher trug jedes Feld seinen eigenen. Einen Arbeitsvertrag auszufüllen + * hiess damit zwölfmal speichern, und wer eines vergass, merkte es erst beim + * Nachlesen. Jetzt sammelt dieses Formular ein, was sich geändert hat, und + * schickt es in einem Zug. + * + * Gespeichert wird **je Feld** und nicht als Ganzes. Das ist kein Versehen: + * Abschnitte sind einzeln versioniert, und wenn eines zwischenzeitlich von + * jemand anderem geändert wurde, soll das die übrigen nicht aufhalten. Die + * Rückmeldung sagt dann genau, was durchging und was noch offen ist. + * + * Die Felder melden sich hier an, statt ihren Zustand nach oben zu geben: + * so löst ein Tastendruck in einem Feld kein Neuzeichnen des ganzen Blattes + * aus. + */ +export type FieldHandle = { + fieldKey: string + /** Der geänderte Wert, oder null wenn unverändert. */ + collect: () => { fieldKey: string; value: unknown; baseVersion: number } | null + apply: (result: SaveSectionResult) => void +} + +type Kontext = { + register: (handle: FieldHandle) => () => void + changed: (fieldKey: string, dirty: boolean) => void + pending: boolean +} + +const SectionsContext = createContext(null) + +export function useSectionsForm(): Kontext | null { + return useContext(SectionsContext) +} + +export function SectionsForm({ + save, + label, + children, +}: { + save: SaveSections + /** „Dokument speichern" oder „Abschnitte speichern" — je nachdem, was es ist. */ + label: string + children: ReactNode +}) { + const felder = useRef(new Map()) + const [geaendert, setGeaendert] = useState>(new Set()) + const [pending, setPending] = useState(false) + const [meldung, setMeldung] = useState(null) + + const kontext: Kontext = { + register(handle) { + felder.current.set(handle.fieldKey, handle) + return () => { + felder.current.delete(handle.fieldKey) + } + }, + changed(fieldKey, dirty) { + setGeaendert((bisher) => { + if (dirty === bisher.has(fieldKey)) return bisher + const naechste = new Set(bisher) + if (dirty) naechste.add(fieldKey) + else naechste.delete(fieldKey) + return naechste + }) + }, + pending, + } + + async function speichern() { + const eintraege = [...felder.current.values()] + .map((handle) => handle.collect()) + .filter((eintrag) => eintrag !== null) + + if (eintraege.length === 0) { + setMeldung(sectionText.unchanged) + return + } + + setPending(true) + setMeldung(null) + try { + const ergebnisse = await save(eintraege) + + let gut = 0 + let offen = 0 + for (const [fieldKey, ergebnis] of Object.entries(ergebnisse)) { + felder.current.get(fieldKey)?.apply(ergebnis) + if (ergebnis.ok) gut++ + else offen++ + } + + setMeldung(offen === 0 ? sectionText.savedCount(gut) : sectionText.partly(gut, offen)) + } finally { + setPending(false) + } + } + + return ( + + {children} + +
+ + + {geaendert.size > 0 ? sectionText.changedCount(geaendert.size) : sectionText.unchanged} + + {meldung ? {meldung} : null} +
+
+ ) +} diff --git a/web/components/sections/types.ts b/web/components/sections/types.ts index 6f32b4c..fc1b320 100644 --- a/web/components/sections/types.ts +++ b/web/components/sections/types.ts @@ -14,3 +14,25 @@ export type SaveSection = (input: { value: unknown baseVersion: number }) => Promise + +/** + * Ein ganzes Formular auf einmal speichern. + * + * Vorher trug jedes Feld seinen eigenen Knopf: einen Arbeitsvertrag mit zwölf + * Feldern zu schreiben hiess zwölfmal speichern. Jetzt sammelt das Formular + * die geänderten Felder ein und schickt sie in einem Zug. + * + * Zurück kommt ein Ergebnis **je Feld**, nicht eines für alles. Abschnitte + * sind einzeln versioniert, und wenn ein Feld zwischenzeitlich von jemand + * anderem geändert wurde, soll das nicht die übrigen elf aufhalten — es soll + * genau an diesem Feld stehen. + */ +export type SaveSectionsInput = { + fieldKey: string + value: unknown + baseVersion: number +}[] + +export type SaveSections = ( + input: SaveSectionsInput, +) => Promise> diff --git a/web/lib/audit/query.ts b/web/lib/audit/query.ts new file mode 100644 index 0000000..6304edc --- /dev/null +++ b/web/lib/audit/query.ts @@ -0,0 +1,183 @@ +import { db } from '@/lib/db' +import type { Prisma } from '@afis/db' + +/** + * Das Zugriffsprotokoll lesen. + * + * Es ist die einzige Stelle im System, die vollständig Auskunft gibt: wer hat + * wann was getan, und bei Stufe 3 aufwärts auch, wer was nur gelesen hat. Nach + * harter Regel 3 aus CLAUDE.md entsteht diese Spur bei jeder schreibenden + * Aktion, ohne Ausnahme und ohne Möglichkeit, sie zu unterdrücken. + * + * Lesen darf sie **nur die Abteilungsleitung**. Das ist keine Bequemlichkeit, + * sondern folgt aus dem, was drinsteht: eine Zeile nennt Aktenzeichen und + * Namen, und schon die Existenz einer Zeile verrät, dass es einen Vorgang + * gibt. Ein gefiltertes Protokoll für alle wäre ein Weg, die Sichtbarkeits- + * regeln von hinten aufzurollen. + */ + +export const AUDIT_PAGE = 50 + +export type AuditFilter = { + actorId?: string + action?: string + targetType?: string + targetId?: string + from?: Date + to?: Date + /** Kennung der ältesten schon gezeigten Zeile, zum Weiterblättern. */ + before?: bigint +} + +export type AuditRow = { + id: string + at: Date + actorId: string + actorName: string + action: string + targetType: string + targetId: string + /** Aktenzeichen, Name oder Dokumentnummer — was man wiedererkennt. */ + targetLabel: string | null + targetHref: string | null + clearanceAtTime: number + meta: Prisma.JsonValue +} + +export async function queryAuditLog( + filter: AuditFilter, +): Promise<{ rows: AuditRow[]; more: boolean }> { + const where: Prisma.AuditLogWhereInput = { + ...(filter.actorId ? { actorId: filter.actorId } : {}), + ...(filter.action ? { action: filter.action } : {}), + ...(filter.targetType ? { targetType: filter.targetType } : {}), + ...(filter.targetId ? { targetId: filter.targetId } : {}), + ...(filter.before ? { id: { lt: filter.before } } : {}), + ...(filter.from || filter.to + ? { + at: { + ...(filter.from ? { gte: filter.from } : {}), + ...(filter.to ? { lte: filter.to } : {}), + }, + } + : {}), + } + + // Eine Zeile mehr holen, als gezeigt wird: daran hängt der Knopf zum + // Weiterblättern, ohne dass eine zweite Zählabfrage nötig wäre. + const zeilen = await db.auditLog.findMany({ + where, + orderBy: { id: 'desc' }, + take: AUDIT_PAGE + 1, + }) + + const seite = zeilen.slice(0, AUDIT_PAGE) + const namen = await namenVon(seite.map((zeile) => zeile.actorId)) + const ziele = await zieleVon(seite) + + return { + rows: seite.map((zeile) => { + const ziel = ziele.get(`${zeile.targetType}:${zeile.targetId}`) + return { + id: zeile.id.toString(), + at: zeile.at, + actorId: zeile.actorId, + actorName: namen.get(zeile.actorId) ?? zeile.actorId, + action: zeile.action, + targetType: zeile.targetType, + targetId: zeile.targetId, + targetLabel: ziel?.label ?? null, + targetHref: ziel?.href ?? null, + clearanceAtTime: zeile.clearanceAtTime, + meta: zeile.meta, + } + }), + more: zeilen.length > AUDIT_PAGE, + } +} + +async function namenVon(ids: string[]): Promise> { + if (ids.length === 0) return new Map() + const nutzer = await db.user.findMany({ + where: { id: { in: [...new Set(ids)] } }, + select: { id: true, displayName: true }, + }) + return new Map(nutzer.map((n) => [n.id, n.displayName])) +} + +/** + * Aus Kennungen werden Aktenzeichen und Namen. + * + * Ohne das steht im Protokoll eine Spalte UUIDs, und niemand liest es. Der + * Nachschlag läuft **ohne** Sichtbarkeitsfilter — das Protokoll ist die + * Ausnahme, die es geben muss, damit die Leitung prüfen kann, ob die Regeln + * eingehalten wurden. Genau deshalb kommt hier auch nur die Leitung hin. + */ +async function zieleVon( + zeilen: { targetType: string; targetId: string }[], +): Promise> { + const je = (art: string): string[] => [ + ...new Set(zeilen.filter((z) => z.targetType === art).map((z) => z.targetId)), + ] + + const fallIds = je('Case') + const personIds = je('PersonnelFile') + const dokumentIds = je('Document') + + const [faelle, personen, dokumente] = await Promise.all([ + fallIds.length + ? db.case.findMany({ + where: { id: { in: fallIds } }, + select: { id: true, caseNumber: true }, + }) + : Promise.resolve([]), + personIds.length + ? db.personnelFile.findMany({ + where: { id: { in: personIds } }, + select: { id: true, displayName: true }, + }) + : Promise.resolve([]), + dokumentIds.length + ? db.document.findMany({ + where: { id: { in: dokumentIds } }, + select: { id: true, documentNumber: true }, + }) + : Promise.resolve([]), + ]) + + const karte = new Map() + for (const akte of faelle) { + karte.set(`Case:${akte.id}`, { + label: akte.caseNumber, + href: `/faelle/${akte.caseNumber}`, + }) + } + for (const person of personen) { + karte.set(`PersonnelFile:${person.id}`, { + label: person.displayName, + href: `/personalakten/${person.id}`, + }) + } + for (const dokument of dokumente) { + karte.set(`Document:${dokument.id}`, { + label: dokument.documentNumber, + href: `/dokumente/${dokument.documentNumber}`, + }) + } + return karte +} + +/** Die Akteure, die überhaupt im Protokoll vorkommen — für die Auswahlliste. */ +export async function auditActors(): Promise<{ id: string; name: string }[]> { + const gruppen = await db.auditLog.groupBy({ by: ['actorId'], _count: { actorId: true } }) + const namen = await namenVon(gruppen.map((g) => g.actorId)) + return gruppen + .map((g) => ({ id: g.actorId, name: namen.get(g.actorId) ?? g.actorId })) + .sort((a, b) => a.name.localeCompare(b.name, 'de')) +} + +/** Die Aktionen, die tatsächlich vorkommen. Keine leeren Filtereinträge. */ +export async function auditActions(): Promise { + const gruppen = await db.auditLog.groupBy({ by: ['action'] }) + return gruppen.map((g) => g.action).sort() +} diff --git a/web/lib/audit.ts b/web/lib/audit/write.ts similarity index 100% rename from web/lib/audit.ts rename to web/lib/audit/write.ts diff --git a/web/lib/cases/entryService.ts b/web/lib/cases/entryService.ts index 91b7779..52083f1 100644 --- a/web/lib/cases/entryService.ts +++ b/web/lib/cases/entryService.ts @@ -1,5 +1,5 @@ import { db } from '@/lib/db' -import { writeAudit } from '@/lib/audit' +import { writeAudit } from '@/lib/audit/write' import { visibleEntryWhere } from '@/lib/access/caseFilter' import { canEditCase } from '@/lib/access/caseWrite' import { AccessError } from '@/lib/cases/service' diff --git a/web/lib/cases/links.ts b/web/lib/cases/links.ts index 17f1037..41dbe15 100644 --- a/web/lib/cases/links.ts +++ b/web/lib/cases/links.ts @@ -1,5 +1,5 @@ import { db } from '@/lib/db' -import { writeAudit } from '@/lib/audit' +import { writeAudit } from '@/lib/audit/write' import { visibleCaseWhere } from '@/lib/access/caseFilter' import { canEditCase } from '@/lib/access/caseWrite' import { AccessError, must } from '@/lib/errors' diff --git a/web/lib/cases/participants.ts b/web/lib/cases/participants.ts index a8b83df..9d669a9 100644 --- a/web/lib/cases/participants.ts +++ b/web/lib/cases/participants.ts @@ -1,5 +1,5 @@ import { db } from '@/lib/db' -import { writeAudit } from '@/lib/audit' +import { writeAudit } from '@/lib/audit/write' import { visibleCaseWhere } from '@/lib/access/caseFilter' import { visiblePersonnelWhere } from '@/lib/access/personnelFilter' import { canEditCase } from '@/lib/access/caseWrite' diff --git a/web/lib/cases/sectionService.ts b/web/lib/cases/sectionService.ts index b4501ca..06d8c20 100644 --- a/web/lib/cases/sectionService.ts +++ b/web/lib/cases/sectionService.ts @@ -1,5 +1,5 @@ import { db } from '@/lib/db' -import { writeAudit } from '@/lib/audit' +import { writeAudit } from '@/lib/audit/write' import { canEditCase } from '@/lib/access/caseWrite' import { loadVisibleCase } from '@/lib/cases/service' import { AccessError } from '@/lib/errors' diff --git a/web/lib/cases/service.ts b/web/lib/cases/service.ts index bc414b8..6309fc1 100644 --- a/web/lib/cases/service.ts +++ b/web/lib/cases/service.ts @@ -1,5 +1,5 @@ import { db } from '@/lib/db' -import { writeAudit } from '@/lib/audit' +import { writeAudit } from '@/lib/audit/write' import { visibleCaseWhere } from '@/lib/access/caseFilter' import { canCreateCase, canEditCase, canReclassify } from '@/lib/access/caseWrite' import { nextNumber } from '@/lib/numbering' diff --git a/web/lib/chat/service.ts b/web/lib/chat/service.ts index 720a10a..d54eb59 100644 --- a/web/lib/chat/service.ts +++ b/web/lib/chat/service.ts @@ -1,5 +1,5 @@ import { db } from '@/lib/db' -import { writeAudit } from '@/lib/audit' +import { writeAudit } from '@/lib/audit/write' import { AccessError, must } from '@/lib/errors' import { visibleCaseWhere } from '@/lib/access/caseFilter' import { visibleMessageWhere } from '@/lib/access/chatFilter' diff --git a/web/lib/documents/service.ts b/web/lib/documents/service.ts index 74a71df..4042b01 100644 --- a/web/lib/documents/service.ts +++ b/web/lib/documents/service.ts @@ -1,5 +1,5 @@ import { db } from '@/lib/db' -import { writeAudit } from '@/lib/audit' +import { writeAudit } from '@/lib/audit/write' import { visibleDocumentWhere, visiblePersonnelWhere } from '@/lib/access/personnelFilter' import { visibleCaseWhere } from '@/lib/access/caseFilter' import { diff --git a/web/lib/export/database.ts b/web/lib/export/database.ts index 54d8f7e..f74aad3 100644 --- a/web/lib/export/database.ts +++ b/web/lib/export/database.ts @@ -1,5 +1,5 @@ import { db } from '@/lib/db' -import { writeAudit } from '@/lib/audit' +import { writeAudit } from '@/lib/audit/write' import { AccessError } from '@/lib/errors' import type { EffectivePermissions } from '@/lib/auth/permissions' diff --git a/web/lib/export/service.ts b/web/lib/export/service.ts index 0a33195..a254fa6 100644 --- a/web/lib/export/service.ts +++ b/web/lib/export/service.ts @@ -1,7 +1,7 @@ import { writeFile } from 'node:fs/promises' import { db } from '@/lib/db' import { getEnv } from '@/lib/env' -import { writeAudit, READ_AUDIT_FROM_LEVEL } from '@/lib/audit' +import { writeAudit, READ_AUDIT_FROM_LEVEL } from '@/lib/audit/write' import { AccessError } from '@/lib/errors' import { loadVisibleCase } from '@/lib/cases/service' import { loadVisibleDocument } from '@/lib/documents/service' diff --git a/web/lib/files/service.ts b/web/lib/files/service.ts index d286f95..19e0e26 100644 --- a/web/lib/files/service.ts +++ b/web/lib/files/service.ts @@ -1,6 +1,6 @@ import { writeFile } from 'node:fs/promises' import { db } from '@/lib/db' -import { writeAudit } from '@/lib/audit' +import { writeAudit } from '@/lib/audit/write' import { AccessError, must } from '@/lib/errors' import { visibleAttachmentWhere } from '@/lib/access/attachmentFilter' import { canEditCase } from '@/lib/access/caseWrite' diff --git a/web/lib/i18n/admin.ts b/web/lib/i18n/admin.ts index ec0daca..571d38a 100644 --- a/web/lib/i18n/admin.ts +++ b/web/lib/i18n/admin.ts @@ -1,3 +1,78 @@ +export const auditText = { + heading: 'Protokoll', + intro: + 'Wer was wo getan hat. Jede schreibende Aktion steht hier, dazu jeder Lesezugriff ab Stufe 3. Einträge lassen sich nicht ändern und nicht entfernen.', + when: 'Zeitpunkt', + who: 'Wer', + what: 'Was', + where: 'Wo', + clearance: 'Stufe', + filterActor: 'Person', + filterAction: 'Aktion', + filterTarget: 'Art', + filterFrom: 'Ab', + filterTo: 'Bis', + apply: 'Filtern', + reset: 'Alles zeigen', + all: 'Alle', + empty: 'Für diesen Ausschnitt steht nichts im Protokoll.', + older: 'Ältere anzeigen', + gone: 'nicht mehr auffindbar', + ofUser: 'Nur von dieser Person', + ofTarget: 'Nur zu diesem Vorgang', + actions: { + 'case.read': 'Fallakte gelesen', + 'case.create': 'Fallakte angelegt', + 'case.update': 'Fallakte geändert', + 'case.reclassify': 'Fallakte umklassifiziert', + 'case.status': 'Status geändert', + 'entry.create': 'Vermerk angelegt', + 'entry.edit': 'Vermerk geändert', + 'entry.retract': 'Vermerk zurückgezogen', + 'entry.restore': 'Vermerk wiederhergestellt', + 'case.participant.add': 'Beteiligten eingetragen', + 'case.participant.remove': 'Beteiligten ausgetragen', + 'case.link': 'Akten verknüpft', + 'case.unlink': 'Verknüpfung gelöst', + 'personnel.read': 'Personalakte gelesen', + 'personnel.create': 'Personalakte angelegt', + 'personnel.update': 'Personalakte geändert', + 'personnel.reclassify': 'Personalakte umklassifiziert', + 'personnel.assign': 'Person zugeordnet', + 'personnel.unassign': 'Zuordnung aufgehoben', + 'document.read': 'Dokument gelesen', + 'document.create': 'Dokument angelegt', + 'document.update': 'Dokument geändert', + 'document.finalize': 'Dokument abgeschlossen', + 'document.retract': 'Dokument zurückgezogen', + 'document.reclassify': 'Dokument umklassifiziert', + 'attachment.upload': 'Datei hochgeladen', + 'attachment.read': 'Datei abgerufen', + 'attachment.release': 'Datei freigegeben', + 'chat.adopt': 'Nachricht als Vermerk übernommen', + 'roles.update': 'Rollenzuordnung geändert', + 'export.pdf': 'PDF erzeugt', + 'export.approve': 'Export freigegeben', + 'export.reject': 'Export abgelehnt', + 'export.download': 'Export heruntergeladen', + 'export.database': 'Datenbankabzug erzeugt', + } as Record, + targets: { + Case: 'Fallakte', + CaseEntry: 'Vermerk', + CaseSection: 'Abschnitt', + PersonnelFile: 'Personalakte', + Document: 'Dokument', + DocumentSection: 'Dokumentabschnitt', + Attachment: 'Datei', + ChatMessage: 'Nachricht', + ExportJob: 'Export', + RoleMapping: 'Rollenzuordnung', + ChangePolicy: 'Änderungsrichtlinie', + Database: 'Datenbank', + } as Record, +} as const + export const adminText = { heading: 'Admin', intro: 'Rollen, Vorlagen, Regeln und das Protokoll der Abteilung.', diff --git a/web/lib/i18n/cases.ts b/web/lib/i18n/cases.ts index e682ddc..9798fe5 100644 --- a/web/lib/i18n/cases.ts +++ b/web/lib/i18n/cases.ts @@ -64,9 +64,16 @@ export const sectionText = { heading: 'Abschnitte', empty: 'Die Vorlage dieser Fallart hat keine Abschnitte.', save: 'Abschnitt speichern', + saveAll: 'Abschnitte speichern', saving: 'Wird gespeichert', saved: 'Gespeichert', draftKept: 'Entwurf lokal gesichert', + unchanged: 'Nichts geändert', + changedCount: (n: number) => (n === 1 ? '1 Feld geändert' : `${n} Felder geändert`), + savedCount: (n: number) => (n === 1 ? '1 Feld gespeichert' : `${n} Felder gespeichert`), + partly: (gespeichert: number, offen: number) => + `${gespeichert} gespeichert, ${offen} offen. Die offenen stehen unten mit beiden Fassungen.`, + discard: 'Änderungen verwerfen', version: (v: number) => `v${v}`, history: 'Fassungen', conflict: diff --git a/web/lib/i18n/chat.ts b/web/lib/i18n/chat.ts index b5f2911..6ac27f2 100644 --- a/web/lib/i18n/chat.ts +++ b/web/lib/i18n/chat.ts @@ -1,5 +1,5 @@ /** - * Aktenchat und freie Kanäle. + * Der Chat einer Akte. * * Eigene Datei und nicht bei den Fallakten, obwohl der Aktenchat an einer * Akte hängt: die Fallakte lädt ihre Texte sofort, den Chat erst beim diff --git a/web/lib/i18n/de.ts b/web/lib/i18n/de.ts index bca3031..64cd47d 100644 --- a/web/lib/i18n/de.ts +++ b/web/lib/i18n/de.ts @@ -43,6 +43,7 @@ import { documentText, attachmentText } from '@/lib/i18n/documents' import { exportText } from '@/lib/i18n/export' import { adminText, + auditText, roleAdminText, templateAdminText, settingsText, @@ -55,6 +56,7 @@ export const de = { auth: authText, overview: overviewText, admin: adminText, + audit: auditText, roleAdmin: roleAdminText, clearance: clearanceText, case: caseText, diff --git a/web/lib/i18n/documents.ts b/web/lib/i18n/documents.ts index 6088582..da56b7f 100644 --- a/web/lib/i18n/documents.ts +++ b/web/lib/i18n/documents.ts @@ -11,6 +11,7 @@ */ export const documentText = { + saveAll: 'Dokument speichern', one: 'Dokument', many: 'Dokumente', number: 'Dokumentnummer', diff --git a/web/lib/i18n/shell.ts b/web/lib/i18n/shell.ts index a7a1855..08e2fcb 100644 --- a/web/lib/i18n/shell.ts +++ b/web/lib/i18n/shell.ts @@ -179,6 +179,8 @@ export const paletteText = { { href: '/faelle/neu', label: 'Neue Fallakte' }, { href: '/personalakten', label: 'Personalakten' }, { href: '/suche', label: 'Suche' }, + { href: '/admin', label: 'Admin' }, + { href: '/admin/protokoll', label: 'Protokoll' }, { href: '/einstellungen', label: 'Benachrichtigungen' }, ], } as const diff --git a/web/lib/notify/enqueue.ts b/web/lib/notify/enqueue.ts index 6df15ed..cdfce6d 100644 --- a/web/lib/notify/enqueue.ts +++ b/web/lib/notify/enqueue.ts @@ -1,5 +1,5 @@ import { db } from '@/lib/db' -import { READ_AUDIT_FROM_LEVEL } from '@/lib/audit' +import { READ_AUDIT_FROM_LEVEL } from '@/lib/audit/write' import { visibleCaseWhere } from '@/lib/access/caseFilter' import { visiblePersonnelWhere } from '@/lib/access/personnelFilter' import { resolvePermissions } from '@/lib/auth/effective' diff --git a/web/lib/personnel/service.ts b/web/lib/personnel/service.ts index a6808a1..678229b 100644 --- a/web/lib/personnel/service.ts +++ b/web/lib/personnel/service.ts @@ -1,5 +1,5 @@ import { db } from '@/lib/db' -import { writeAudit } from '@/lib/audit' +import { writeAudit } from '@/lib/audit/write' import { visibleCaseWhere } from '@/lib/access/caseFilter' import { visiblePersonnelWhere } from '@/lib/access/personnelFilter' import { canWritePersonnel } from '@/lib/access/documentWrite' diff --git a/web/next-env.d.ts b/web/next-env.d.ts index 830fb59..341b947 100644 --- a/web/next-env.d.ts +++ b/web/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -/// +/// // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/web/tests/chat.test.ts b/web/tests/chat.test.ts index d032eab..56f3ed2 100644 --- a/web/tests/chat.test.ts +++ b/web/tests/chat.test.ts @@ -31,7 +31,7 @@ function perms( } } -suite('Aktenchat und Kanäle', () => { +suite('Aktenchat', () => { const nutzer = { stufe1: `${marke}-stufe1`, stufe4: `${marke}-stufe4` } let offenNummer = '' let geheimNummer = '' diff --git a/web/tests/protokoll.test.ts b/web/tests/protokoll.test.ts new file mode 100644 index 0000000..cd0c2d9 --- /dev/null +++ b/web/tests/protokoll.test.ts @@ -0,0 +1,126 @@ +// @vitest-environment node +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { db } from '@/lib/db' +import { writeAudit } from '@/lib/audit/write' +import { queryAuditLog, AUDIT_PAGE } from '@/lib/audit/query' + +/** + * Das Protokoll ist die Gegenprobe zum ganzen System: hier soll stehen, was + * passiert ist, vollständig und unverändert. Geprüft wird deshalb zweierlei — + * dass die Filter genau schneiden, und dass eine Zeile nicht verschwinden + * kann, wenn ihr Ziel verschwindet. + */ +const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL +const suite = url ? describe : describe.skip + +const marke = `prot-${Date.now()}` + +suite('Zugriffsprotokoll', () => { + const akteur = `${marke}-akteur` + const anderer = `${marke}-anderer` + let fallId = '' + let fallNummer = '' + + beforeAll(async () => { + const vorlage = await db.documentTemplate.create({ + data: { key: `${marke}-vorlage`, version: 1, name: 'Prüfvorlage', fields: [] }, + }) + for (const id of [akteur, anderer]) { + await db.user.create({ data: { id, username: id, displayName: `Name ${id}` } }) + } + + const akte = await db.case.create({ + data: { + templateId: vorlage.id, + templateKey: vorlage.key, + createdById: akteur, + caseNumber: `Prot-2026-${Date.now().toString(36)}`, + title: 'Vorgang', + classification: 1, + status: 'OPEN', + }, + }) + fallId = akte.id + fallNummer = akte.caseNumber + + await writeAudit(db, { + actorId: akteur, + action: 'case.create', + targetType: 'Case', + targetId: fallId, + clearanceAtTime: 4, + }) + await writeAudit(db, { + actorId: anderer, + action: 'case.read', + targetType: 'Case', + targetId: fallId, + clearanceAtTime: 1, + }) + await writeAudit(db, { + actorId: akteur, + action: 'export.pdf', + targetType: 'ExportJob', + targetId: `${marke}-auftrag`, + clearanceAtTime: 4, + }) + }) + + afterAll(async () => { + await db.$disconnect() + }) + + it('filtert nach Person und schneidet dabei genau', async () => { + const { rows } = await queryAuditLog({ actorId: akteur }) + const eigene = rows.filter((zeile) => zeile.actorId === akteur) + expect(eigene).toHaveLength(rows.length) + expect(rows.some((zeile) => zeile.action === 'case.create')).toBe(true) + expect(rows.some((zeile) => zeile.actorId === anderer)).toBe(false) + }) + + it('filtert nach Aktion und nach Ziel', async () => { + const nurLesen = await queryAuditLog({ action: 'case.read', targetId: fallId }) + expect(nurLesen.rows).toHaveLength(1) + expect(nurLesen.rows[0]?.actorId).toBe(anderer) + + const nurExport = await queryAuditLog({ targetType: 'ExportJob', actorId: akteur }) + expect(nurExport.rows.every((zeile) => zeile.targetType === 'ExportJob')).toBe(true) + }) + + it('macht aus Kennungen Aktenzeichen und lässt Unauflösbares stehen', async () => { + const { rows } = await queryAuditLog({ actorId: akteur }) + + const zurAkte = rows.find((zeile) => zeile.targetId === fallId) + expect(zurAkte?.targetLabel).toBe(fallNummer) + expect(zurAkte?.targetHref).toBe(`/faelle/${fallNummer}`) + + // Ein Auftrag ist kein Datensatz, den das Protokoll auflösen kann. Die + // Zeile bleibt trotzdem stehen — ein Protokoll mit Lücken wäre keins. + const zumAuftrag = rows.find((zeile) => zeile.targetType === 'ExportJob') + expect(zumAuftrag).toBeDefined() + expect(zumAuftrag?.targetLabel).toBeNull() + }) + + it('blättert rückwärts ohne Zeile doppelt oder gar nicht zu zeigen', async () => { + const erste = await queryAuditLog({}) + expect(erste.rows.length).toBeLessThanOrEqual(AUDIT_PAGE) + + if (erste.more) { + const zweite = await queryAuditLog({ before: BigInt(erste.rows[erste.rows.length - 1]!.id) }) + const kennungen = new Set(erste.rows.map((zeile) => zeile.id)) + expect(zweite.rows.some((zeile) => kennungen.has(zeile.id))).toBe(false) + } + }) + + it('lässt sich nicht ändern und nicht entfernen', async () => { + const zeile = await db.auditLog.findFirstOrThrow({ + where: { actorId: akteur, action: 'case.create' }, + }) + + // Harte Regel 3 aus CLAUDE.md steht als Regel in der Datenbank, nicht nur + // in der Anwendung: das Löschen läuft ins Leere statt zu wirken. + await db.auditLog.deleteMany({ where: { id: zeile.id } }) + const danach = await db.auditLog.findUnique({ where: { id: zeile.id } }) + expect(danach).not.toBeNull() + }) +}) diff --git a/web/tsconfig.json b/web/tsconfig.json index 503406d..a0d47a3 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -1,16 +1,35 @@ { "extends": "../tsconfig.base.json", "compilerOptions": { - "lib": ["DOM", "DOM.Iterable", "ES2023"], + "lib": [ + "DOM", + "DOM.Iterable", + "ES2023" + ], "jsx": "preserve", "moduleResolution": "Bundler", "allowJs": true, "incremental": true, - "plugins": [{ "name": "next" }], + "plugins": [ + { + "name": "next" + } + ], "paths": { - "@/*": ["./*"] + "@/*": [ + "./*" + ] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules", "e2e"] + "include": [ + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + "next-env.d.ts", + ".next-build/types/**/*.ts" + ], + "exclude": [ + "node_modules", + "e2e" + ] }