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>
101 lines
2.8 KiB
TypeScript
101 lines
2.8 KiB
TypeScript
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()
|
|
}
|