diff --git a/TASKS.md b/TASKS.md index 1c114ad..1220b36 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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 diff --git a/docs/13-entscheidungen.md b/docs/13-entscheidungen.md index 778c3d1..d5cdda6 100644 --- a/docs/13-entscheidungen.md +++ b/docs/13-entscheidungen.md @@ -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: diff --git a/web/lib/redaction.ts b/web/lib/redaction.ts new file mode 100644 index 0000000..1acc147 --- /dev/null +++ b/web/lib/redaction.ts @@ -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) + ) +} diff --git a/web/lib/templates/fields.ts b/web/lib/templates/fields.ts new file mode 100644 index 0000000..d3ba2b0 --- /dev/null +++ b/web/lib/templates/fields.ts @@ -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() + 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 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, + 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) +} diff --git a/web/tests/schwaerzung-sql.test.ts b/web/tests/schwaerzung-sql.test.ts new file mode 100644 index 0000000..0946d0a --- /dev/null +++ b/web/tests/schwaerzung-sql.test.ts @@ -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') + }) +}) diff --git a/web/tests/schwaerzung.test.ts b/web/tests/schwaerzung.test.ts new file mode 100644 index 0000000..dcb3986 --- /dev/null +++ b/web/tests/schwaerzung.test.ts @@ -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) => + 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) + }) +})