import { db } from '@/lib/db' import { writeAudit } from '@/lib/audit' import { visibleCaseWhere } from '@/lib/access/caseFilter' import { canCreateCase, canEditCase, canReclassify } from '@/lib/access/caseWrite' import { nextCaseNumber } from '@/lib/cases/caseNumber' import { findTransition, type CaseStatusName } from '@/lib/cases/lifecycle' import type { EffectivePermissions } from '@/lib/auth/permissions' /** * Fachlogik der Akte. Jede Funktion prüft die Berechtigung selbst und * schreibt ihren Protokolleintrag in derselben Transaktion wie die Änderung. * * Die Server Actions daneben sind dünn: sie nehmen entgegen, rufen hier auf * und geben zurück. Die Regeln stehen an genau einer Stelle. */ export class AccessError extends Error { constructor(readonly code: string) { super(code) this.name = 'AccessError' } } function must(check: { ok: boolean; reason?: string }): void { if (!check.ok) throw new AccessError(check.reason ?? 'denied') } /** Lädt eine Akte, sofern der Nutzer sie sehen darf. Sonst `null` — nie 403. */ export async function loadVisibleCase(p: EffectivePermissions, caseNumber: string) { return db.case.findFirst({ where: { AND: [visibleCaseWhere(p), { caseNumber }] }, select: { id: true, caseNumber: true, title: true, status: true, classification: true, templateId: true, templateKey: true, dueAt: true, closedAt: true, outcome: true, createdById: true, createdAt: true, updatedAt: true, }, }) } export async function createCase( p: EffectivePermissions, input: { title: string; classification: number; templateKey: string; dueAt?: Date | null }, ): Promise<{ caseNumber: string; id: string }> { must(canCreateCase(p, input.classification)) const template = await db.caseTemplate.findFirst({ where: { key: input.templateKey, isActive: true }, orderBy: { version: 'desc' }, select: { id: true, key: true, numberPrefix: true, numberPadding: true }, }) if (!template) throw new AccessError('template-missing') return db.$transaction(async (tx) => { // Das Muster kommt aus der Vorlage, jede Fallart hat ihren Nummernkreis. const caseNumber = await nextCaseNumber(tx, { prefix: template.numberPrefix, padding: template.numberPadding, }) const created = await tx.case.create({ data: { caseNumber, title: input.title, classification: input.classification, templateId: template.id, templateKey: template.key, createdById: p.userId, dueAt: input.dueAt ?? null, status: 'DRAFT', }, select: { id: true, caseNumber: true }, }) // Wer eine Akte anlegt, ist ihr Ermittler. Sonst verliert er sie aus dem // Blick, sobald jemand die Klassifizierung anhebt. await tx.caseParticipant.create({ data: { caseId: created.id, userId: p.userId, role: 'LEAD', addedById: p.userId }, }) await tx.caseEntry.create({ data: { id: crypto.randomUUID(), caseId: created.id, type: 'SYSTEM', body: `Akte angelegt mit Klassifizierung ${input.classification}`, authorId: p.userId, minClearance: 0, }, }) await writeAudit(tx, { actorId: p.userId, action: 'case.create', targetType: 'Case', targetId: created.id, clearanceAtTime: p.clearanceLevel, meta: { caseNumber: created.caseNumber, classification: input.classification }, }) return created }) } export async function addEntry( p: EffectivePermissions, input: { id: string; caseNumber: string; body: string; type?: 'NOTE' | 'INTERROGATION' | 'EVIDENCE'; minClearance?: number }, ): Promise<{ id: string }> { const target = await loadVisibleCase(p, input.caseNumber) if (!target) throw new AccessError('not-found') must(canEditCase(p, target, 'entry.create')) const minClearance = input.minClearance ?? 0 if (minClearance > p.clearanceLevel) throw new AccessError('clearance-too-low') return db.$transaction(async (tx) => { // Die clientseitige UUIDv7 ist zugleich Idempotenzschlüssel: ein // wiederholtes Absenden nach einem Netzfehler erzeugt keinen zweiten // Eintrag. Siehe docs/09-performance-und-ux.md. const existing = await tx.caseEntry.findUnique({ where: { id: input.id }, select: { id: true } }) if (existing) return existing const entry = await tx.caseEntry.create({ data: { id: input.id, caseId: target.id, type: input.type ?? 'NOTE', body: input.body, authorId: p.userId, minClearance, }, select: { id: true }, }) await writeAudit(tx, { actorId: p.userId, action: 'entry.create', targetType: 'CaseEntry', targetId: entry.id, clearanceAtTime: p.clearanceLevel, meta: { caseId: target.id, caseNumber: target.caseNumber, minClearance }, }) return entry }) } export async function changeStatus( p: EffectivePermissions, input: { caseNumber: string; to: CaseStatusName; reason?: string }, ): Promise { const target = await loadVisibleCase(p, input.caseNumber) if (!target) throw new AccessError('not-found') const transition = findTransition(target.status as CaseStatusName, input.to) if (!transition) throw new AccessError('transition-invalid') must(canEditCase(p, target, transition.permission)) const reason = input.reason?.trim() ?? '' if (transition.requiresReason && reason.length < 10) throw new AccessError('reason-required') if (transition.requiresCompleteSections) { const missing = await missingRequiredSections(target.id, target.templateId) if (missing.length > 0) throw new AccessError('sections-incomplete') } await db.$transaction(async (tx) => { await tx.case.update({ where: { id: target.id }, data: { status: input.to, closedAt: input.to === 'CLOSED' ? new Date() : target.closedAt, }, }) await tx.caseEntry.create({ data: { id: crypto.randomUUID(), caseId: target.id, type: 'STATUS_CHANGE', body: reason ? `Status von ${target.status} auf ${input.to} geändert. Begründung: ${reason}` : `Status von ${target.status} auf ${input.to} geändert`, authorId: p.userId, minClearance: 0, }, }) await writeAudit(tx, { actorId: p.userId, action: 'case.status', targetType: 'Case', targetId: target.id, clearanceAtTime: p.clearanceLevel, meta: { from: target.status, to: input.to, reason: reason || undefined }, }) }) } /** * Klassifizierung ändern. * * Eine Herabstufung macht Inhalte für Leute sichtbar, die sie vorher nicht * sehen durften. Deshalb landet sie nicht nur im Protokoll, sondern als * sichtbarer Systemeintrag im Verlauf der Akte. */ export async function reclassify( p: EffectivePermissions, input: { caseNumber: string; to: number; reason?: string }, ): Promise { const target = await loadVisibleCase(p, input.caseNumber) if (!target) throw new AccessError('not-found') if (target.classification === input.to) return must(canReclassify(p, target, input.to)) const lowering = input.to < target.classification const reason = input.reason?.trim() ?? '' if (lowering && reason.length < 10) throw new AccessError('reason-required') await db.$transaction(async (tx) => { await tx.case.update({ where: { id: target.id }, data: { classification: input.to } }) await tx.caseEntry.create({ data: { id: crypto.randomUUID(), caseId: target.id, type: 'SYSTEM', body: lowering ? `Klassifizierung von ${target.classification} auf ${input.to} herabgesetzt. Begründung: ${reason}` : `Klassifizierung von ${target.classification} auf ${input.to} angehoben`, authorId: p.userId, minClearance: 0, }, }) await writeAudit(tx, { actorId: p.userId, action: 'case.reclassify', targetType: 'Case', targetId: target.id, clearanceAtTime: p.clearanceLevel, meta: { from: target.classification, to: input.to, lowering, reason: reason || undefined, }, }) }) } /** Felder mit `requiredToClose`, die noch leer sind. Kommt mit Phase 3 zum Tragen. */ async function missingRequiredSections(caseId: string, templateId: string): Promise { const template = await db.caseTemplate.findUnique({ where: { id: templateId }, select: { fields: true }, }) const fields = Array.isArray(template?.fields) ? template.fields : [] const required = fields .filter((field): field is { key: string; requiredToClose?: boolean } => typeof field === 'object' && field !== null && 'key' in field, ) .filter((field) => field.requiredToClose === true) .map((field) => field.key) if (required.length === 0) return [] const filled = await db.caseSection.findMany({ where: { caseId, fieldKey: { in: required } }, select: { fieldKey: true, value: true }, }) const filledKeys = new Set( filled.filter((s) => s.value !== null && s.value !== '').map((s) => s.fieldKey), ) return required.filter((key) => !filledKeys.has(key)) }