Prüfung / Lint, Typen, Tests (push) Canceled after 0s
Prüfung / Images bauen (pdf) (push) Canceled after 0s
Prüfung / Images bauen (web) (push) Canceled after 0s
Prüfung / Images bauen (worker) (push) Canceled after 0s
Images / bauen (pdf, docker/pdf.Dockerfile) (push) Canceled after 0s
Images / bauen (web, docker/web.Dockerfile) (push) Canceled after 0s
Images / bauen (worker, docker/worker.Dockerfile) (push) Canceled after 0s
Bisher gab es ueberhaupt keinen Weg, einen Anhang wieder loszuwerden. Wer die falsche Datei hochlud, musste damit leben. Das ist keine Strenge, sondern eine Luecke: die Regel des Hauses lautet nicht "nichts geht wieder weg", sondern "nichts geht unbemerkt weg". Also derselbe Weg wie beim Verlaufseintrag und beim Dokument. Zurueckziehen mit Pflichtbegruendung, der Anhang bleibt als Grabstein an seiner Stelle stehen - durchgestrichen, mit Zeitpunkt und Grund, fuer jeden sichtbar, der ihn sehen darf. Aufheben laesst es sich, beides steht im Protokoll. Aus dem Umlauf heisst wirklich aus dem Umlauf: die Auslieferung verweigert die Datei, sie verschwindet aus Lichtbildfeldern, und das Vorschaubild faellt weg. Ein Grabstein, unter dem der Download weiterlaeuft, waere eine Beschriftung und keine Ruecknahme. Fuer `audit.read` bleibt sie abrufbar - wie der Text eines zurueckgezogenen Eintrags, sonst waere es doch ein Loeschen. Im Ausdruck steht der Anhang weiter im Verzeichnis, mit dem Grund daneben. Ihn dort wegzulassen hiesse, den Ausdruck sauberer zu machen als die Akte. Die Datei auf der Platte bleibt liegen. Sie zu entfernen waere das Loeschen, das hier gerade nicht stattfindet - und ein Anhang, dessen Pruefsumme nicht mehr nachrechenbar ist, waere als Beweismittel wertlos. Dazu eine Regel in der Datenbank, die DELETE auf Attachment verbietet, und eine Bedingung, die einen Grabstein ohne Begruendung ausschliesst. Zuruecknehmen darf, wer die Datei hochgeladen hat - der haeufige Fall, und ohne das bliebe jeder Fehlgriff liegen, weil dafuer die Leitung zu rufen waere - sowie jeder, der am Besitzer schreiben darf.
134 lines
3.6 KiB
TypeScript
134 lines
3.6 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'
|
|
| 'attachment.retract'
|
|
| 'attachment.restore'
|
|
| 'chat.adopt'
|
|
| 'department.create'
|
|
| 'department.update'
|
|
| 'department.assign'
|
|
| 'department.entry.create'
|
|
| 'department.entry.edit'
|
|
| 'department.entry.retract'
|
|
| 'roles.update'
|
|
| 'template.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()
|
|
}
|