Protokoll im Adminbereich, ein Knopf für das ganze Blatt

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.
This commit is contained in:
Harlekin
2026-09-03 15:16:10 +02:00
parent 6af1e437e7
commit 89edaa6ae1
39 changed files with 963 additions and 42 deletions
+242
View File
@@ -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<string, string | string[] | undefined>
/**
* 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<Params>
}) {
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 (
<section>
<h2 className="font-condensed">{de.audit.heading}</h2>
<p className="mt-1 max-w-(--container-measure) text-2xs text-text-dim">{de.audit.intro}</p>
<form method="get" className="mt-6 flex flex-wrap items-end gap-3">
<label className="text-2xs text-text-dim">
{de.audit.filterActor}
<select name="wer" defaultValue={filter.actorId ?? ''} className="field mt-1 text-2xs">
<option value="">{de.audit.all}</option>
{akteure.map((akteur) => (
<option key={akteur.id} value={akteur.id}>
{akteur.name}
</option>
))}
</select>
</label>
<label className="text-2xs text-text-dim">
{de.audit.filterAction}
<select name="was" defaultValue={filter.action ?? ''} className="field mt-1 text-2xs">
<option value="">{de.audit.all}</option>
{aktionen.map((aktion) => (
<option key={aktion} value={aktion}>
{de.audit.actions[aktion] ?? aktion}
</option>
))}
</select>
</label>
<label className="text-2xs text-text-dim">
{de.audit.filterFrom}
<input
type="date"
name="ab"
defaultValue={wert('ab') ?? ''}
className="field mt-1 font-mono text-2xs"
/>
</label>
<label className="text-2xs text-text-dim">
{de.audit.filterTo}
<input
type="date"
name="bis"
defaultValue={wert('bis') ?? ''}
className="field mt-1 font-mono text-2xs"
/>
</label>
{filter.targetType ? (
<input type="hidden" name="art" value={filter.targetType} />
) : null}
{filter.targetId ? <input type="hidden" name="ziel" value={filter.targetId} /> : null}
<button type="submit" className="btn btn-sm btn-primary">
{de.audit.apply}
</button>
{gefiltert ? (
<Link href="/admin/protokoll" className="btn btn-sm">
{de.audit.reset}
</Link>
) : null}
</form>
{arten.length > 1 || filter.targetType ? (
<div className="mt-3 flex flex-wrap gap-2">
<Link
href={adresse(params, { art: undefined, vor: undefined })}
aria-current={filter.targetType ? undefined : 'page'}
className="btn btn-sm"
>
{de.audit.all}
</Link>
{arten.map((art) => (
<Link
key={art}
href={adresse(params, { art, vor: undefined })}
aria-current={filter.targetType === art ? 'page' : undefined}
className="btn btn-sm"
>
{de.audit.targets[art] ?? art}
</Link>
))}
</div>
) : null}
{rows.length === 0 ? (
<p className="mt-8 text-text-dim">{de.audit.empty}</p>
) : (
<div className="mt-6 overflow-x-auto">
<table className="w-full border-collapse text-2xs">
<thead>
<tr className="border-b border-line text-left">
<th className="py-2 pr-4">{de.audit.when}</th>
<th className="py-2 pr-4">{de.audit.who}</th>
<th className="py-2 pr-4">{de.audit.what}</th>
<th className="py-2 pr-4">{de.audit.where}</th>
<th className="py-2">{de.audit.clearance}</th>
</tr>
</thead>
<tbody>
{rows.map((zeile) => (
<Zeile key={zeile.id} zeile={zeile} params={params} />
))}
</tbody>
</table>
</div>
)}
{more && rows.length > 0 ? (
<div className="mt-6">
<Link
href={adresse(params, { vor: rows[rows.length - 1]!.id })}
className="btn btn-sm"
>
{de.audit.older}
</Link>
</div>
) : null}
</section>
)
}
function Zeile({ zeile, params }: { zeile: AuditRow; params: Params }) {
return (
<tr className="border-b border-line align-baseline">
<td className="py-2 pr-4 font-mono whitespace-nowrap text-text-dim">
{zeile.at.toLocaleString('de-DE')}
</td>
<td className="py-2 pr-4">
<Link
href={adresse(params, { wer: zeile.actorId, vor: undefined })}
title={de.audit.ofUser}
className="no-underline"
>
{zeile.actorName}
</Link>
</td>
<td className="py-2 pr-4">{de.audit.actions[zeile.action] ?? zeile.action}</td>
<td className="py-2 pr-4">
<span className="text-text-dim">
{de.audit.targets[zeile.targetType] ?? zeile.targetType}
</span>{' '}
{zeile.targetHref ? (
<Link href={zeile.targetHref} className="font-mono">
{zeile.targetLabel}
</Link>
) : (
<span className="font-mono text-text-dim">{zeile.targetLabel ?? de.audit.gone}</span>
)}
</td>
<td className="py-2 font-mono text-text-dim">{zeile.clearanceAtTime}</td>
</tr>
)
}
/** 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, string | undefined>): 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'
}
+1 -1
View File
@@ -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({
+1 -1
View File
@@ -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'
@@ -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({
<Sections
scope={`dokument:${document.documentNumber}`}
save={saveDocumentSectionAction.bind(null, document.documentNumber)}
saveAll={saveDocumentSectionsAction.bind(null, document.documentNumber)}
saveAllLabel={de.document.saveAll}
sections={sections}
canEdit={canEdit}
documentNumber={document.documentNumber}
+16 -1
View File
@@ -14,7 +14,7 @@ import {
type Owner,
} from '@/lib/documents/service'
import { de } from '@/lib/i18n/de'
import type { SaveSectionResult } from '@/components/sections/types'
import type { SaveSectionResult, SaveSectionsInput } from '@/components/sections/types'
export type ActionResult = { ok: true } | { ok: false; error: string }
@@ -154,3 +154,18 @@ export async function reclassifyDocumentAction(input: unknown): Promise<ActionRe
return { ok: false, error: toMessage(error) }
}
}
/** Dasselbe für ein Dokument: ein Knopf, ein Ergebnis je Feld. */
export async function saveDocumentSectionsAction(
documentNumber: string,
input: unknown,
): Promise<Record<string, SaveSectionResult>> {
const parsed = z.array(sectionSchema).max(200).safeParse(input)
if (!parsed.success) return {}
const ergebnisse: Record<string, SaveSectionResult> = {}
for (const feld of parsed.data as SaveSectionsInput) {
ergebnisse[feld.fieldKey] = await saveDocumentSectionAction(documentNumber, feld)
}
return ergebnisse
}
+3 -2
View File
@@ -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({
<Sections
scope={`fall:${caseNumber}`}
save={saveSectionAction.bind(null, caseNumber)}
saveAll={saveSectionsAction.bind(null, caseNumber)}
sections={sections}
canEdit={canEdit}
/>
@@ -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<Record<string, SaveSectionResult>> {
const parsed = batchSchema.safeParse(input)
if (!parsed.success) return {}
const ergebnisse: Record<string, SaveSectionResult> = {}
for (const feld of parsed.data as SaveSectionsInput) {
ergebnisse[feld.fieldKey] = await saveSectionAction(caseNumber, feld)
}
return ergebnisse
}
const batchSchema = z.array(schema).max(200)
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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'
+55
View File
@@ -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<ReturnType<typeof setTimeout> | 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) {
)}
<div className="mt-2 flex flex-wrap items-center gap-3 text-2xs">
{/* Ohne umgebendes Formular trägt das Feld seinen eigenen Knopf. */}
{formular ? null : (
<button
type="button"
onClick={() => submit()}
disabled={pending}
aria-busy={pending}
className="btn btn-primary"
>
{pending ? sectionText.saving : sectionText.save}
</button>
)}
{version > 0 ? (
<span className="font-mono text-text-dim">{sectionText.version(version)}</span>
) : null}
+20 -2
View File
@@ -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 = (
<dl className="space-y-8">
{sections.map((section) => {
const field = section.field
@@ -148,4 +158,12 @@ export function Sections({
})}
</dl>
)
if (!canEdit || !saveAll) return blatt
return (
<SectionsForm save={saveAll} label={saveAllLabel ?? de.section.saveAll}>
{blatt}
</SectionsForm>
)
}
+127
View File
@@ -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<Kontext | null>(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<string, FieldHandle>())
const [geaendert, setGeaendert] = useState<Set<string>>(new Set())
const [pending, setPending] = useState(false)
const [meldung, setMeldung] = useState<string | null>(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 (
<SectionsContext.Provider value={kontext}>
{children}
<div className="sticky bottom-0 mt-8 flex flex-wrap items-center gap-4 border-t border-line bg-surface py-3">
<button
type="button"
onClick={() => void speichern()}
disabled={pending}
aria-busy={pending}
className="btn btn-primary"
>
{pending ? sectionText.saving : label}
</button>
<span className="font-condensed text-2xs text-text-dim">
{geaendert.size > 0 ? sectionText.changedCount(geaendert.size) : sectionText.unchanged}
</span>
{meldung ? <span className="font-condensed text-2xs text-accent">{meldung}</span> : null}
</div>
</SectionsContext.Provider>
)
}
+22
View File
@@ -14,3 +14,25 @@ export type SaveSection = (input: {
value: unknown
baseVersion: number
}) => Promise<SaveSectionResult>
/**
* 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<Record<string, SaveSectionResult>>
+183
View File
@@ -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<Map<string, string>> {
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<Map<string, { label: string; href: string | null }>> {
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<string, { label: string; href: string | null }>()
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<string[]> {
const gruppen = await db.auditLog.groupBy({ by: ['action'] })
return gruppen.map((g) => g.action).sort()
}
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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'
+75
View File
@@ -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<string, string>,
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<string, string>,
} as const
export const adminText = {
heading: 'Admin',
intro: 'Rollen, Vorlagen, Regeln und das Protokoll der Abteilung.',
+7
View File
@@ -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:
+1 -1
View File
@@ -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
+2
View File
@@ -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,
+1
View File
@@ -11,6 +11,7 @@
*/
export const documentText = {
saveAll: 'Dokument speichern',
one: 'Dokument',
many: 'Dokumente',
number: 'Dokumentnummer',
+2
View File
@@ -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
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
/// <reference path="./.next-build/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+1 -1
View File
@@ -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 = ''
+126
View File
@@ -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()
})
})
+24 -5
View File
@@ -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"
]
}