Files
AfiS-Aktensystem/web/lib/audit/write.ts
T
HarlekinandClaude Opus 5 5f9f5d99fb Protokoll im Adminbereich, ein Knopf für das ganze Blatt
Die restlichen zwei Änderungswünsche.

**Protokoll.** „Wer was wo macht" stand seit dem ersten Tag in AuditLog —
harte Regel 3 sorgt dafür. Was fehlte, war die Ansicht darauf. /admin/protokoll
ist genau das und keine zweite Aufzeichnung: filterbar nach Person, Aktion,
Art und Zeitraum, rückwärts blätternd.

Nur für die Leitung. Eine Zeile nennt Aktenzeichen und Namen, und schon die
Existenz einer Zeile verrät, dass es einen Vorgang gibt; ein gefiltertes
Protokoll für alle wäre ein Weg, die Sichtbarkeitsregeln von hinten
aufzurollen. Aus demselben Grund werden Kennungen ohne Sichtbarkeitsfilter zu
Aktenzeichen aufgelöst — ein Protokoll aus UUIDs liest niemand, und eines, das
niemand liest, ist keine Kontrolle.

**Speichern.** Jedes Feld trug seinen eigenen Knopf: ein Arbeitsvertrag mit
zwölf Feldern hiess zwölfmal speichern. Der Knopf steht jetzt am Blatt.

Geschrieben wird trotzdem je Feld und nicht als Transaktion, und das folgt aus
dem Datenmodell: Abschnitte sind einzeln versioniert, weil mehrere Leute
gleichzeitig an einer Akte arbeiten. Als Transaktion blockierte ein einziges
zwischenzeitlich geändertes Feld das ganze Blatt — elf gute Änderungen verloren,
weil jemand das zwölfte angefasst hat. So geht durch, was durchgeht, und die
Rückmeldung sagt, was offen blieb und wo. Lichtbilder bleiben ausgenommen: ein
Upload mit Prüfkette ist keine Feldänderung.

lib/audit.ts liegt jetzt als lib/audit/write.ts neben lib/audit/query.ts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 15:16:10 +02:00

125 lines
3.3 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'
| 'case.participant.add'
| 'case.participant.remove'
| 'case.link'
| 'case.unlink'
| 'personnel.read'
| 'personnel.create'
| 'personnel.update'
| 'personnel.reclassify'
| 'personnel.assign'
| 'personnel.unassign'
| 'document.read'
| 'document.create'
| 'document.update'
| 'document.finalize'
| 'document.retract'
| 'document.reclassify'
| 'attachment.upload'
| 'attachment.read'
| 'attachment.release'
| 'chat.adopt'
| 'roles.update'
| 'export.pdf'
| 'export.approve'
| 'export.reject'
| 'export.download'
| 'export.database'
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()
}