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