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:
@@ -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 }
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -178,6 +178,28 @@ export const de = {
|
||||
} 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: {
|
||||
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:
|
||||
|
||||
+45
-75
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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[]) : []
|
||||
}
|
||||
Reference in New Issue
Block a user