Phase 2, erster Teil: Akten anlegen, lesen, Vermerke schreiben

Fachlogik in lib/cases/service.ts, Zugriffsprüfung in lib/access/. Jede
Funktion prüft selbst und schreibt ihren Protokolleintrag in derselben
Transaktion wie die Änderung — die Server Actions daneben sind dünn.

Schreibgrenzen nach docs/03: niemand legt eine Akte über der eigenen Stufe an,
niemand stuft über die eigene Stufe hoch, archiviert und versiegelt sind
schreibgeschützt auch für Admins. Eine Herabstufung verlangt eine Begründung
und landet als sichtbarer Systemeintrag im Verlauf, nicht nur im Protokoll.

Aktenzeichen über CaseCounter mit INSERT ... ON CONFLICT DO UPDATE, geprüft mit
acht gleichzeitigen Anlagen ohne Doppelung. Wer eine Akte anlegt, wird ihr
Ermittler, sonst verliert er sie aus dem Blick, sobald jemand hochstuft.

Fallliste cursorbasiert auf (updatedAt, id), Sichtbarkeit und Trefferzahl in
derselben Abfrage. Aktenansicht mit Kopfdaten sofort und Verlauf in eigener
Suspense-Grenze. Vermerke optimistisch mit clientseitiger UUIDv7, die zugleich
Idempotenzschlüssel ist — zweimal gesendet bleibt ein Eintrag.

Lesezugriffe ab Stufe 3 werden protokolliert, gepuffert und alle zwei Sekunden
gebündelt geschrieben, damit das Protokoll die Antwort nicht ausbremst.

Die vier Startvorlagen stehen jetzt im Seed statt in Phase 3: ohne Vorlage
lässt sich keine Akte anlegen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Harlekin
2026-09-01 22:57:38 +02:00
co-authored by Claude Opus 5
parent e8c5efc214
commit 1e5a470daf
19 changed files with 1600 additions and 16 deletions
+284
View File
@@ -0,0 +1,284 @@
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 },
})
if (!template) throw new AccessError('template-missing')
return db.$transaction(async (tx) => {
const caseNumber = await nextCaseNumber(tx)
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<void> {
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<void> {
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<string[]> {
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))
}