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.
This commit is contained in:
Harlekin
2026-09-02 12:52:53 +02:00
parent 30c3d28726
commit d2174f472b
18 changed files with 1498 additions and 93 deletions
+45 -75
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
* 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<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 type TemplateField = z.infer<typeof templateFieldSchema>
export type InputField = z.infer<typeof inputField>
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<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[]) : []
}