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 { 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 } }