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:
co-authored by
Claude Opus 5
parent
d160741237
commit
f5769ca58a
@@ -0,0 +1,171 @@
|
||||
// @vitest-environment node
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { createPrismaClient, type PrismaClient } from '@afis/db'
|
||||
import type { EffectivePermissions, Permission } from '@/lib/auth/permissions'
|
||||
|
||||
const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL
|
||||
const suite = url ? describe : describe.skip
|
||||
|
||||
let db: PrismaClient
|
||||
const marke = `abschnitt-${Date.now()}`
|
||||
const NUTZER = `${marke}-nutzer`
|
||||
const ZWEITER = `${marke}-zweiter`
|
||||
|
||||
function perms(level: number, userId = NUTZER): EffectivePermissions {
|
||||
const permissions: Permission[] = ['case.create', 'case.edit', 'entry.create']
|
||||
return { userId, clearanceLevel: level, permissions: new Set(permissions), isAdmin: false, hasMembership: true }
|
||||
}
|
||||
|
||||
const FELDER = [
|
||||
{ key: 'sachverhalt', label: 'Sachverhalt', type: 'longtext' },
|
||||
{ key: 'bewertung', label: 'Bewertung', type: 'longtext', requiredToClose: true },
|
||||
{ key: 'zeugenname', label: 'Zeuge', type: 'shorttext', minClearance: 4 },
|
||||
{ key: 'hinweis', label: 'Hinweis', type: 'paragraph', text: 'Fester Text mit {{sachverhalt}}' },
|
||||
]
|
||||
|
||||
let caseNumber = ''
|
||||
let caseId = ''
|
||||
let templateId = ''
|
||||
|
||||
suite('Abschnitte', () => {
|
||||
beforeAll(async () => {
|
||||
db = createPrismaClient(url)
|
||||
const vorlage = await db.caseTemplate.create({
|
||||
data: { key: `${marke}-vorlage`, version: 1, name: 'Prüfvorlage', fields: FELDER },
|
||||
})
|
||||
templateId = vorlage.id
|
||||
for (const id of [NUTZER, ZWEITER]) {
|
||||
await db.user.create({ data: { id, username: id, displayName: id } })
|
||||
}
|
||||
const { createCase } = await import('@/lib/cases/service')
|
||||
const akte = await createCase(perms(5), {
|
||||
title: 'Abschnittsprüfung',
|
||||
classification: 1,
|
||||
templateKey: `${marke}-vorlage`,
|
||||
})
|
||||
caseNumber = akte.caseNumber
|
||||
caseId = akte.id
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await db.$disconnect()
|
||||
})
|
||||
|
||||
it('legt beim ersten Speichern Version 1 an', async () => {
|
||||
const { saveSection } = await import('@/lib/cases/sectionService')
|
||||
const ergebnis = await saveSection(perms(5), {
|
||||
caseNumber,
|
||||
fieldKey: 'sachverhalt',
|
||||
value: 'Erster Stand',
|
||||
baseVersion: 0,
|
||||
})
|
||||
expect(ergebnis.version).toBe(1)
|
||||
})
|
||||
|
||||
it('erkennt einen Konflikt, statt still zu überschreiben', async () => {
|
||||
const { saveSection, ConflictError } = await import('@/lib/cases/sectionService')
|
||||
|
||||
// Zwei laden dieselbe Fassung.
|
||||
await saveSection(perms(5), {
|
||||
caseNumber,
|
||||
fieldKey: 'bewertung',
|
||||
value: 'Fassung A',
|
||||
baseVersion: 0,
|
||||
})
|
||||
|
||||
// Einer speichert, der andere hat noch die alte Version im Formular.
|
||||
await saveSection(perms(5), {
|
||||
caseNumber,
|
||||
fieldKey: 'bewertung',
|
||||
value: 'Fassung B',
|
||||
baseVersion: 1,
|
||||
})
|
||||
|
||||
await expect(
|
||||
saveSection(perms(5, ZWEITER), {
|
||||
caseNumber,
|
||||
fieldKey: 'bewertung',
|
||||
value: 'Fassung C',
|
||||
baseVersion: 1,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ConflictError)
|
||||
|
||||
// Der Konflikt bringt die fremde Fassung mit, damit die Oberfläche beide
|
||||
// nebeneinander zeigen kann.
|
||||
try {
|
||||
await saveSection(perms(5, ZWEITER), {
|
||||
caseNumber,
|
||||
fieldKey: 'bewertung',
|
||||
value: 'Fassung C',
|
||||
baseVersion: 1,
|
||||
})
|
||||
} catch (error) {
|
||||
expect((error as InstanceType<typeof ConflictError>).current).toMatchObject({
|
||||
value: 'Fassung B',
|
||||
version: 2,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('schreibt jede Fassung in die Versionstabelle', async () => {
|
||||
const section = await db.caseSection.findUniqueOrThrow({
|
||||
where: { caseId_fieldKey: { caseId, fieldKey: 'bewertung' } },
|
||||
})
|
||||
const versionen = await db.caseSectionVersion.findMany({
|
||||
where: { sectionId: section.id },
|
||||
orderBy: { version: 'asc' },
|
||||
})
|
||||
expect(versionen.map((v) => v.value)).toEqual(['Fassung A'])
|
||||
expect(section.currentVersion).toBe(2)
|
||||
})
|
||||
|
||||
it('ersetzt ein Feld über der Stufe des Lesers serverseitig', async () => {
|
||||
const { saveSection, loadSections } = await import('@/lib/cases/sectionService')
|
||||
await saveSection(perms(5), {
|
||||
caseNumber,
|
||||
fieldKey: 'zeugenname',
|
||||
value: 'Wachmann Perreira',
|
||||
baseVersion: 0,
|
||||
})
|
||||
|
||||
const fuerNiedrig = await loadSections(perms(2), caseId, templateId)
|
||||
const feld = fuerNiedrig.find((s) => s.fieldKey === 'zeugenname')
|
||||
expect(feld?.visible).toBe(false)
|
||||
// Der echte Wert verlässt den Server nicht.
|
||||
expect(JSON.stringify(fuerNiedrig)).not.toContain('Perreira')
|
||||
expect(feld?.value).toMatchObject({ redacted: true, requiredLevel: 4 })
|
||||
|
||||
const fuerHoch = await loadSections(perms(4), caseId, templateId)
|
||||
expect(fuerHoch.find((s) => s.fieldKey === 'zeugenname')?.value).toBe('Wachmann Perreira')
|
||||
})
|
||||
|
||||
it('lässt niemanden ein Feld über die eigene Stufe heben', async () => {
|
||||
const { saveSection } = await import('@/lib/cases/sectionService')
|
||||
const { AccessError } = await import('@/lib/cases/service')
|
||||
await expect(
|
||||
saveSection(perms(2), {
|
||||
caseNumber,
|
||||
fieldKey: 'sachverhalt',
|
||||
value: 'Heimlich hochstufen',
|
||||
baseVersion: 1,
|
||||
minClearance: 5,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(AccessError)
|
||||
})
|
||||
|
||||
it('gibt Fliesstext als Segmente zurück, mit angewandter Schwärzung', async () => {
|
||||
const { saveSection, loadSections } = await import('@/lib/cases/sectionService')
|
||||
await saveSection(perms(5), {
|
||||
caseNumber,
|
||||
fieldKey: 'sachverhalt',
|
||||
value: 'Offen [[c4]]geheim[[/c4]] Ende',
|
||||
baseVersion: 1,
|
||||
})
|
||||
|
||||
const niedrig = await loadSections(perms(1), caseId, templateId)
|
||||
const feld = niedrig.find((s) => s.fieldKey === 'sachverhalt')
|
||||
if (!feld || feld.visible !== true) throw new Error('Abschnitt sollte sichtbar sein')
|
||||
expect(JSON.stringify(feld.segments)).not.toContain('geheim')
|
||||
expect(JSON.stringify(feld.segments)).toContain('Offen')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { diffSummary, wordDiff } from '@/lib/diff'
|
||||
|
||||
describe('Wortdiff', () => {
|
||||
it('setzt den ursprünglichen Text verlustfrei wieder zusammen', () => {
|
||||
const vorher = 'Der Zeuge sagte am Montag aus.'
|
||||
const nachher = 'Der Zeuge sagte am Dienstag erneut aus.'
|
||||
const teile = wordDiff(vorher, nachher)
|
||||
|
||||
const rekonstruiertVorher = teile
|
||||
.filter((t) => t.kind !== 'neu')
|
||||
.map((t) => t.text)
|
||||
.join('')
|
||||
const rekonstruiertNachher = teile
|
||||
.filter((t) => t.kind !== 'entfernt')
|
||||
.map((t) => t.text)
|
||||
.join('')
|
||||
|
||||
expect(rekonstruiertVorher).toBe(vorher)
|
||||
expect(rekonstruiertNachher).toBe(nachher)
|
||||
})
|
||||
|
||||
it('markiert nur die geänderten Wörter', () => {
|
||||
const teile = wordDiff('a b c', 'a x c')
|
||||
expect(teile.find((t) => t.kind === 'entfernt')?.text.trim()).toBe('b')
|
||||
expect(teile.find((t) => t.kind === 'neu')?.text.trim()).toBe('x')
|
||||
})
|
||||
|
||||
it('kommt mit leeren Fassungen zurecht', () => {
|
||||
expect(wordDiff('', 'neu').every((t) => t.kind === 'neu')).toBe(true)
|
||||
expect(wordDiff('weg', '').every((t) => t.kind === 'entfernt')).toBe(true)
|
||||
expect(wordDiff('', '')).toEqual([])
|
||||
})
|
||||
|
||||
it('meldet bei unverändertem Text keine Änderung', () => {
|
||||
expect(diffSummary('gleich', 'gleich')).toEqual({ entfernt: 0, neu: 0 })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user