diff --git a/packages/db/prisma/seed.ts b/packages/db/prisma/seed.ts
index d954f10..ec2ca52 100644
--- a/packages/db/prisma/seed.ts
+++ b/packages/db/prisma/seed.ts
@@ -66,9 +66,39 @@ const TEMPLATES: {
]
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: 'beteiligte', label: 'Beteiligte Personen', type: 'longtext' },
{ key: 'feststellungen', label: 'Feststellungen', type: 'longtext' },
+ { key: 'anmerkungen', label: 'Anmerkungen', type: 'longtext' },
+ { key: 'massnahmen', label: 'Maßnahmen', type: 'longtext' },
{
key: 'bewertung',
label: 'Abschließende Bewertung',
@@ -110,9 +140,14 @@ async function main(): Promise {
fields: TEMPLATE_FIELDS,
isActive: true,
},
- // Muster und Felder bleiben unangetastet: was der Leiter im
- // Vorlagen-Editor eingestellt hat, überschreibt der Seed nicht.
- update: { name: template.name, description: template.description },
+ // Version 1 gehört dem Seed und wird von ihm aktuell gehalten. Ändert
+ // der Leiter eine Vorlage im Editor, entsteht Version 2 — die fasst der
+ // 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`)
diff --git a/web/app/(app)/admin/vorlagen/TemplateEditor.tsx b/web/app/(app)/admin/vorlagen/TemplateEditor.tsx
new file mode 100644
index 0000000..1eb7a6c
--- /dev/null
+++ b/web/app/(app)/admin/vorlagen/TemplateEditor.tsx
@@ -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(null)
+ const [error, setError] = useState(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 (
+
+
+
{vorlage.name}
+ {vorlage.key}
+
+ {de.templateAdmin.version(vorlage.version)}
+
+
+
+
+
+
+
+
{de.templateAdmin.preview}
+
{de.templateAdmin.previewIntro}
+
+ {geparst.ok ? : null}
+
+
+
+
+ )
+}
+
+/** „So sieht eine neue Akte damit aus", ohne etwas zu speichern. */
+function Vorschau({ fields }: { fields: TemplateField[] }) {
+ if (fields.length === 0) return {de.section.empty}
+
+ return (
+
+ {fields.map((field) => (
+
+
+ {field.label}
+ {field.type}
+ {field.minClearance ? (
+ ≥ {field.minClearance}
+ ) : null}
+ {isInputField(field) && field.requiredToClose ? (
+
+ {de.section.requiredToClose}
+
+ ) : null}
+
+
+ {!isInputField(field) ? (
+
+ {field.text ? field.text.slice(0, 120) : de.templateAdmin.staticBlock}
+
+ ) : field.type === 'longtext' ? (
+
+ ) : field.type === 'select' || field.type === 'multiselect' ? (
+ {(field.options ?? []).join(' · ')}
+ ) : (
+
+ )}
+
+
+ ))}
+
+ )
+}
diff --git a/web/app/(app)/admin/vorlagen/actions.ts b/web/app/(app)/admin/vorlagen/actions.ts
new file mode 100644
index 0000000..01b2fa9
--- /dev/null
+++ b/web/app/(app)/admin/vorlagen/actions.ts
@@ -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 {
+ 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 }
+}
diff --git a/web/app/(app)/admin/vorlagen/page.tsx b/web/app/(app)/admin/vorlagen/page.tsx
new file mode 100644
index 0000000..032c5c9
--- /dev/null
+++ b/web/app/(app)/admin/vorlagen/page.tsx
@@ -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 (
+
+ {de.templateAdmin.heading}
+ {de.templateAdmin.intro}
+
+ {vorlagen.length === 0 ? (
+ {de.templateAdmin.empty}
+ ) : (
+
+ {vorlagen.map((vorlage) => (
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/web/app/(app)/faelle/[caseNumber]/SectionEditor.tsx b/web/app/(app)/faelle/[caseNumber]/SectionEditor.tsx
new file mode 100644
index 0000000..aa2acc1
--- /dev/null
+++ b/web/app/(app)/faelle/[caseNumber]/SectionEditor.tsx
@@ -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(null)
+ const [saved, setSaved] = useState(false)
+ const [draftKept, setDraftKept] = useState(false)
+ const [pending, startTransition] = useTransition()
+ const timer = useRef | 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 (
+
+ {String(value || '—')}
+
+ )
+ }
+
+ return (
+
+ {props.type === 'select' ? (
+
change(event.target.value)}
+ className="border border-line bg-raised px-3 py-2 text-sm"
+ >
+ —
+ {props.options?.map((option) => (
+
+ {option}
+
+ ))}
+
+ ) : props.type === 'longtext' ? (
+
+ )
+}
+
+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
+}
diff --git a/web/app/(app)/faelle/[caseNumber]/Sections.tsx b/web/app/(app)/faelle/[caseNumber]/Sections.tsx
new file mode 100644
index 0000000..1e74922
--- /dev/null
+++ b/web/app/(app)/faelle/[caseNumber]/Sections.tsx
@@ -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 {de.section.empty}
+ }
+
+ const werte = Object.fromEntries(
+ sections.filter((s) => s.visible).map((s) => [s.fieldKey, s.value]),
+ )
+
+ return (
+
+ {sections.map((section) => {
+ const field = section.field
+
+ // Feste Bausteine der Vorlage.
+ if (!isInputField(field)) {
+ if (field.type === 'pagebreak') return
+ if (field.type === 'signature') {
+ return (
+
+ )
+ }
+ const text = fillPlaceholders(field.text ?? '', werte)
+ return field.type === 'heading' ? (
+
+ {text || field.label}
+
+ ) : (
+
+ {text}
+
+ )
+ }
+
+ return (
+
+
+ {field.label}
+ {field.requiredToClose ? (
+
+ {de.section.requiredToClose}
+
+ ) : null}
+
+
+ {!section.visible ? (
+ // Serverseitig ersetzt: der echte Wert ist gar nicht erst hier.
+
+
+
+ {de.section.lockedField(section.value.requiredLevel)}
+
+
+ ) : section.segments ? (
+
+
+ {canEdit ? (
+
+ ) : null}
+
+ ) : (
+
+ )}
+
+
+ )
+ })}
+
+ )
+}
diff --git a/web/app/(app)/faelle/[caseNumber]/aenderungen/page.tsx b/web/app/(app)/faelle/[caseNumber]/aenderungen/page.tsx
index 4ef79e6..deab90c 100644
--- a/web/app/(app)/faelle/[caseNumber]/aenderungen/page.tsx
+++ b/web/app/(app)/faelle/[caseNumber]/aenderungen/page.tsx
@@ -6,6 +6,7 @@ import { requireUser } from '@/lib/auth/current'
import { loadVisibleCase } from '@/lib/cases/service'
import { loadChangeHistory } from '@/lib/cases/entryService'
import { de } from '@/lib/i18n/de'
+import { wordDiff } from '@/lib/diff'
export const metadata: Metadata = { title: de.entry.changesHeading }
export const dynamic = 'force-dynamic'
@@ -72,20 +73,23 @@ export default async function ChangesPage({
) : null}
-
-
-
{de.entry.before}
-
- {version.body}
-
-
-
-
{de.entry.after}
-
- {version.entry.body}
-
-
-
+ {/* Wortdiff statt zweier Spalten: bei langen Vermerken sieht man
+ sonst nicht, was sich geändert hat. */}
+
+ {wordDiff(version.body, version.entry.body).map((teil, index) =>
+ teil.kind === 'gleich' ? (
+ {teil.text}
+ ) : teil.kind === 'entfernt' ? (
+
+ {teil.text}
+
+ ) : (
+
+ {teil.text}
+
+ ),
+ )}
+
))}
diff --git a/web/app/(app)/faelle/[caseNumber]/page.tsx b/web/app/(app)/faelle/[caseNumber]/page.tsx
index 07253c3..56a0f8b 100644
--- a/web/app/(app)/faelle/[caseNumber]/page.tsx
+++ b/web/app/(app)/faelle/[caseNumber]/page.tsx
@@ -10,6 +10,9 @@ import { de } from '@/lib/i18n/de'
import { StampMark, classificationEdgeStyle } from '@/components/StampMark'
import { SkeletonLine } from '@/components/Skeleton'
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 { EntryActions } from './EntryActions'
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
+}
+
export default async function CasePage({
params,
}: {
@@ -192,7 +212,31 @@ export default async function CasePage({
/>
-
+
+ {de.section.heading}
+
+
+
+
+
+
+
+ }
+ >
+
+
+
+
+
+
{de.case.timeline}
{de.case.changes}
diff --git a/web/app/(app)/faelle/[caseNumber]/sectionActions.ts b/web/app/(app)/faelle/[caseNumber]/sectionActions.ts
new file mode 100644
index 0000000..2f73202
--- /dev/null
+++ b/web/app/(app)/faelle/[caseNumber]/sectionActions.ts
@@ -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 {
+ 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! }
+ }
+}
diff --git a/web/app/(app)/layout.tsx b/web/app/(app)/layout.tsx
index 11f1134..2f1e305 100644
--- a/web/app/(app)/layout.tsx
+++ b/web/app/(app)/layout.tsx
@@ -25,6 +25,10 @@ export default async function AppLayout({ children }: { children: React.ReactNod
{user.permissions.isAdmin || user.permissions.permissions.has('roles.manage') ? (
<>
{de.nav.admin}
+ {user.permissions.isAdmin ||
+ user.permissions.permissions.has('template.manage') ? (
+ {de.templateAdmin.heading}
+ ) : null}
{user.permissions.isAdmin ? (
{de.settings.heading}
) : null}
diff --git a/web/components/Redacted.tsx b/web/components/Redacted.tsx
new file mode 100644
index 0000000..dcad240
--- /dev/null
+++ b/web/components/Redacted.tsx
@@ -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 (
+
+ {segments.map((segment, index) =>
+ segment.kind === 'text' ? (
+ {segment.text}
+ ) : (
+
+
+ {' '}
+
+
+ {segment.requiredLevel}
+
+
+ ),
+ )}
+
+ )
+}
diff --git a/web/lib/cases/sectionService.ts b/web/lib/cases/sectionService.ts
new file mode 100644
index 0000000..aa2875d
--- /dev/null
+++ b/web/lib/cases/sectionService.ts
@@ -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 {
+ 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 }
+}
diff --git a/web/lib/diff.ts b/web/lib/diff.ts
new file mode 100644
index 0000000..a263c34
--- /dev/null
+++ b/web/lib/diff.ts
@@ -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(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),
+ }
+}
diff --git a/web/lib/i18n/de.ts b/web/lib/i18n/de.ts
index aef2aaa..15db28c 100644
--- a/web/lib/i18n/de.ts
+++ b/web/lib/i18n/de.ts
@@ -178,6 +178,28 @@ export const de = {
} as Record,
},
+ 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: {
one: 'Vermerk',
many: 'Vermerke',
@@ -243,6 +265,27 @@ export const de = {
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: {
heading: 'Änderungsrichtlinie',
intro:
diff --git a/web/lib/templates/fields.ts b/web/lib/templates/fields.ts
index d3ba2b0..077cafb 100644
--- a/web/lib/templates/fields.ts
+++ b/web/lib/templates/fields.ts
@@ -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
* Eingabe entgegen, andere liefern festen Text. Damit tragen dieselbe Struktur
- * ein Vorfallsbericht (fast nur Eingaben) und ein Sonderarbeitsvertrag (fast
- * 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.
+ * ein Vorfallsbericht und ein Sonderarbeitsvertrag.
*/
/** 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 StaticType = (typeof STATIC_TYPES)[number]
-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(),
+export type InputField = {
+ key: string
+ label: string
+ type: InputType
+ help?: string
+ minClearance?: number
+ required?: boolean
+ requiredToClose?: boolean
+ options?: string[]
+ columns?: string[]
}
-const inputField = z.object({
- ...gemeinsam,
- type: z.enum(INPUT_TYPES),
- required: z.boolean().optional(),
- /** Blockiert den Abschluss des Falls, solange leer. */
- requiredToClose: z.boolean().optional(),
- /** Für select und multiselect. */
- options: z.array(z.string().min(1)).optional(),
- /** Für table: die Spaltenüberschriften. */
- columns: z.array(z.string().min(1)).optional(),
-})
+export type StaticBlock = {
+ key: string
+ label: string
+ type: StaticType
+ help?: string
+ minClearance?: number
+ text?: string
+}
-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()
- 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 type TemplateField = z.infer
-export type InputField = z.infer
+export type TemplateField = InputField | StaticBlock
export function isInputField(field: TemplateField): field is InputField {
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)
.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([...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),
+ )
+}
diff --git a/web/lib/templates/schema.ts b/web/lib/templates/schema.ts
new file mode 100644
index 0000000..7b5f097
--- /dev/null
+++ b/web/lib/templates/schema.ts
@@ -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()
+ 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[]) : []
+}
diff --git a/web/tests/abschnitte.test.ts b/web/tests/abschnitte.test.ts
new file mode 100644
index 0000000..96cf28d
--- /dev/null
+++ b/web/tests/abschnitte.test.ts
@@ -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).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')
+ })
+})
diff --git a/web/tests/diff.test.ts b/web/tests/diff.test.ts
new file mode 100644
index 0000000..fd7c088
--- /dev/null
+++ b/web/tests/diff.test.ts
@@ -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 })
+ })
+})