Phase 3 abgeschlossen: Vorlagen, Abschnitte, Schwärzung, Diff

Eine Vorlage ist eine geordnete Liste von Bausteinen: Eingabefelder und feste
Textbausteine mit Platzhaltern. Damit trägt dieselbe Struktur den
Vorfallsbericht und den Sonderarbeitsvertrag. Die Startvorlagen bilden jetzt
den echten Bericht der Abteilung ab, samt Berichtsart, Gefahreneinschätzung
und Foundation-Freigabe — letztere als gewöhnliches Feld ohne Wirkung auf den
Zugriff.

Abschnitte werden gerendert und bearbeitet, Entwürfe alle zwei Sekunden lokal
gesichert. Gespeichert wird auf Knopfdruck: ein Abschnitt ist der Sachstand
der Akte, kein Chatbeitrag. Die geladene Version geht mit; weicht sie ab, gibt
es kein stilles Überschreiben, sondern beide Fassungen nebeneinander mit
"Meine übernehmen" und "Fremde übernehmen".

Felder über der Stufe des Lesers werden serverseitig durch einen Platzhalter
ersetzt, Schwärzungen im Fliesstext kommen als Segmente. Beides so, dass der
echte Wert den Server nicht verlässt — nicht als Ausblenden im Client.

Der Wortdiff ist selbst geschrieben statt eingekauft: ein Dutzend Zeilen gegen
30 kB im Bündel für etwas, das nur serverseitig läuft.

Beim Bauen des Vorlagen-Editors riss das Bündel-Budget auf 127,9 kB, weil zod
über das Vorlagenmodul in den Client geriet. Typen und Validierung sind jetzt
getrennt: fields.ts ohne zod für Client und Server, schema.ts nur
serverseitig. Wieder bei 111,9 kB von 120.

85 Tests, 22 E2E.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Harlekin
2026-09-02 12:52:53 +02:00
co-authored by Claude Opus 5
parent d160741237
commit f5769ca58a
19 changed files with 1505 additions and 100 deletions
+7 -7
View File
@@ -68,14 +68,14 @@ Das ist der schwierigste Teil. Wenn er steht, ist der Rest Formularbau.
- [x] `CaseTemplate` mit Feldtypen aus `docs/05-akten-und-vorlagen.md` - [x] `CaseTemplate` mit Feldtypen aus `docs/05-akten-und-vorlagen.md`
- [x] Aktenzeichen-Muster je Vorlage: Präfix und Stellenzahl, Nummernkreis je Präfix und Jahr - [x] Aktenzeichen-Muster je Vorlage: Präfix und Stellenzahl, Nummernkreis je Präfix und Jahr
- [ ] Feld „Foundation-Freigabe" als gewöhnliches Vorlagenfeld, ohne Wirkung auf den Zugriff - [x] Feld „Foundation-Freigabe" als gewöhnliches Vorlagenfeld, ohne Wirkung auf den Zugriff
- [ ] Vorlagen-Editor im Adminbereich, versioniert, mit Vorschau - [x] Vorlagen-Editor im Adminbereich, versioniert, mit Vorschau
- [ ] Abschnitte rendern und bearbeiten, Autosave lokal alle 2 Sekunden - [x] Abschnitte rendern und bearbeiten, Autosave lokal alle 2 Sekunden
- [ ] Versionierung mit Diff-Ansicht - [x] Versionierung mit Diff-Ansicht
- [ ] Konflikterkennung über mitgesendete Version, 409 mit Vergleichsansicht - [x] Konflikterkennung über mitgesendete Version, 409 mit Vergleichsansicht
- [ ] `minClearance` pro Feld, serverseitige Ersetzung durch Platzhalter - [x] `minClearance` pro Feld, serverseitige Ersetzung durch Platzhalter
- [x] Schwärzungs-Auszeichnung im Freitext, Parser in `lib/redaction.ts` - [x] Schwärzungs-Auszeichnung im Freitext, Parser in `lib/redaction.ts`
- [ ] Seed mit vier Startvorlagen - [x] Seed mit vier Startvorlagen
## Phase 4 — Personalakten und Verknüpfungen ## Phase 4 — Personalakten und Verknüpfungen
+38 -3
View File
@@ -66,9 +66,39 @@ const TEMPLATES: {
] ]
const TEMPLATE_FIELDS = [ const TEMPLATE_FIELDS = [
// Nachgebaut nach dem Vorfallsbericht der Abteilung. Die Foundation-Freigabe
// steht im Kopf des Dokuments, steuert aber keinen Zugriff — das tut allein
// die interne Freigabe. Siehe docs/13-entscheidungen.md.
{
key: 'foundation_freigabe',
label: 'Foundation-Freigabe',
type: 'select',
options: ['0', '1', '2', '3', '4', '5'],
},
{
key: 'berichtsart',
label: 'Berichtsart',
type: 'multiselect',
options: [
'Vorfallsbericht',
'Sicherheitsanalyse',
'Interne Untersuchung',
'Allg. Bericht',
'Sonstiges',
],
},
{
key: 'gefahreneinschaetzung',
label: 'Gefahreneinschätzung',
type: 'select',
options: ['Keine', 'Leichte', 'Mittlere', 'Erhöhte', 'Kritische'],
},
{ key: 'vorfallsdatum', label: 'Datum des Vorfalls', type: 'date' },
{ key: 'sachverhalt', label: 'Sachverhalt', type: 'longtext', required: true }, { key: 'sachverhalt', label: 'Sachverhalt', type: 'longtext', required: true },
{ key: 'beteiligte', label: 'Beteiligte Personen', type: 'longtext' }, { key: 'beteiligte', label: 'Beteiligte Personen', type: 'longtext' },
{ key: 'feststellungen', label: 'Feststellungen', type: 'longtext' }, { key: 'feststellungen', label: 'Feststellungen', type: 'longtext' },
{ key: 'anmerkungen', label: 'Anmerkungen', type: 'longtext' },
{ key: 'massnahmen', label: 'Maßnahmen', type: 'longtext' },
{ {
key: 'bewertung', key: 'bewertung',
label: 'Abschließende Bewertung', label: 'Abschließende Bewertung',
@@ -110,9 +140,14 @@ async function main(): Promise<void> {
fields: TEMPLATE_FIELDS, fields: TEMPLATE_FIELDS,
isActive: true, isActive: true,
}, },
// Muster und Felder bleiben unangetastet: was der Leiter im // Version 1 gehört dem Seed und wird von ihm aktuell gehalten. Ändert
// Vorlagen-Editor eingestellt hat, überschreibt der Seed nicht. // der Leiter eine Vorlage im Editor, entsteht Version 2 — die fasst der
update: { name: template.name, description: template.description }, // Seed nie an, und bestehende Akten behalten ohnehin ihre Version.
update: {
name: template.name,
description: template.description,
fields: TEMPLATE_FIELDS,
},
}) })
} }
console.log(`[seed] ${TEMPLATES.length} Startvorlagen`) console.log(`[seed] ${TEMPLATES.length} Startvorlagen`)
@@ -0,0 +1,200 @@
'use client'
import { useMemo, useState, useTransition } from 'react'
import { de } from '@/lib/i18n/de'
import { isInputField, looksLikeFields, type TemplateField } from '@/lib/templates/fields'
import { saveTemplate } from './actions'
type Vorlage = {
key: string
name: string
description: string | null
version: number
numberPrefix: string
numberPadding: number
fields: unknown
}
/**
* Vorlagen-Editor.
*
* Die Bausteine werden als JSON bearbeitet. Das ist bewusst: ein
* Baukasten-Editor mit Ziehen und Ablegen wäre eine eigene Phase, und die
* Vorlagen ändern sich selten. Geprüft wird gegen dasselbe Schema wie
* serverseitig, und die Vorschau zeigt sofort, was dabei herauskommt.
*
* Nicht optimistisch: eine neue Version wirkt auf alle künftigen Akten.
*/
export function TemplateEditor({ vorlage }: { vorlage: Vorlage }) {
const [name, setName] = useState(vorlage.name)
const [description, setDescription] = useState(vorlage.description ?? '')
const [numberPrefix, setNumberPrefix] = useState(vorlage.numberPrefix)
const [numberPadding, setNumberPadding] = useState(vorlage.numberPadding)
const [json, setJson] = useState(() => JSON.stringify(vorlage.fields, null, 2))
const [result, setResult] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [pending, startTransition] = useTransition()
const geparst = useMemo(() => {
try {
const roh = JSON.parse(json) as unknown
// Nur eine grobe Formprüfung für die Vorschau. Ob die Vorlage
// gespeichert werden darf, entscheidet allein der Server.
if (!looksLikeFields(roh)) {
return { ok: false as const, fehler: de.templateAdmin.fieldsInvalid }
}
return { ok: true as const, fields: roh }
} catch {
return { ok: false as const, fehler: de.templateAdmin.invalid }
}
}, [json])
function submit() {
if (!geparst.ok) return
setError(null)
setResult(null)
startTransition(async () => {
const antwort = await saveTemplate({
key: vorlage.key,
name,
description: description || undefined,
numberPrefix,
numberPadding,
fields: geparst.fields,
})
if (antwort.ok) setResult(de.templateAdmin.saved(antwort.version))
else setError(antwort.error)
})
}
return (
<section className="border-b border-line py-6">
<div className="flex flex-wrap items-baseline gap-x-3">
<h2 className="font-condensed">{vorlage.name}</h2>
<span className="font-mono text-2xs text-text-dim">{vorlage.key}</span>
<span className="font-condensed text-2xs text-text-dim">
{de.templateAdmin.version(vorlage.version)}
</span>
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-2">
<div className="space-y-3 text-xs">
<label className="block">
<span className="font-condensed text-text-dim">{de.case.title}</span>
<input
value={name}
onChange={(event) => setName(event.target.value)}
className="mt-1 block w-full border border-line bg-raised px-2 py-1 text-sm"
/>
</label>
<label className="block">
<span className="font-condensed text-text-dim">{de.case.type}</span>
<input
value={description}
onChange={(event) => setDescription(event.target.value)}
className="mt-1 block w-full border border-line bg-raised px-2 py-1 text-sm"
/>
</label>
<div className="flex gap-3">
<label className="block">
<span className="font-condensed text-text-dim">{de.templateAdmin.numberPrefix}</span>
<input
value={numberPrefix}
onChange={(event) => setNumberPrefix(event.target.value)}
className="mt-1 block w-32 border border-line bg-raised px-2 py-1 font-mono text-sm"
/>
</label>
<label className="block">
<span className="font-condensed text-text-dim">{de.templateAdmin.numberPadding}</span>
<input
type="number"
min={1}
max={8}
value={numberPadding}
onChange={(event) => setNumberPadding(Number(event.target.value))}
className="mt-1 block w-20 border border-line bg-raised px-2 py-1 font-mono text-sm"
/>
</label>
</div>
<label className="block">
<span className="font-condensed text-text-dim">{de.templateAdmin.fieldsLabel}</span>
<textarea
value={json}
onChange={(event) => setJson(event.target.value)}
rows={16}
spellCheck={false}
className="mt-1 block w-full border border-line bg-raised px-2 py-1 font-mono text-2xs"
/>
<span className="mt-1 block text-2xs text-text-dim">{de.templateAdmin.fieldsHelp}</span>
</label>
<div className="flex items-center gap-3">
<button
type="button"
onClick={submit}
disabled={pending || !geparst.ok}
className="border border-line bg-raised px-3 py-1.5 font-condensed text-accent disabled:text-text-dim"
>
{pending ? de.case.saving : de.templateAdmin.save}
</button>
{result ? <span className="font-condensed text-2xs text-text-dim">{result}</span> : null}
{!geparst.ok ? (
<span className="font-condensed text-2xs text-stamp-4">{geparst.fehler}</span>
) : null}
{error ? <span className="font-condensed text-2xs text-stamp-4">{error}</span> : null}
</div>
</div>
<div className="border border-line p-4">
<p className="font-condensed text-xs text-text-dim">{de.templateAdmin.preview}</p>
<p className="mt-1 text-2xs text-text-dim">{de.templateAdmin.previewIntro}</p>
<div className="mt-4">
{geparst.ok ? <Vorschau fields={geparst.fields} /> : null}
</div>
</div>
</div>
</section>
)
}
/** „So sieht eine neue Akte damit aus", ohne etwas zu speichern. */
function Vorschau({ fields }: { fields: TemplateField[] }) {
if (fields.length === 0) return <p className="text-2xs text-text-dim">{de.section.empty}</p>
return (
<dl className="space-y-4">
{fields.map((field) => (
<div key={field.key}>
<dt className="flex flex-wrap items-baseline gap-x-2">
<span className="font-condensed text-2xs text-text-dim">{field.label}</span>
<span className="font-mono text-2xs text-text-dim">{field.type}</span>
{field.minClearance ? (
<span className="font-mono text-2xs text-stamp-3">≥ {field.minClearance}</span>
) : null}
{isInputField(field) && field.requiredToClose ? (
<span className="font-condensed text-2xs text-stamp-3">
{de.section.requiredToClose}
</span>
) : null}
</dt>
<dd className="mt-1">
{!isInputField(field) ? (
<p className="text-2xs text-text-dim italic">
{field.text ? field.text.slice(0, 120) : de.templateAdmin.staticBlock}
</p>
) : field.type === 'longtext' ? (
<div className="h-12 border border-line bg-raised" />
) : field.type === 'select' || field.type === 'multiselect' ? (
<p className="text-2xs text-text-dim">{(field.options ?? []).join(' · ')}</p>
) : (
<div className="h-6 w-48 border border-line bg-raised" />
)}
</dd>
</div>
))}
</dl>
)
}
+80
View File
@@ -0,0 +1,80 @@
'use server'
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 { templateFieldsSchema } from '@/lib/templates/schema'
import { de } from '@/lib/i18n/de'
const schema = z.object({
key: z.string().regex(/^[a-z][a-z0-9_-]*$/),
name: z.string().trim().min(1).max(200),
description: z.string().trim().max(500).optional(),
numberPrefix: z.string().regex(/^[A-Za-z][A-Za-z0-9-]*$/).max(20),
numberPadding: z.coerce.number().int().min(1).max(8),
fields: templateFieldsSchema,
})
export type TemplateResult =
| { ok: true; version: number }
| { ok: false; error: string }
/**
* Speichert eine Vorlage als **neue Version**.
*
* Bestehende Akten bleiben unberührt: sie merken sich ihre `templateId`. Eine
* Änderung gilt erst für Akten, die danach angelegt werden. Siehe
* docs/05-akten-und-vorlagen.md.
*/
export async function saveTemplate(input: unknown): Promise<TemplateResult> {
const user = await requirePermissionForAction('template.manage')
const parsed = schema.safeParse(input)
if (!parsed.success) {
const erste = parsed.error.issues[0]
return {
ok: false,
error: erste ? `${erste.path.join('.')}: ${erste.message}` : de.case.errors.default!,
}
}
const { key, fields, ...rest } = parsed.data
const neueste = await db.caseTemplate.findFirst({
where: { key },
orderBy: { version: 'desc' },
select: { version: true },
})
const version = (neueste?.version ?? 0) + 1
await db.$transaction(async (tx) => {
// Ältere Versionen bleiben stehen, aber nur die neueste ist auswählbar.
await tx.caseTemplate.updateMany({ where: { key }, data: { isActive: false } })
await tx.caseTemplate.create({
data: {
key,
version,
...rest,
description: rest.description || null,
fields,
isActive: true,
createdById: user.id,
},
})
await writeAudit(tx, {
actorId: user.id,
action: 'roles.update',
targetType: 'CaseTemplate',
targetId: `${key}:${version}`,
clearanceAtTime: user.permissions.clearanceLevel,
meta: { key, version, fieldCount: fields.length },
})
})
revalidatePath('/admin/vorlagen')
return { ok: true, version }
}
+46
View File
@@ -0,0 +1,46 @@
import type { Metadata } from 'next'
import { db } from '@/lib/db'
import { requirePermission } from '@/lib/auth/current'
import { de } from '@/lib/i18n/de'
import { TemplateEditor } from './TemplateEditor'
export const metadata: Metadata = { title: de.templateAdmin.heading }
export const dynamic = 'force-dynamic'
export default async function TemplateAdminPage() {
await requirePermission('template.manage')
// Je Schlüssel die neueste Version; ältere bleiben für bestehende Akten stehen.
const vorlagen = await db.caseTemplate.findMany({
orderBy: [{ key: 'asc' }, { version: 'desc' }],
distinct: ['key'],
})
return (
<main className="mx-auto max-w-5xl px-6 py-10">
<h1 className="font-condensed">{de.templateAdmin.heading}</h1>
<p className="mt-2 max-w-(--container-measure) text-text-dim">{de.templateAdmin.intro}</p>
{vorlagen.length === 0 ? (
<p className="mt-8 text-text-dim">{de.templateAdmin.empty}</p>
) : (
<div className="mt-6 border-t border-line">
{vorlagen.map((vorlage) => (
<TemplateEditor
key={vorlage.id}
vorlage={{
key: vorlage.key,
name: vorlage.name,
description: vorlage.description,
version: vorlage.version,
numberPrefix: vorlage.numberPrefix,
numberPadding: vorlage.numberPadding,
fields: vorlage.fields,
}}
/>
))}
</div>
)}
</main>
)
}
@@ -0,0 +1,210 @@
'use client'
import { useEffect, useRef, useState, useTransition } from 'react'
import { de } from '@/lib/i18n/de'
import { saveSectionAction } from './sectionActions'
type Props = {
caseNumber: string
fieldKey: string
label: string
type: string
options?: string[]
help?: string
requiredToClose?: boolean
initialValue: unknown
initialVersion: number
readOnly: boolean
}
const AUTOSAVE_MS = 2000
/**
* Ein Abschnitt zum Bearbeiten.
*
* Entwürfe werden alle zwei Sekunden **lokal** gesichert, damit ein
* versehentlicher Tab-Wechsel keinen Text kostet. Gespeichert wird erst auf
* Knopfdruck: ein Abschnitt ist der Sachstand der Akte, nicht ein Chatbeitrag.
* Siehe docs/09-performance-und-ux.md.
*
* Beim Speichern geht die geladene Version mit. Weicht sie ab, gibt es kein
* stilles Überschreiben, sondern beide Fassungen nebeneinander.
*/
export function SectionEditor(props: Props) {
const speicherSchluessel = `afis:abschnitt:${props.caseNumber}:${props.fieldKey}`
const [value, setValue] = useState(() => normalise(props.initialValue, props.type))
const [version, setVersion] = useState(props.initialVersion)
const [conflict, setConflict] = useState<{ value: unknown; version: number } | null>(null)
const [error, setError] = useState<string | null>(null)
const [saved, setSaved] = useState(false)
const [draftKept, setDraftKept] = useState(false)
const [pending, startTransition] = useTransition()
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
// Entwurf aus dem letzten Besuch übernehmen, falls er neuer ist.
useEffect(() => {
try {
const stored = localStorage.getItem(speicherSchluessel)
if (stored !== null && stored !== normalise(props.initialValue, props.type)) {
setValue(stored)
setDraftKept(true)
}
} catch {
// Privates Fenster oder gesperrter Speicher: dann eben ohne Entwurf.
}
}, [speicherSchluessel, props.initialValue, props.type])
function change(next: string) {
setValue(next)
setSaved(false)
if (timer.current) clearTimeout(timer.current)
timer.current = setTimeout(() => {
try {
localStorage.setItem(speicherSchluessel, next)
setDraftKept(true)
} catch {
// still ignorieren, der Entwurf ist eine Bequemlichkeit
}
}, AUTOSAVE_MS)
}
function submit(override?: unknown, baseVersion = version) {
setError(null)
startTransition(async () => {
const result = await saveSectionAction({
caseNumber: props.caseNumber,
fieldKey: props.fieldKey,
value: override ?? parse(value, props.type),
baseVersion,
})
if (result.ok) {
setVersion(result.version)
setConflict(null)
setSaved(true)
setDraftKept(false)
try {
localStorage.removeItem(speicherSchluessel)
} catch {
// egal
}
return
}
if (result.conflict) setConflict(result.current)
else setError(result.error)
})
}
if (props.readOnly) {
return (
<p className="mt-1 max-w-(--container-measure) text-sm whitespace-pre-wrap">
{String(value || '—')}
</p>
)
}
return (
<div className="mt-1.5">
{props.type === 'select' ? (
<select
value={value}
onChange={(event) => change(event.target.value)}
className="border border-line bg-raised px-3 py-2 text-sm"
>
<option value="">—</option>
{props.options?.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
) : props.type === 'longtext' ? (
<textarea
value={value}
onChange={(event) => change(event.target.value)}
rows={6}
className="block w-full max-w-(--container-measure) border border-line bg-raised px-3 py-2 text-sm"
/>
) : (
<input
type={props.type === 'date' ? 'date' : props.type === 'number' ? 'number' : 'text'}
value={value}
onChange={(event) => change(event.target.value)}
className="block w-full max-w-(--container-measure) border border-line bg-raised px-3 py-2 text-sm"
/>
)}
<div className="mt-2 flex flex-wrap items-center gap-3 text-2xs">
<button
type="button"
onClick={() => submit()}
disabled={pending}
className="border border-line bg-raised px-3 py-1 font-condensed text-accent disabled:text-text-dim"
>
{pending ? de.section.saving : de.section.save}
</button>
{version > 0 ? (
<span className="font-mono text-text-dim">{de.section.version(version)}</span>
) : null}
{saved ? <span className="font-condensed text-text-dim">{de.section.saved}</span> : null}
{draftKept && !saved ? (
<span className="font-condensed text-text-dim">{de.section.draftKept}</span>
) : null}
{error ? <span className="font-condensed text-stamp-4">{error}</span> : null}
</div>
{conflict ? (
<div className="mt-3 border border-stamp-4 p-3">
<p className="font-condensed text-2xs text-stamp-4">{de.section.conflict}</p>
<div className="mt-3 grid gap-4 md:grid-cols-2">
<div>
<p className="font-condensed text-2xs text-text-dim">{de.section.mine}</p>
<p className="mt-1 border-l-(length:--spacing-edge) border-steel pl-3 text-sm whitespace-pre-wrap">
{value}
</p>
<button
type="button"
onClick={() => submit(parse(value, props.type), conflict.version)}
className="mt-2 font-condensed text-2xs text-accent"
>
{de.section.takeMine}
</button>
</div>
<div>
<p className="font-condensed text-2xs text-text-dim">{de.section.theirs}</p>
<p className="mt-1 border-l-(length:--spacing-edge) border-line pl-3 text-sm whitespace-pre-wrap">
{String(conflict.value ?? '')}
</p>
<button
type="button"
onClick={() => {
setValue(normalise(conflict.value, props.type))
setVersion(conflict.version)
setConflict(null)
}}
className="mt-2 font-condensed text-2xs text-accent"
>
{de.section.takeTheirs}
</button>
</div>
</div>
</div>
) : null}
</div>
)
}
function normalise(value: unknown, _type: string): string {
if (value === null || value === undefined) return ''
if (Array.isArray(value)) return value.join(', ')
return String(value)
}
function parse(value: string, type: string): unknown {
if (type === 'number') return value === '' ? null : Number(value)
if (type === 'multiselect' || type === 'checklist') {
return value.split(',').map((part) => part.trim()).filter(Boolean)
}
return value === '' ? null : value
}
@@ -0,0 +1,114 @@
import { de } from '@/lib/i18n/de'
import { fillPlaceholders, isInputField } from '@/lib/templates/fields'
import { RedactedText } from '@/components/Redacted'
import { SectionEditor } from './SectionEditor'
import type { RenderedSection } from '@/lib/cases/sectionService'
/**
* Abschnitte einer Fallakte: der aktuelle Sachstand, aus der Vorlage erzeugt.
*
* 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.
*/
export function Sections({
caseNumber,
sections,
canEdit,
}: {
caseNumber: string
sections: RenderedSection[]
canEdit: boolean
}) {
if (sections.length === 0) {
return <p className="text-text-dim">{de.section.empty}</p>
}
const werte = Object.fromEntries(
sections.filter((s) => s.visible).map((s) => [s.fieldKey, s.value]),
)
return (
<dl className="space-y-8">
{sections.map((section) => {
const field = section.field
// Feste Bausteine der Vorlage.
if (!isInputField(field)) {
if (field.type === 'pagebreak') return <hr key={field.key} className="border-line" />
if (field.type === 'signature') {
return (
<div key={field.key} className="pt-8">
<div className="w-64 border-t border-line" />
<p className="mt-1 text-2xs text-text-dim">{field.label}</p>
</div>
)
}
const text = fillPlaceholders(field.text ?? '', werte)
return field.type === 'heading' ? (
<h3 key={field.key} className="font-condensed">
{text || field.label}
</h3>
) : (
<p key={field.key} className="max-w-(--container-measure) text-sm whitespace-pre-wrap">
{text}
</p>
)
}
return (
<div key={field.key}>
<dt className="flex flex-wrap items-baseline gap-x-3">
<span className="font-condensed text-xs text-text-dim">{field.label}</span>
{field.requiredToClose ? (
<span className="font-condensed text-2xs text-stamp-3">
{de.section.requiredToClose}
</span>
) : null}
</dt>
<dd>
{!section.visible ? (
// Serverseitig ersetzt: der echte Wert ist gar nicht erst hier.
<p className="mt-1.5 flex items-center gap-2">
<span className="redaction inline-block h-[1.1rem] w-40" aria-hidden />
<span className="font-mono text-2xs text-text-dim">
{de.section.lockedField(section.value.requiredLevel)}
</span>
</p>
) : section.segments ? (
<div className="mt-1.5">
<RedactedText segments={section.segments} />
{canEdit ? (
<SectionEditor
caseNumber={caseNumber}
fieldKey={field.key}
label={field.label}
type={field.type}
options={field.options}
requiredToClose={field.requiredToClose}
initialValue={section.value}
initialVersion={section.version}
readOnly={false}
/>
) : null}
</div>
) : (
<SectionEditor
caseNumber={caseNumber}
fieldKey={field.key}
label={field.label}
type={field.type}
options={field.options}
requiredToClose={field.requiredToClose}
initialValue={section.value}
initialVersion={section.version}
readOnly={!canEdit}
/>
)}
</dd>
</div>
)
})}
</dl>
)
}
@@ -6,6 +6,7 @@ import { requireUser } from '@/lib/auth/current'
import { loadVisibleCase } from '@/lib/cases/service' import { loadVisibleCase } from '@/lib/cases/service'
import { loadChangeHistory } from '@/lib/cases/entryService' import { loadChangeHistory } from '@/lib/cases/entryService'
import { de } from '@/lib/i18n/de' import { de } from '@/lib/i18n/de'
import { wordDiff } from '@/lib/diff'
export const metadata: Metadata = { title: de.entry.changesHeading } export const metadata: Metadata = { title: de.entry.changesHeading }
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -72,20 +73,23 @@ export default async function ChangesPage({
</p> </p>
) : null} ) : null}
<div className="mt-3 grid gap-4 md:grid-cols-2"> {/* Wortdiff statt zweier Spalten: bei langen Vermerken sieht man
<div> sonst nicht, was sich geändert hat. */}
<p className="font-condensed text-2xs text-text-dim">{de.entry.before}</p> <p className="mt-3 max-w-(--container-measure) text-sm whitespace-pre-wrap">
<p className="mt-1 border-l-(length:--spacing-edge) border-line pl-3 text-sm whitespace-pre-wrap"> {wordDiff(version.body, version.entry.body).map((teil, index) =>
{version.body} teil.kind === 'gleich' ? (
<span key={index}>{teil.text}</span>
) : teil.kind === 'entfernt' ? (
<del key={index} className="text-stamp-4 decoration-stamp-4">
{teil.text}
</del>
) : (
<ins key={index} className="text-stamp-2 no-underline">
{teil.text}
</ins>
),
)}
</p> </p>
</div>
<div>
<p className="font-condensed text-2xs text-text-dim">{de.entry.after}</p>
<p className="mt-1 border-l-(length:--spacing-edge) border-steel pl-3 text-sm whitespace-pre-wrap">
{version.entry.body}
</p>
</div>
</div>
</li> </li>
))} ))}
</ol> </ol>
+45 -1
View File
@@ -10,6 +10,9 @@ import { de } from '@/lib/i18n/de'
import { StampMark, classificationEdgeStyle } from '@/components/StampMark' import { StampMark, classificationEdgeStyle } from '@/components/StampMark'
import { SkeletonLine } from '@/components/Skeleton' import { SkeletonLine } from '@/components/Skeleton'
import { EntryComposer } from './EntryComposer' import { EntryComposer } from './EntryComposer'
import { Sections } from './Sections'
import { loadSections } from '@/lib/cases/sectionService'
import { canEditCase } from '@/lib/access/caseWrite'
import { CaseActions } from './CaseActions' import { CaseActions } from './CaseActions'
import { EntryActions } from './EntryActions' import { EntryActions } from './EntryActions'
import { allowedTargets, type CaseStatusName } from '@/lib/cases/lifecycle' import { allowedTargets, type CaseStatusName } from '@/lib/cases/lifecycle'
@@ -129,6 +132,23 @@ async function Timeline({
) )
} }
async function SectionBlock({
caseId,
caseNumber,
templateId,
permissions,
canEdit,
}: {
caseId: string
caseNumber: string
templateId: string
permissions: EffectivePermissions
canEdit: boolean
}) {
const sections = await loadSections(permissions, caseId, templateId)
return <Sections caseNumber={caseNumber} sections={sections} canEdit={canEdit} />
}
export default async function CasePage({ export default async function CasePage({
params, params,
}: { }: {
@@ -192,7 +212,31 @@ export default async function CasePage({
/> />
</header> </header>
<nav className="mt-8 flex gap-4 border-b border-line pb-2 font-condensed text-xs"> <section className="mt-10">
<h2 className="font-condensed">{de.section.heading}</h2>
<div className="mt-4">
<Suspense
fallback={
<div className="space-y-6">
<SkeletonLine width="10rem" />
<SkeletonLine />
<SkeletonLine width="12rem" />
<SkeletonLine />
</div>
}
>
<SectionBlock
caseId={akte.id}
caseNumber={akte.caseNumber}
templateId={akte.templateId}
permissions={user.permissions}
canEdit={canEditCase(user.permissions, akte).ok}
/>
</Suspense>
</div>
</section>
<nav className="mt-10 flex gap-4 border-b border-line pb-2 font-condensed text-xs">
<span className="text-text-dim">{de.case.timeline}</span> <span className="text-text-dim">{de.case.timeline}</span>
<Link href={`/faelle/${akte.caseNumber}/aenderungen`}>{de.case.changes}</Link> <Link href={`/faelle/${akte.caseNumber}/aenderungen`}>{de.case.changes}</Link>
</nav> </nav>
@@ -0,0 +1,47 @@
'use server'
import { revalidatePath } from 'next/cache'
import { z } from 'zod'
import { requireUser } from '@/lib/auth/current'
import { AccessError } from '@/lib/cases/service'
import { ConflictError, saveSection } from '@/lib/cases/sectionService'
import { de } from '@/lib/i18n/de'
const schema = z.object({
caseNumber: z.string().min(1),
fieldKey: z.string().min(1),
value: z.unknown(),
baseVersion: z.coerce.number().int().min(0),
minClearance: z.coerce.number().int().min(0).max(5).optional(),
})
export type SaveSectionResult =
| { ok: true; version: number }
/** Kein stilles Überschreiben: die fremde Fassung kommt mit zurück. */
| { ok: false; conflict: true; current: { value: unknown; version: number } }
| { ok: false; conflict: false; error: string }
export async function saveSectionAction(input: unknown): Promise<SaveSectionResult> {
const user = await requireUser()
const parsed = schema.safeParse(input)
if (!parsed.success) return { ok: false, conflict: false, error: de.case.errors.default! }
try {
const { version } = await saveSection(user.permissions, parsed.data)
revalidatePath(`/faelle/${parsed.data.caseNumber}`)
return { ok: true, version }
} catch (error) {
if (error instanceof ConflictError) {
return {
ok: false,
conflict: true,
current: { value: error.current.value, version: error.current.version },
}
}
if (error instanceof AccessError) {
return { ok: false, conflict: false, error: de.case.errors[error.code] ?? de.case.errors.default! }
}
console.error('[abschnitt] unerwartet', error)
return { ok: false, conflict: false, error: de.case.errors.default! }
}
}
+4
View File
@@ -25,6 +25,10 @@ export default async function AppLayout({ children }: { children: React.ReactNod
{user.permissions.isAdmin || user.permissions.permissions.has('roles.manage') ? ( {user.permissions.isAdmin || user.permissions.permissions.has('roles.manage') ? (
<> <>
<Link href="/admin/rollen">{de.nav.admin}</Link> <Link href="/admin/rollen">{de.nav.admin}</Link>
{user.permissions.isAdmin ||
user.permissions.permissions.has('template.manage') ? (
<Link href="/admin/vorlagen">{de.templateAdmin.heading}</Link>
) : null}
{user.permissions.isAdmin ? ( {user.permissions.isAdmin ? (
<Link href="/admin/richtlinie">{de.settings.heading}</Link> <Link href="/admin/richtlinie">{de.settings.heading}</Link>
) : null} ) : null}
+37
View File
@@ -0,0 +1,37 @@
import type { Segment } from '@/lib/redaction'
import { de } from '@/lib/i18n/de'
/**
* Rendert Fliesstext mit Schwärzungen.
*
* Die Segmente kommen fertig vom Server: der geschwärzte Inhalt ist gar nicht
* erst im Dokument. Der Balken bekommt seine Breite aus der gerundeten Länge,
* nicht aus dem echten Text.
*
* Der Wischeffekt beim ersten Erscheinen steckt in der Klasse `.redaction`
* (siehe app/globals.css) und respektiert prefers-reduced-motion.
*/
export function RedactedText({ segments }: { segments: Segment[] }) {
return (
<p className="max-w-(--container-measure) text-sm whitespace-pre-wrap">
{segments.map((segment, index) =>
segment.kind === 'text' ? (
<span key={index}>{segment.text}</span>
) : (
<span key={index} className="inline-flex items-baseline gap-1.5 align-baseline">
<span
className="redaction"
style={{ width: `${segment.blockLength}ch` }}
aria-label={de.section.redactedFrom(segment.requiredLevel)}
>
{' '}
</span>
<span className="font-mono text-2xs text-text-dim" aria-hidden>
{segment.requiredLevel}
</span>
</span>
),
)}
</p>
)
}
+224
View File
@@ -0,0 +1,224 @@
import { db } from '@/lib/db'
import { writeAudit } from '@/lib/audit'
import { canEditCase } from '@/lib/access/caseWrite'
import { AccessError, loadVisibleCase } from '@/lib/cases/service'
import { isInputField, type TemplateField } from '@/lib/templates/fields'
import { parseTemplateFields } from '@/lib/templates/schema'
import { redactForLevel, type Segment } from '@/lib/redaction'
import type { EffectivePermissions } from '@/lib/auth/permissions'
import type { Prisma } from '@afis/db'
/**
* Abschnitte einer Fallakte: der aktuelle Sachstand, aus der Vorlage erzeugt
* und je Feld versioniert. Siehe docs/05-akten-und-vorlagen.md.
*/
/** Was ein Leser sieht, dessen Stufe für ein Feld nicht reicht. */
export type Placeholder = { redacted: true; requiredLevel: number }
export type RenderedSection = {
fieldKey: string
field: TemplateField
version: number
updatedAt: Date | null
updatedById: string | null
} & (
| { visible: true; value: unknown; segments: Segment[] | null }
| { visible: false; value: Placeholder }
)
/**
* Prisma trennt „Feld nicht setzen" von „JSON null". Ein leerer Abschnitt ist
* inhaltlich JSON null, nicht das Fehlen des Feldes.
*/
function asJson(value: unknown): Prisma.InputJsonValue {
return (value ?? null) as Prisma.InputJsonValue
}
export function templateFields(raw: unknown): TemplateField[] {
return parseTemplateFields(raw)
}
/**
* Lädt die Abschnitte einer Akte und wendet dabei die Stufe des Lesers an.
*
* Felder über seiner Stufe werden **serverseitig** durch einen Platzhalter
* ersetzt. Der echte Wert verlässt den Server nicht — nie im Client
* ausblenden. Dasselbe gilt für Schwärzungen im Fliesstext, die als Segmente
* zurückkommen.
*/
export async function loadSections(
p: EffectivePermissions,
caseId: string,
templateId: string,
): Promise<RenderedSection[]> {
const [template, rows] = await Promise.all([
db.caseTemplate.findUnique({ where: { id: templateId }, select: { fields: true } }),
db.caseSection.findMany({ where: { caseId } }),
])
const fields = templateFields(template?.fields)
const byKey = new Map(rows.map((row) => [row.fieldKey, row]))
return fields.map((field): RenderedSection => {
const row = byKey.get(field.key)
const required = Math.max(field.minClearance ?? 0, row?.minClearance ?? 0)
const common = {
fieldKey: field.key,
field,
version: row?.currentVersion ?? 0,
updatedAt: row?.updatedAt ?? null,
updatedById: row?.updatedById ?? null,
}
if (!p.isAdmin && required > p.clearanceLevel) {
return { ...common, visible: false, value: { redacted: true, requiredLevel: required } }
}
const value = row?.value ?? null
const segments =
isInputField(field) && field.type === 'longtext' && typeof value === 'string'
? redactForLevel(value, p.clearanceLevel)
: null
return { ...common, visible: true, value, segments }
})
}
export class ConflictError extends Error {
constructor(
readonly current: { value: unknown; version: number; updatedById: string | null },
) {
super('conflict')
this.name = 'ConflictError'
}
}
/**
* Speichert einen Abschnitt.
*
* Der Client schickt die Version mit, die er geladen hat. Weicht sie ab, gibt
* es kein stilles Überschreiben, sondern einen Konflikt mit beiden Fassungen.
* Siehe docs/05-akten-und-vorlagen.md.
*/
export async function saveSection(
p: EffectivePermissions,
input: {
caseNumber: string
fieldKey: string
value: unknown
/** Version, die der Client geladen hat. 0 heisst: Feld war noch leer. */
baseVersion: number
minClearance?: number
},
): Promise<{ version: number }> {
const target = await loadVisibleCase(p, input.caseNumber)
if (!target) throw new AccessError('not-found')
const check = canEditCase(p, target)
if (!check.ok) throw new AccessError(check.reason)
const template = await db.caseTemplate.findUnique({
where: { id: target.templateId },
select: { fields: true },
})
const field = templateFields(template?.fields).find((f) => f.key === input.fieldKey)
if (!field || !isInputField(field)) throw new AccessError('field-unknown')
const existing = await db.caseSection.findUnique({
where: { caseId_fieldKey: { caseId: target.id, fieldKey: input.fieldKey } },
})
const required = Math.max(field.minClearance ?? 0, existing?.minClearance ?? 0)
if (!p.isAdmin && required > p.clearanceLevel) throw new AccessError('clearance-too-low')
const nextClearance = input.minClearance ?? existing?.minClearance ?? field.minClearance ?? 0
if (nextClearance > p.clearanceLevel && !p.isAdmin) throw new AccessError('clearance-too-low')
if ((existing?.currentVersion ?? 0) !== input.baseVersion) {
throw new ConflictError({
value: existing?.value ?? null,
version: existing?.currentVersion ?? 0,
updatedById: existing?.updatedById ?? null,
})
}
const nextVersion = (existing?.currentVersion ?? 0) + 1
await db.$transaction(async (tx) => {
if (existing) {
// Der vorherige Stand wandert in die Versionstabelle, bevor überschrieben
// wird. Die Historie ist damit lückenlos.
await tx.caseSectionVersion.create({
data: {
sectionId: existing.id,
version: existing.currentVersion,
value: asJson(existing.value),
authorId: existing.updatedById,
},
})
await tx.caseSection.update({
where: { id: existing.id },
data: {
value: asJson(input.value),
currentVersion: nextVersion,
updatedById: p.userId,
minClearance: nextClearance,
},
})
} else {
await tx.caseSection.create({
data: {
caseId: target.id,
fieldKey: input.fieldKey,
value: asJson(input.value),
currentVersion: 1,
updatedById: p.userId,
minClearance: nextClearance,
},
})
}
await writeAudit(tx, {
actorId: p.userId,
action: 'case.update',
targetType: 'CaseSection',
targetId: `${target.id}:${input.fieldKey}`,
clearanceAtTime: p.clearanceLevel,
meta: {
caseNumber: target.caseNumber,
fieldKey: input.fieldKey,
version: nextVersion,
minClearance: nextClearance,
},
})
})
return { version: nextVersion }
}
/** Die Versionen eines Abschnitts, für die Diff-Ansicht. */
export async function loadSectionHistory(
p: EffectivePermissions,
caseNumber: string,
fieldKey: string,
) {
const target = await loadVisibleCase(p, caseNumber)
if (!target) throw new AccessError('not-found')
const section = await db.caseSection.findUnique({
where: { caseId_fieldKey: { caseId: target.id, fieldKey } },
select: { id: true, minClearance: true, value: true, currentVersion: true, updatedById: true },
})
if (!section) return null
if (!p.isAdmin && section.minClearance > p.clearanceLevel) throw new AccessError('clearance-too-low')
const versions = await db.caseSectionVersion.findMany({
where: { sectionId: section.id },
orderBy: { version: 'desc' },
take: 50,
})
return { section, versions }
}
+65
View File
@@ -0,0 +1,65 @@
/**
* Wortdiff für die Fassungsvergleiche an Abschnitten und Verlaufseinträgen.
*
* Bewusst ohne Paket: eine längste gemeinsame Teilfolge über Wörter ist ein
* Dutzend Zeilen, und die Alternative wären 30 kB im Bündel für etwas, das
* ausschliesslich serverseitig läuft. Siehe docs/09-performance-und-ux.md.
*/
export type DiffPart = { kind: 'gleich' | 'entfernt' | 'neu'; text: string }
function tokenize(text: string): string[] {
// Wörter samt folgendem Leerraum, damit sich der Text verlustfrei
// zusammensetzen lässt.
return text.match(/\S+\s*|\s+/g) ?? []
}
export function wordDiff(before: string, after: string): DiffPart[] {
const a = tokenize(before)
const b = tokenize(after)
// Längste gemeinsame Teilfolge, klassisch über eine Tabelle.
const lengths: number[][] = Array.from({ length: a.length + 1 }, () =>
new Array<number>(b.length + 1).fill(0),
)
for (let i = a.length - 1; i >= 0; i--) {
for (let j = b.length - 1; j >= 0; j--) {
lengths[i]![j] = a[i] === b[j] ? lengths[i + 1]![j + 1]! + 1 : Math.max(lengths[i + 1]![j]!, lengths[i]![j + 1]!)
}
}
const parts: DiffPart[] = []
const push = (kind: DiffPart['kind'], text: string) => {
const last = parts.at(-1)
if (last && last.kind === kind) last.text += text
else parts.push({ kind, text })
}
let i = 0
let j = 0
while (i < a.length && j < b.length) {
if (a[i] === b[j]) {
push('gleich', a[i]!)
i++
j++
} else if (lengths[i + 1]![j]! >= lengths[i]![j + 1]!) {
push('entfernt', a[i]!)
i++
} else {
push('neu', b[j]!)
j++
}
}
while (i < a.length) push('entfernt', a[i++]!)
while (j < b.length) push('neu', b[j++]!)
return parts
}
/** Grobe Kennzahl für das Protokoll, ohne den Text zu doppeln. */
export function diffSummary(before: string, after: string): { entfernt: number; neu: number } {
const parts = wordDiff(before, after)
return {
entfernt: parts.filter((p) => p.kind === 'entfernt').reduce((n, p) => n + p.text.length, 0),
neu: parts.filter((p) => p.kind === 'neu').reduce((n, p) => n + p.text.length, 0),
}
}
+43
View File
@@ -178,6 +178,28 @@ export const de = {
} as Record<string, string>, } as Record<string, string>,
}, },
section: {
heading: 'Abschnitte',
empty: 'Die Vorlage dieser Fallart hat keine Abschnitte.',
save: 'Abschnitt speichern',
saving: 'Wird gespeichert',
saved: 'Gespeichert',
draftKept: 'Entwurf lokal gesichert',
version: (v: number) => `v${v}`,
history: 'Fassungen',
conflict:
'Nicht gespeichert, die Akte wurde zwischenzeitlich geändert. Fassungen vergleichen.',
mine: 'Meine Fassung',
theirs: 'Fremde Fassung',
takeMine: 'Meine übernehmen',
takeTheirs: 'Fremde übernehmen',
redacted: 'Geschwärzt',
redactedFrom: (n: number) => `Geschwärzt, ab Stufe ${n} sichtbar`,
lockedField: (n: number) => `Feld gesperrt, sichtbar ab Stufe ${n}`,
requiredToClose: 'Für den Abschluss nötig',
signatureLine: 'Unterschrift',
},
entry: { entry: {
one: 'Vermerk', one: 'Vermerk',
many: 'Vermerke', many: 'Vermerke',
@@ -243,6 +265,27 @@ export const de = {
conflict: 'Nicht gespeichert, die Akte wurde zwischenzeitlich geändert. Fassungen vergleichen.', conflict: 'Nicht gespeichert, die Akte wurde zwischenzeitlich geändert. Fassungen vergleichen.',
}, },
templateAdmin: {
heading: 'Vorlagen',
intro:
'Eine Vorlage bestimmt, aus welchen Bausteinen eine Fallart besteht. Speichern legt eine neue Version an; bestehende Akten behalten die Version, mit der sie angelegt wurden.',
empty: 'Noch keine Vorlage.',
version: (v: number) => `Version ${v}`,
fieldsLabel: 'Bausteine als JSON',
fieldsHelp:
'Liste von Bausteinen. Eingabefelder: shorttext, longtext, select, multiselect, date, person, number, checklist, table. Feste Bausteine: heading, paragraph, signature, pagebreak.',
numberPrefix: 'Präfix des Aktenzeichens',
numberPadding: 'Stellen der laufenden Nummer',
save: 'Als neue Version speichern',
saved: (v: number) => `Gespeichert als Version ${v}`,
preview: 'Vorschau',
previewIntro: 'So sieht eine neue Akte mit dieser Vorlage aus.',
invalid: 'Die Bausteine sind kein gültiges JSON.',
fieldsInvalid: 'Die Bausteine sind nicht gültig.',
inputField: 'Eingabe',
staticBlock: 'Fester Baustein',
},
settings: { settings: {
heading: 'Änderungsrichtlinie', heading: 'Änderungsrichtlinie',
intro: intro:
+44 -74
View File
@@ -1,15 +1,15 @@
import { z } from 'zod'
/** /**
* Bausteine einer Vorlage. * Bausteine einer Vorlage — Typen und reine Funktionen.
*
* **Ohne zod.** Dieses Modul wird auch im Client geladen (Vorlagen-Editor,
* Vorschau), und eine Validierungsbibliothek im Bündel kostet mehr als das
* Budget aus docs/09-performance-und-ux.md hergibt. Die verbindliche Prüfung
* steht in `schema.ts` und läuft ausschliesslich serverseitig — dort, wo sie
* ohnehin hingehört.
* *
* Eine Vorlage ist eine **geordnete Liste von Bausteinen**. Manche nehmen eine * Eine Vorlage ist eine **geordnete Liste von Bausteinen**. Manche nehmen eine
* Eingabe entgegen, andere liefern festen Text. Damit tragen dieselbe Struktur * Eingabe entgegen, andere liefern festen Text. Damit tragen dieselbe Struktur
* ein Vorfallsbericht (fast nur Eingaben) und ein Sonderarbeitsvertrag (fast * ein Vorfallsbericht und ein Sonderarbeitsvertrag.
* nur fester Text mit ein paar Platzhaltern).
*
* Eine neue Dokumentart ist damit ein Datensatz, kein Deployment.
* Siehe docs/05-akten-und-vorlagen.md und docs/13-entscheidungen.md.
*/ */
/** Bausteine, die der Nutzer ausfüllt. Ihr Wert landet in einem Abschnitt. */ /** Bausteine, die der Nutzer ausfüllt. Ihr Wert landet in einem Abschnitt. */
@@ -31,76 +31,28 @@ export const STATIC_TYPES = ['heading', 'paragraph', 'signature', 'pagebreak'] a
export type InputType = (typeof INPUT_TYPES)[number] export type InputType = (typeof INPUT_TYPES)[number]
export type StaticType = (typeof STATIC_TYPES)[number] export type StaticType = (typeof STATIC_TYPES)[number]
const key = z export type InputField = {
.string() key: string
.min(1) label: string
.max(64) type: InputType
// Der Schlüssel ist stabil und wird nie geändert: ein Abschnitt findet über help?: string
// ihn seinen Baustein wieder, auch über Vorlagenversionen hinweg. minClearance?: number
.regex(/^[a-z][a-z0-9_]*$/, 'Kleinbuchstaben, Ziffern und Unterstrich') required?: boolean
requiredToClose?: boolean
const gemeinsam = { options?: string[]
key, columns?: string[]
label: z.string().min(1).max(200),
help: z.string().max(500).optional(),
/** Feld erst ab dieser Stufe sichtbar, siehe docs/05-akten-und-vorlagen.md. */
minClearance: z.number().int().min(0).max(5).optional(),
} }
const inputField = z.object({ export type StaticBlock = {
...gemeinsam, key: string
type: z.enum(INPUT_TYPES), label: string
required: z.boolean().optional(), type: StaticType
/** Blockiert den Abschluss des Falls, solange leer. */ help?: string
requiredToClose: z.boolean().optional(), minClearance?: number
/** Für select und multiselect. */ text?: string
options: z.array(z.string().min(1)).optional(),
/** Für table: die Spaltenüberschriften. */
columns: z.array(z.string().min(1)).optional(),
})
const staticBlock = z.object({
...gemeinsam,
type: z.enum(STATIC_TYPES),
/**
* Fester Text des Bausteins. Platzhalter in geschweiften Klammern werden
* beim Rendern durch Werte ersetzt: `{{name}}` zieht den Wert des
* Bausteins mit dem Schlüssel `name`.
*/
text: z.string().max(20_000).optional(),
})
export const templateFieldSchema = z.union([inputField, staticBlock])
export const templateFieldsSchema = z
.array(templateFieldSchema)
.max(200)
.superRefine((fields, ctx) => {
const seen = new Set<string>()
for (const [index, field] of fields.entries()) {
if (seen.has(field.key)) {
ctx.addIssue({
code: 'custom',
path: [index, 'key'],
message: `Schlüssel doppelt vergeben: ${field.key}`,
})
} }
seen.add(field.key)
if ( export type TemplateField = InputField | StaticBlock
(field.type === 'select' || field.type === 'multiselect') &&
(!('options' in field) || !field.options?.length)
) {
ctx.addIssue({
code: 'custom',
path: [index, 'options'],
message: 'Auswahlfelder brauchen mindestens eine Option',
})
}
}
})
export type TemplateField = z.infer<typeof templateFieldSchema>
export type InputField = z.infer<typeof inputField>
export function isInputField(field: TemplateField): field is InputField { export function isInputField(field: TemplateField): field is InputField {
return (INPUT_TYPES as readonly string[]).includes(field.type) return (INPUT_TYPES as readonly string[]).includes(field.type)
@@ -127,3 +79,21 @@ export function requiredToCloseKeys(fields: TemplateField[]): string[] {
.filter((field): field is InputField => isInputField(field) && field.requiredToClose === true) .filter((field): field is InputField => isInputField(field) && field.requiredToClose === true)
.map((field) => field.key) .map((field) => field.key)
} }
/**
* Grobe Formprüfung für die Vorschau im Editor. Sagt, ob sich die Eingabe
* überhaupt darstellen lässt — nicht, ob sie gespeichert werden darf. Das
* entscheidet allein der Server.
*/
export function looksLikeFields(value: unknown): value is TemplateField[] {
if (!Array.isArray(value)) return false
const known = new Set<string>([...INPUT_TYPES, ...STATIC_TYPES])
return value.every(
(item) =>
typeof item === 'object' &&
item !== null &&
typeof (item as TemplateField).key === 'string' &&
typeof (item as TemplateField).label === 'string' &&
known.has((item as TemplateField).type),
)
}
+73
View File
@@ -0,0 +1,73 @@
import { z } from 'zod'
import { INPUT_TYPES, STATIC_TYPES, type TemplateField } from '@/lib/templates/fields'
/**
* Verbindliche Prüfung einer Vorlage. Läuft ausschliesslich serverseitig:
* eine Server Action ist ein Endpunkt, und was hier durchgeht, geht auch durch,
* wenn jemand den Editor umgeht.
*/
const key = z
.string()
.min(1)
.max(64)
// Der Schlüssel ist stabil und wird nie geändert: ein Abschnitt findet über
// ihn seinen Baustein wieder, auch über Vorlagenversionen hinweg.
.regex(/^[a-z][a-z0-9_]*$/, 'Kleinbuchstaben, Ziffern und Unterstrich')
const gemeinsam = {
key,
label: z.string().min(1).max(200),
help: z.string().max(500).optional(),
/** Feld erst ab dieser Stufe sichtbar, siehe docs/05-akten-und-vorlagen.md. */
minClearance: z.number().int().min(0).max(5).optional(),
}
const inputField = z.object({
...gemeinsam,
type: z.enum(INPUT_TYPES),
required: z.boolean().optional(),
requiredToClose: z.boolean().optional(),
options: z.array(z.string().min(1)).optional(),
columns: z.array(z.string().min(1)).optional(),
})
const staticBlock = z.object({
...gemeinsam,
type: z.enum(STATIC_TYPES),
text: z.string().max(20_000).optional(),
})
export const templateFieldSchema = z.union([inputField, staticBlock])
export const templateFieldsSchema = z
.array(templateFieldSchema)
.max(200)
.superRefine((fields, ctx) => {
const seen = new Set<string>()
for (const [index, field] of fields.entries()) {
if (seen.has(field.key)) {
ctx.addIssue({
code: 'custom',
path: [index, 'key'],
message: `Schlüssel doppelt vergeben: ${field.key}`,
})
}
seen.add(field.key)
if (
(field.type === 'select' || field.type === 'multiselect') &&
(!('options' in field) || !field.options?.length)
) {
ctx.addIssue({
code: 'custom',
path: [index, 'options'],
message: 'Auswahlfelder brauchen mindestens eine Option',
})
}
}
})
export function parseTemplateFields(raw: unknown): TemplateField[] {
const parsed = templateFieldsSchema.safeParse(raw)
return parsed.success ? (parsed.data as TemplateField[]) : []
}
+171
View File
@@ -0,0 +1,171 @@
// @vitest-environment node
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { createPrismaClient, type PrismaClient } from '@afis/db'
import type { EffectivePermissions, Permission } from '@/lib/auth/permissions'
const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL
const suite = url ? describe : describe.skip
let db: PrismaClient
const marke = `abschnitt-${Date.now()}`
const NUTZER = `${marke}-nutzer`
const ZWEITER = `${marke}-zweiter`
function perms(level: number, userId = NUTZER): EffectivePermissions {
const permissions: Permission[] = ['case.create', 'case.edit', 'entry.create']
return { userId, clearanceLevel: level, permissions: new Set(permissions), isAdmin: false, hasMembership: true }
}
const FELDER = [
{ key: 'sachverhalt', label: 'Sachverhalt', type: 'longtext' },
{ key: 'bewertung', label: 'Bewertung', type: 'longtext', requiredToClose: true },
{ key: 'zeugenname', label: 'Zeuge', type: 'shorttext', minClearance: 4 },
{ key: 'hinweis', label: 'Hinweis', type: 'paragraph', text: 'Fester Text mit {{sachverhalt}}' },
]
let caseNumber = ''
let caseId = ''
let templateId = ''
suite('Abschnitte', () => {
beforeAll(async () => {
db = createPrismaClient(url)
const vorlage = await db.caseTemplate.create({
data: { key: `${marke}-vorlage`, version: 1, name: 'Prüfvorlage', fields: FELDER },
})
templateId = vorlage.id
for (const id of [NUTZER, ZWEITER]) {
await db.user.create({ data: { id, username: id, displayName: id } })
}
const { createCase } = await import('@/lib/cases/service')
const akte = await createCase(perms(5), {
title: 'Abschnittsprüfung',
classification: 1,
templateKey: `${marke}-vorlage`,
})
caseNumber = akte.caseNumber
caseId = akte.id
})
afterAll(async () => {
await db.$disconnect()
})
it('legt beim ersten Speichern Version 1 an', async () => {
const { saveSection } = await import('@/lib/cases/sectionService')
const ergebnis = await saveSection(perms(5), {
caseNumber,
fieldKey: 'sachverhalt',
value: 'Erster Stand',
baseVersion: 0,
})
expect(ergebnis.version).toBe(1)
})
it('erkennt einen Konflikt, statt still zu überschreiben', async () => {
const { saveSection, ConflictError } = await import('@/lib/cases/sectionService')
// Zwei laden dieselbe Fassung.
await saveSection(perms(5), {
caseNumber,
fieldKey: 'bewertung',
value: 'Fassung A',
baseVersion: 0,
})
// Einer speichert, der andere hat noch die alte Version im Formular.
await saveSection(perms(5), {
caseNumber,
fieldKey: 'bewertung',
value: 'Fassung B',
baseVersion: 1,
})
await expect(
saveSection(perms(5, ZWEITER), {
caseNumber,
fieldKey: 'bewertung',
value: 'Fassung C',
baseVersion: 1,
}),
).rejects.toBeInstanceOf(ConflictError)
// Der Konflikt bringt die fremde Fassung mit, damit die Oberfläche beide
// nebeneinander zeigen kann.
try {
await saveSection(perms(5, ZWEITER), {
caseNumber,
fieldKey: 'bewertung',
value: 'Fassung C',
baseVersion: 1,
})
} catch (error) {
expect((error as InstanceType<typeof ConflictError>).current).toMatchObject({
value: 'Fassung B',
version: 2,
})
}
})
it('schreibt jede Fassung in die Versionstabelle', async () => {
const section = await db.caseSection.findUniqueOrThrow({
where: { caseId_fieldKey: { caseId, fieldKey: 'bewertung' } },
})
const versionen = await db.caseSectionVersion.findMany({
where: { sectionId: section.id },
orderBy: { version: 'asc' },
})
expect(versionen.map((v) => v.value)).toEqual(['Fassung A'])
expect(section.currentVersion).toBe(2)
})
it('ersetzt ein Feld über der Stufe des Lesers serverseitig', async () => {
const { saveSection, loadSections } = await import('@/lib/cases/sectionService')
await saveSection(perms(5), {
caseNumber,
fieldKey: 'zeugenname',
value: 'Wachmann Perreira',
baseVersion: 0,
})
const fuerNiedrig = await loadSections(perms(2), caseId, templateId)
const feld = fuerNiedrig.find((s) => s.fieldKey === 'zeugenname')
expect(feld?.visible).toBe(false)
// Der echte Wert verlässt den Server nicht.
expect(JSON.stringify(fuerNiedrig)).not.toContain('Perreira')
expect(feld?.value).toMatchObject({ redacted: true, requiredLevel: 4 })
const fuerHoch = await loadSections(perms(4), caseId, templateId)
expect(fuerHoch.find((s) => s.fieldKey === 'zeugenname')?.value).toBe('Wachmann Perreira')
})
it('lässt niemanden ein Feld über die eigene Stufe heben', async () => {
const { saveSection } = await import('@/lib/cases/sectionService')
const { AccessError } = await import('@/lib/cases/service')
await expect(
saveSection(perms(2), {
caseNumber,
fieldKey: 'sachverhalt',
value: 'Heimlich hochstufen',
baseVersion: 1,
minClearance: 5,
}),
).rejects.toBeInstanceOf(AccessError)
})
it('gibt Fliesstext als Segmente zurück, mit angewandter Schwärzung', async () => {
const { saveSection, loadSections } = await import('@/lib/cases/sectionService')
await saveSection(perms(5), {
caseNumber,
fieldKey: 'sachverhalt',
value: 'Offen [[c4]]geheim[[/c4]] Ende',
baseVersion: 1,
})
const niedrig = await loadSections(perms(1), caseId, templateId)
const feld = niedrig.find((s) => s.fieldKey === 'sachverhalt')
if (!feld || feld.visible !== true) throw new Error('Abschnitt sollte sichtbar sein')
expect(JSON.stringify(feld.segments)).not.toContain('geheim')
expect(JSON.stringify(feld.segments)).toContain('Offen')
})
})
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import { diffSummary, wordDiff } from '@/lib/diff'
describe('Wortdiff', () => {
it('setzt den ursprünglichen Text verlustfrei wieder zusammen', () => {
const vorher = 'Der Zeuge sagte am Montag aus.'
const nachher = 'Der Zeuge sagte am Dienstag erneut aus.'
const teile = wordDiff(vorher, nachher)
const rekonstruiertVorher = teile
.filter((t) => t.kind !== 'neu')
.map((t) => t.text)
.join('')
const rekonstruiertNachher = teile
.filter((t) => t.kind !== 'entfernt')
.map((t) => t.text)
.join('')
expect(rekonstruiertVorher).toBe(vorher)
expect(rekonstruiertNachher).toBe(nachher)
})
it('markiert nur die geänderten Wörter', () => {
const teile = wordDiff('a b c', 'a x c')
expect(teile.find((t) => t.kind === 'entfernt')?.text.trim()).toBe('b')
expect(teile.find((t) => t.kind === 'neu')?.text.trim()).toBe('x')
})
it('kommt mit leeren Fassungen zurecht', () => {
expect(wordDiff('', 'neu').every((t) => t.kind === 'neu')).toBe(true)
expect(wordDiff('weg', '').every((t) => t.kind === 'entfernt')).toBe(true)
expect(wordDiff('', '')).toEqual([])
})
it('meldet bei unverändertem Text keine Änderung', () => {
expect(diffSummary('gleich', 'gleich')).toEqual({ entfernt: 0, neu: 0 })
})
})