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.
This commit is contained in:
Harlekin
2026-09-01 22:57:38 +02:00
parent 31ac436fde
commit 651f239c68
18 changed files with 1587 additions and 4 deletions
+100
View File
@@ -0,0 +1,100 @@
import type { EffectivePermissions, Permission } from '@/lib/auth/permissions'
/**
* Schreibgrenzen der Freigabestufe, siehe docs/03-auth-und-rechte.md.
*
* Die Leseprüfung steckt in `visibleCaseWhere`. Diese Regeln kommen obendrauf
* und gelten für jede Server Action, unabhängig davon, was die Oberfläche
* vorher versteckt hat.
*/
/** Status, in denen eine Akte nicht mehr verändert wird. */
export const READ_ONLY_STATUS = ['ARCHIVED', 'SEALED'] as const
export type WriteDenial =
| { ok: false; reason: 'no-permission'; permission: Permission }
| { ok: false; reason: 'clearance-too-low'; required: number }
| { ok: false; reason: 'read-only'; status: string }
| { ok: false; reason: 'closed' }
export type WriteCheck = { ok: true } | WriteDenial
function requirePermission(p: EffectivePermissions, permission: Permission): WriteCheck {
if (p.isAdmin || p.permissions.has(permission)) return { ok: true }
return { ok: false, reason: 'no-permission', permission }
}
/**
* Darf dieser Nutzer eine Akte mit dieser Klassifizierung anlegen?
*
* Niemand legt eine Akte über der eigenen Stufe an — auch kein Admin, denn er
* hätte sonst eine Akte erzeugt, deren Sichtbarkeit er nicht überblickt.
* Der Admin hat ohnehin Stufe 5.
*/
export function canCreateCase(p: EffectivePermissions, classification: number): WriteCheck {
const permitted = requirePermission(p, 'case.create')
if (!permitted.ok) return permitted
if (classification > p.clearanceLevel) {
return { ok: false, reason: 'clearance-too-low', required: classification }
}
return { ok: true }
}
/**
* Darf dieser Nutzer diese Akte verändern?
*
* Setzt voraus, dass die Akte bereits durch `visibleCaseWhere` gelaufen ist:
* was man nicht sehen darf, kann man nicht anfassen.
*/
export function canEditCase(
p: EffectivePermissions,
target: { status: string; classification: number },
permission: Permission = 'case.edit',
): WriteCheck {
if ((READ_ONLY_STATUS as readonly string[]).includes(target.status)) {
// Archiviert und versiegelt sind schreibgeschützt, auch für Admins.
return { ok: false, reason: 'read-only', status: target.status }
}
const permitted = requirePermission(p, permission)
if (!permitted.ok) return permitted
if (target.classification > p.clearanceLevel && !p.isAdmin) {
return { ok: false, reason: 'clearance-too-low', required: target.classification }
}
if (target.status === 'CLOSED' && permission !== 'case.reopen') {
// Änderungen an abgeschlossenen Fällen brauchen ein eigenes Recht und
// erzeugen zusätzlich einen SYSTEM-Eintrag.
const closedAllowed = requirePermission(p, 'case.edit_closed')
if (!closedAllowed.ok) return { ok: false, reason: 'closed' }
}
return { ok: true }
}
/**
* Darf dieser Nutzer die Klassifizierung auf diesen Wert setzen?
*
* Die neue Stufe darf die eigene nicht übersteigen. Eine Herabstufung ist der
* heikelste Vorgang im System: sie macht Inhalte für Leute sichtbar, die sie
* vorher nicht sehen durften. Erlaubt ist sie trotzdem — sie wird nur sichtbar
* protokolliert, im Verlauf und im Zugriffsprotokoll.
*/
export function canReclassify(
p: EffectivePermissions,
target: { status: string; classification: number },
next: number,
): WriteCheck {
if (next < 0 || next > 5) return { ok: false, reason: 'clearance-too-low', required: next }
if (next > p.clearanceLevel) {
return { ok: false, reason: 'clearance-too-low', required: next }
}
return canEditCase(p, target)
}
/** Für Server Actions: wirft mit einer Meldung, die in die Oberfläche darf. */
export function assertWritable(check: WriteCheck, messages: Record<string, string>): void {
if (check.ok) return
throw new Error(messages[check.reason] ?? messages.default ?? 'Nicht erlaubt')
}
+100
View File
@@ -0,0 +1,100 @@
import type { Prisma } from '@afis/db'
import { db } from '@/lib/db'
/**
* Zugriffsprotokoll. Zweite, unabhängige Spur neben der Versionstabelle.
*
* Zwei Wege, aus einem Grund: der Eintrag darf die Antwort nicht blockieren
* (docs/09-performance-und-ux.md), aber bei schreibenden Aktionen darf er auch
* nicht verloren gehen.
*
* - `writeAudit` läuft **in derselben Transaktion** wie die Änderung. Schlägt
* das Protokoll fehl, schlägt die Änderung fehl.
* - `recordRead` sammelt Lesezugriffe im Prozess und schreibt sie alle zwei
* Sekunden gebündelt weg. Ein verlorener Lesevermerk nach einem Absturz ist
* hinnehmbar, eine um 40 ms langsamere Aktenansicht nicht.
*/
export type AuditAction =
| 'case.read'
| 'case.create'
| 'case.update'
| 'case.reclassify'
| 'case.status'
| 'entry.create'
| 'entry.edit'
| 'entry.retract'
| 'entry.restore'
| 'roles.update'
| 'export.pdf'
export type AuditInput = {
actorId: string
action: AuditAction
targetType: string
targetId: string
clearanceAtTime: number
meta?: Prisma.InputJsonValue
}
/** Ab dieser Stufe wird auch das blosse Lesen protokolliert (harte Regel 3). */
export const READ_AUDIT_FROM_LEVEL = 3
type Client = Pick<typeof db, 'auditLog'>
/** Schreibende Aktionen: synchron, in der Transaktion des Aufrufers. */
export async function writeAudit(client: Client, input: AuditInput): Promise<void> {
await client.auditLog.create({
data: {
actorId: input.actorId,
action: input.action,
targetType: input.targetType,
targetId: input.targetId,
clearanceAtTime: input.clearanceAtTime,
meta: input.meta,
},
})
}
const buffer: AuditInput[] = []
let flushTimer: NodeJS.Timeout | null = null
async function flush(): Promise<void> {
flushTimer = null
if (buffer.length === 0) return
const batch = buffer.splice(0, buffer.length)
try {
await db.auditLog.createMany({
data: batch.map((entry) => ({
actorId: entry.actorId,
action: entry.action,
targetType: entry.targetType,
targetId: entry.targetId,
clearanceAtTime: entry.clearanceAtTime,
meta: entry.meta,
})),
})
} catch (error) {
console.error('[audit] Bündel nicht geschrieben', error)
}
}
/**
* Lesezugriff vormerken. Nur ab Stufe 3 aufrufen, sonst füllt sich das
* Protokoll mit Belanglosigkeiten und die interessanten Zeilen gehen unter.
*/
export function recordRead(input: AuditInput): void {
buffer.push(input)
flushTimer ??= setTimeout(() => void flush(), 2000)
// Der Prozess soll nicht wegen des Protokolls offen bleiben.
flushTimer.unref?.()
}
/** Für Tests und für das geordnete Herunterfahren. */
export async function flushAuditBuffer(): Promise<void> {
if (flushTimer) {
clearTimeout(flushTimer)
flushTimer = null
}
await flush()
}
+48
View File
@@ -0,0 +1,48 @@
import type { Prisma } from '@afis/db'
/** `AfiS-2026-0042`, siehe Glossar in docs/01-produkt.md. */
export function formatCaseNumber(year: number, number: number): string {
return `AfiS-${year}-${String(number).padStart(4, '0')}`
}
const PATTERN = /^AfiS-(\d{4})-(\d{4})$/
export function parseCaseNumber(value: string): { year: number; number: number } | null {
const match = PATTERN.exec(value.trim())
if (!match) return null
return { year: Number(match[1]), number: Number(match[2]) }
}
/** Erkennt Aktenzeichen im Fliesstext, für die Vorschaukarten im Chat. */
export const CASE_NUMBER_GLOBAL = /AfiS-\d{4}-\d{4}/g
/**
* Vergibt das nächste Aktenzeichen des Jahres.
*
* Läuft mit `SELECT … FOR UPDATE` in **derselben** Transaktion wie das Anlegen
* des Falls. Zwei gleichzeitige Anlagen warten dadurch aufeinander, statt
* dieselbe Nummer zu ziehen oder eine Lücke zu hinterlassen.
* Siehe docs/04-datenmodell.md.
*
* Eine Sequenz pro Jahr wäre einfacher, müsste aber jedes Jahr von Hand
* angelegt werden — und der Abteilungsleiter ist ausdrücklich nicht technisch.
*/
export async function nextCaseNumber(
tx: Prisma.TransactionClient,
year = new Date().getFullYear(),
): Promise<string> {
const rows = await tx.$queryRaw<{ lastNumber: number }[]>`
INSERT INTO "CaseCounter" ("year", "lastNumber")
VALUES (${year}, 1)
ON CONFLICT ("year") DO UPDATE
SET "lastNumber" = "CaseCounter"."lastNumber" + 1
RETURNING "lastNumber"
`
const lastNumber = rows[0]?.lastNumber
if (lastNumber === undefined) {
throw new Error('Aktenzeichen konnte nicht vergeben werden')
}
return formatCaseNumber(year, lastNumber)
}
+61
View File
@@ -0,0 +1,61 @@
import type { Permission } from '@/lib/auth/permissions'
/**
* Lebenszyklus einer Akte, siehe docs/05-akten-und-vorlagen.md.
*
* DRAFT ──► OPEN ──► REVIEW ──► CLOSED ──► ARCHIVED
* │ ▲ │
* ▼ │ └──► OPEN (Rückgabe zur Nachbesserung)
* DORMANT
*
* SEALED ist aus jedem Zustand erreichbar und nur mit Recht 'admin' oder
* Stufe 5 lesbar.
*/
export type CaseStatusName =
| 'DRAFT'
| 'OPEN'
| 'DORMANT'
| 'REVIEW'
| 'CLOSED'
| 'ARCHIVED'
| 'SEALED'
export type Transition = {
from: CaseStatusName
to: CaseStatusName
permission: Permission
/** Verlangt eine Begründung, die als Vermerk im Verlauf landet. */
requiresReason?: boolean
/** Verlangt, dass alle `requiredToClose`-Felder gefüllt sind. */
requiresCompleteSections?: boolean
}
export const TRANSITIONS: readonly Transition[] = [
{ from: 'DRAFT', to: 'OPEN', permission: 'case.edit' },
{ from: 'OPEN', to: 'DORMANT', permission: 'case.edit' },
{ from: 'DORMANT', to: 'OPEN', permission: 'case.edit' },
{ from: 'OPEN', to: 'REVIEW', permission: 'case.edit' },
{ from: 'REVIEW', to: 'OPEN', permission: 'case.edit' },
{ from: 'REVIEW', to: 'CLOSED', permission: 'case.close', requiresCompleteSections: true },
{ from: 'CLOSED', to: 'OPEN', permission: 'case.reopen', requiresReason: true },
{ from: 'CLOSED', to: 'ARCHIVED', permission: 'case.close' },
]
/** SEALED erreicht man aus jedem Zustand, aber nur mit dem Recht 'admin'. */
export const SEAL_PERMISSION: Permission = 'admin'
export function findTransition(
from: CaseStatusName,
to: CaseStatusName,
): Transition | undefined {
if (to === 'SEALED' && from !== 'SEALED') {
return { from, to, permission: SEAL_PERMISSION, requiresReason: true }
}
return TRANSITIONS.find((t) => t.from === from && t.to === to)
}
export function allowedTargets(from: CaseStatusName): CaseStatusName[] {
const targets = TRANSITIONS.filter((t) => t.from === from).map((t) => t.to)
if (from !== 'SEALED') targets.push('SEALED')
return targets
}
+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))
}
+28
View File
@@ -139,6 +139,34 @@ export const de = {
links: 'Verknüpfungen',
attachments: 'Anhänge',
empty: 'Noch keine Fallakte. Erste Fallakte anlegen.',
create: 'Fallakte anlegen',
createTitle: 'Neue Fallakte',
open: 'Öffnen',
updatedAt: 'Zuletzt geändert',
more: 'Weitere laden',
listHeading: 'Fallakten',
countLabel: (n: number) => (n === 1 ? '1 Akte' : `${n} Akten`),
filterStatus: 'Status',
filterAll: 'Alle',
reclassify: 'Klassifizierung ändern',
reclassifyReason: 'Begründung der Herabstufung',
statusChange: 'Status ändern',
newEntry: 'Neuer Vermerk',
entryPlaceholder: 'Was ist passiert?',
saving: 'Wird gespeichert',
unsaved: 'Nicht gespeichert',
errors: {
'not-found': 'Die Akte gibt es nicht, oder du darfst sie nicht sehen.',
'clearance-too-low': 'Deine Freigabestufe reicht dafür nicht.',
'no-permission': 'Dir fehlt das nötige Recht.',
'read-only': 'Die Akte ist schreibgeschützt.',
closed: 'Die Akte ist abgeschlossen. Änderungen brauchen ein eigenes Recht.',
'reason-required': 'Begründung nötig, mindestens 10 Zeichen.',
'transition-invalid': 'Dieser Statuswechsel ist nicht vorgesehen.',
'sections-incomplete': 'Es fehlen Pflichtangaben für den Abschluss.',
'template-missing': 'Für diese Fallart gibt es keine Vorlage.',
default: 'Nicht gespeichert. Erneut versuchen.',
} as Record<string, string>,
statusNames: {
DRAFT: 'Entwurf',
OPEN: 'Offen',