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" + ] }