Phase 3, erster Teil: Vorlagen-Bausteine und Schwärzung

Entschieden und festgehalten: jede Akte trägt Kurzvermerke und Dokumente, die
Fallakte zusätzlich ihre Abschnitte. Die Trennung aus docs/05 bleibt damit
bestehen.

Eine Vorlage ist jetzt eine geordnete Liste von Bausteinen. Manche nehmen eine
Eingabe entgegen, andere liefern festen Text mit Platzhaltern. Damit tragen
dieselbe Struktur ein Vorfallsbericht, der fast nur aus Eingaben besteht, und
ein Sonderarbeitsvertrag, der fast nur aus Paragraphen und Unterschriftenzeilen
besteht. Eine neue Dokumentart bleibt ein Datensatz, kein Deployment.

Der Schwärzungs-Parser gibt Segmente zurück, nicht den vollen Text mit einem
Sichtbarkeits-Flag: was der Client ausblenden müsste, hat er dann schon. Die
Balkenlänge wird auf ein Raster von acht Zeichen gerundet, sonst verrät sie,
wie lang der geschwärzte Name war.

Ein Test vergleicht den TypeScript-Parser gegen die SQL-Funktion
strip_redactions aus der Migration. Laufen die beiden auseinander, findet die
Suche einen Begriff, den die Ansicht verbirgt — und der Treffer allein verrät
ihn dann.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Harlekin
2026-09-02 12:39:35 +02:00
co-authored by Claude Opus 5
parent 5cbdabf337
commit d160741237
6 changed files with 410 additions and 7 deletions
+5 -6
View File
@@ -61,13 +61,12 @@ Das ist der schwierigste Teil. Wenn er steht, ist der Rest Formularbau.
## Phase 3 — Vorlagen und Abschnitte
- [ ] **Zuerst entscheiden**: gemeinsame Dokumentebene für Dokumentarten jenseits der
Fallakte (Bußgeld, Vertrag, Beurteilung). Bewertung und Empfehlung in
`docs/13-entscheidungen.md`, Abschnitt „Offen: Dokumentarten über die Fallakte hinaus"
- [ ] Feldtypen für Textkörper-Vorlagen ergänzen: fester Fließtext, Überschrift,
- [x] Entschieden: jede Akte trägt Kurzvermerke und Dokumente, die Fallakte zusätzlich
Abschnitte. Siehe `docs/13-entscheidungen.md`
- [x] Feldtypen für Textkörper-Vorlagen ergänzen: fester Fließtext, Überschrift,
Platzhalter, Unterschriftenzeile — der Sonderarbeitsvertrag ist kein Formular
- [ ] `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
- [ ] Feld „Foundation-Freigabe" als gewöhnliches Vorlagenfeld, ohne Wirkung auf den Zugriff
- [ ] Vorlagen-Editor im Adminbereich, versioniert, mit Vorschau
@@ -75,7 +74,7 @@ Das ist der schwierigste Teil. Wenn er steht, ist der Rest Formularbau.
- [ ] Versionierung mit Diff-Ansicht
- [ ] Konflikterkennung über mitgesendete Version, 409 mit Vergleichsansicht
- [ ] `minClearance` pro Feld, serverseitige Ersetzung durch Platzhalter
- [ ] 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
## Phase 4 — Personalakten und Verknüpfungen
+17 -1
View File
@@ -228,7 +228,23 @@ Die Feldtypen aus `docs/05-akten-und-vorlagen.md` decken den **Bericht vollstän
Unterschriftenzeilen sind keine Formularfelder. Dafür braucht die Vorlage einen Textkörper
mit Platzhaltern statt einer Feldliste.
### Empfehlung
### Entschieden
**Jede Akte ist ein Behälter, der Inhalt sind Beiträge: Kurzvermerk oder Dokument.** Das gilt
für Fallakten, Personalakten und alles, was später dazukommt.
Die dritte Ebene bleibt: eine Fallakte behält zusätzlich ihre **Abschnitte**, also
Vorlagenfelder direkt an der Akte, die „was wissen wir jetzt" beantworten. Die Trennung aus
`docs/05-akten-und-vorlagen.md` gilt damit weiter. Eine Fallakte hat also:
| Ebene | Was |
|---|---|
| Kopfdaten | Aktenzeichen, Titel, Status, Freigabestufe, Wiedervorlage |
| Abschnitte | aktueller Sachstand aus der Vorlage der Fallart |
| Verlauf | Kurzvermerke, chronologisch |
| Dokumente | aus einer Vorlage erzeugt, versioniert, einzeln exportierbar |
### Empfehlung für den Aufbau
Eine gemeinsame Dokumentebene, additiv statt als Umbau:
+113
View File
@@ -0,0 +1,113 @@
/**
* Schwärzung im Freitext.
*
* Im Editor markiert man einen Bereich und wählt eine Stufe. Gespeichert wird
* als Auszeichnung:
*
* Der Hinweis kam von [[c4]]Wachmann Perreira, Sektor B[[/c4]] am 27.08.
*
* Der Server ersetzt den Inhalt für niedrigere Stufen. **Der echte Wert
* verlässt den Server nicht** — deshalb gibt diese Datei Segmente zurück und
* nicht etwa den vollen Text mit einem Sichtbarkeits-Flag. Was der Client
* ausblenden müsste, hat er schon.
*
* Die Länge wird auf ein festes Raster gerundet. Ein Balken exakter Länge
* verriete sonst, wie lang der Name war, und bei kurzen Einschüben ist das
* genug, um zu raten. Siehe docs/05-akten-und-vorlagen.md.
*/
/** Auf dieses Vielfache wird die Länge eines Balkens gerundet. */
const LENGTH_GRID = 8
const MARKUP = /\[\[c([0-5])\]\]([\s\S]*?)\[\[\/c\1\]\]/g
export type Segment =
| { kind: 'text'; text: string }
| { kind: 'redacted'; requiredLevel: number; blockLength: number }
/** Auf das Raster gerundete Länge, mindestens ein Block. */
export function barLength(rawLength: number): number {
return Math.max(LENGTH_GRID, Math.ceil(rawLength / LENGTH_GRID) * LENGTH_GRID)
}
/**
* Zerlegt einen Text in Segmente und wendet dabei die Stufe des Lesers an.
* Bereiche über seiner Stufe kommen ohne ihren Inhalt zurück.
*/
export function redactForLevel(source: string, viewerLevel: number): Segment[] {
const segments: Segment[] = []
let cursor = 0
MARKUP.lastIndex = 0
let match: RegExpExecArray | null
while ((match = MARKUP.exec(source)) !== null) {
if (match.index > cursor) {
segments.push({ kind: 'text', text: source.slice(cursor, match.index) })
}
const requiredLevel = Number(match[1])
const content = match[2] ?? ''
if (viewerLevel >= requiredLevel) {
segments.push({ kind: 'text', text: content })
} else {
segments.push({
kind: 'redacted',
requiredLevel,
blockLength: barLength(content.length),
})
}
cursor = match.index + match[0].length
}
if (cursor < source.length) {
segments.push({ kind: 'text', text: source.slice(cursor) })
}
return segments
}
/**
* Die höchste Stufe, die in einem Text ausgezeichnet ist. Damit lässt sich an
* einem Eintrag anzeigen, dass er geschwärzte Stellen enthält, ohne den Text
* zu übertragen.
*/
export function highestRedactionLevel(source: string): number {
let highest = 0
MARKUP.lastIndex = 0
let match: RegExpExecArray | null
while ((match = MARKUP.exec(source)) !== null) {
highest = Math.max(highest, Number(match[1]))
}
return highest
}
/**
* Entfernt die Auszeichnung, behält den Inhalt. Für Leser, die alles sehen
* dürfen, und für den Volltextindex hoher Stufen.
*/
export function stripMarkup(source: string): string {
return source.replace(MARKUP, (_full, _level, content: string) => content)
}
/**
* Entfernt ausgezeichnete Bereiche samt Inhalt. Genau das tut auch die
* Funktion `strip_redactions` in der Migration, bevor der Suchvektor gefüllt
* wird — beide müssen dasselbe Ergebnis liefern.
*/
export function stripRedacted(source: string): string {
return source.replace(MARKUP, ' ')
}
/** Fügt eine Auszeichnung um einen markierten Bereich ein. */
export function markRedaction(source: string, start: number, end: number, level: number): string {
if (start >= end || level < 0 || level > 5) return source
return (
source.slice(0, start) +
`[[c${level}]]` +
source.slice(start, end) +
`[[/c${level}]]` +
source.slice(end)
)
}
+129
View File
@@ -0,0 +1,129 @@
import { z } from 'zod'
/**
* Bausteine einer Vorlage.
*
* 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.
*/
/** Bausteine, die der Nutzer ausfüllt. Ihr Wert landet in einem Abschnitt. */
export const INPUT_TYPES = [
'shorttext',
'longtext',
'select',
'multiselect',
'date',
'person',
'number',
'checklist',
'table',
] as const
/** Bausteine, die aus der Vorlage kommen und nicht ausgefüllt werden. */
export const STATIC_TYPES = ['heading', 'paragraph', 'signature', 'pagebreak'] as const
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(),
}
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(),
})
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 function isInputField(field: TemplateField): field is InputField {
return (INPUT_TYPES as readonly string[]).includes(field.type)
}
/** Platzhalter `{{schlüssel}}` in einem festen Textbaustein. */
export const PLACEHOLDER = /\{\{\s*([a-z][a-z0-9_]*)\s*\}\}/g
export function fillPlaceholders(
text: string,
values: Record<string, unknown>,
fallback = (key: string) => `[${key}]`,
): string {
return text.replace(PLACEHOLDER, (_match, name: string) => {
const value = values[name]
if (value === undefined || value === null || value === '') return fallback(name)
return Array.isArray(value) ? value.join(', ') : String(value)
})
}
/** Die Bausteine, die vor dem Abschluss eines Falls gefüllt sein müssen. */
export function requiredToCloseKeys(fields: TemplateField[]): string[] {
return fields
.filter((field): field is InputField => isInputField(field) && field.requiredToClose === true)
.map((field) => field.key)
}
+58
View File
@@ -0,0 +1,58 @@
// @vitest-environment node
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { createPrismaClient, type PrismaClient } from '@afis/db'
import { stripRedacted } from '@/lib/redaction'
/**
* Die Schwärzung wird an zwei Stellen entfernt: in TypeScript beim Rendern und
* in SQL, bevor der Suchvektor gefüllt wird. Laufen die beiden auseinander,
* findet die Suche einen Begriff, den die Ansicht verbirgt — und der Treffer
* allein verrät ihn dann.
*/
const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL
const suite = url ? describe : describe.skip
let db: PrismaClient
const PROBEN = [
'Der Hinweis kam von [[c4]]Wachmann Perreira, Sektor B[[/c4]] am 27.08.',
'[[c2]]zwei[[/c2]] und [[c5]]fünf[[/c5]] und [[c0]]offen[[/c0]]',
'ganz ohne Auszeichnung',
'mehrzeilig [[c3]]Zeile eins\nZeile zwei[[/c3]] Ende',
'unvollständige [[c3]]Auszeichnung ohne Ende',
]
suite('Schwärzung in TypeScript und SQL', () => {
beforeAll(() => {
db = createPrismaClient(url)
})
afterAll(async () => {
await db.$disconnect()
})
it('entfernt in beiden Sprachen dieselben Stellen', async () => {
for (const probe of PROBEN) {
const [row] = await db.$queryRaw<{ ergebnis: string }[]>`
SELECT strip_redactions(${probe}) AS ergebnis
`
const ausSql = row?.ergebnis ?? ''
const ausTs = stripRedacted(probe)
// Auf Wortebene vergleichen: SQL ersetzt durch ein Leerzeichen, der
// Parser ebenso, aber die umliegenden Leerzeichen können abweichen.
expect(ausSql.split(/\s+/).filter(Boolean)).toEqual(
ausTs.split(/\s+/).filter(Boolean),
)
}
})
it('lässt geschwärzte Begriffe in keiner der beiden Fassungen stehen', async () => {
const probe = PROBEN[0]!
const [row] = await db.$queryRaw<{ ergebnis: string }[]>`
SELECT strip_redactions(${probe}) AS ergebnis
`
expect(row?.ergebnis).not.toContain('Perreira')
expect(stripRedacted(probe)).not.toContain('Perreira')
})
})
+88
View File
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest'
import {
barLength,
highestRedactionLevel,
markRedaction,
redactForLevel,
stripMarkup,
stripRedacted,
} from '@/lib/redaction'
const BEISPIEL = 'Der Hinweis kam von [[c4]]Wachmann Perreira, Sektor B[[/c4]] am 27.08.'
describe('Schwärzung im Freitext', () => {
it('gibt den Inhalt frei, wenn die Stufe reicht', () => {
const segmente = redactForLevel(BEISPIEL, 4)
expect(segmente.every((s) => s.kind === 'text')).toBe(true)
expect(segmente.map((s) => (s.kind === 'text' ? s.text : '')).join('')).toContain(
'Wachmann Perreira',
)
})
it('schickt den Inhalt nicht mit, wenn die Stufe nicht reicht', () => {
const segmente = redactForLevel(BEISPIEL, 1)
const alsText = JSON.stringify(segmente)
// Das ist der Kern: der echte Wert verlässt den Server nicht.
expect(alsText).not.toContain('Perreira')
expect(alsText).not.toContain('Sektor B')
const geschwaerzt = segmente.find((s) => s.kind === 'redacted')
expect(geschwaerzt).toMatchObject({ requiredLevel: 4 })
})
it('rundet die Balkenlänge auf ein Raster, damit sie nichts verrät', () => {
// Zwei verschieden lange Namen dürfen nicht zu verschieden langen Balken
// führen, sonst lässt sich aus der Länge raten.
const kurz = redactForLevel('a [[c3]]Kim[[/c3]] b', 0)
const laenger = redactForLevel('a [[c3]]Kim Lee[[/c3]] b', 0)
const balken = (s: ReturnType<typeof redactForLevel>) =>
s.find((x) => x.kind === 'redacted') as { blockLength: number }
expect(balken(kurz).blockLength).toBe(balken(laenger).blockLength)
expect(barLength(1)).toBe(8)
expect(barLength(8)).toBe(8)
expect(barLength(9)).toBe(16)
})
it('behält den Text um die Schwärzung herum', () => {
const segmente = redactForLevel(BEISPIEL, 0)
const sichtbar = segmente.map((s) => (s.kind === 'text' ? s.text : '')).join('')
expect(sichtbar).toContain('Der Hinweis kam von')
expect(sichtbar).toContain('am 27.08.')
})
it('kommt mit mehreren und verschachtelt wirkenden Auszeichnungen zurecht', () => {
const text = '[[c2]]zwei[[/c2]] und [[c5]]fünf[[/c5]] und [[c0]]offen[[/c0]]'
const segmente = redactForLevel(text, 2)
const stufen = segmente.filter((s) => s.kind === 'redacted').map((s) => s.requiredLevel)
expect(stufen).toEqual([5])
expect(JSON.stringify(segmente)).toContain('zwei')
expect(JSON.stringify(segmente)).toContain('offen')
expect(JSON.stringify(segmente)).not.toContain('fünf')
})
it('nennt die höchste ausgezeichnete Stufe, ohne den Text preiszugeben', () => {
expect(highestRedactionLevel(BEISPIEL)).toBe(4)
expect(highestRedactionLevel('ohne Auszeichnung')).toBe(0)
})
it('entfernt die Auszeichnung, behält aber den Inhalt', () => {
expect(stripMarkup(BEISPIEL)).toBe(
'Der Hinweis kam von Wachmann Perreira, Sektor B am 27.08.',
)
})
it('entfernt ausgezeichnete Bereiche für den Suchvektor', () => {
// Muss dasselbe leisten wie strip_redactions in der Migration.
const fuerIndex = stripRedacted(BEISPIEL)
expect(fuerIndex).not.toContain('Perreira')
expect(fuerIndex).toContain('Der Hinweis kam von')
})
it('zeichnet einen markierten Bereich aus', () => {
const quelle = 'Zeuge Meier war dabei'
const markiert = markRedaction(quelle, 6, 11, 3)
expect(markiert).toBe('Zeuge [[c3]]Meier[[/c3]] war dabei')
expect(redactForLevel(markiert, 1).some((s) => s.kind === 'redacted')).toBe(true)
})
})