Files
AfiS-Aktensystem/web/lib/audit/query.ts
T
Harlekin 38be773636 README erklärt das Projekt, Zusicherungen unter Nummern
Die README beschrieb das Vorgehen statt der Anwendung. Sie erklärt jetzt, was
das System kann, wie es aufgebaut ist und wie man es lokal und im Betrieb
fährt.

Dazu tragen die acht Zusicherungen dort ihre Nummern - Berechtigungsprüfung in
der Abfrage, kein DELETE im Verlauf, Protokollpflicht ab Stufe 3, Magic Bytes
statt Dateiendungen, Skelette statt Spinner, Existenz als Information, keine
Geheimnisse im Repository, keine Einmal-Container. Achtzehn Dateien verwiesen
für ebendiese Regeln auf eine Datei, die nicht mehr mitverfolgt wird; sie
zeigen jetzt auf die README.

Arbeitsunterlagen bleiben auf der Platte, werden aber nicht mehr verfolgt. Die
Ausschlussregel steht in .git/info/exclude statt in .gitignore, damit sie nicht
selbst im Repository steht.
2026-09-04 08:42:07 +02:00

184 lines
5.9 KiB
TypeScript

import { db } from '@/lib/db'
import type { Prisma } from '@afis/db'
/**
* Das Zugriffsprotokoll lesen.
*
* Es ist die einzige Stelle im System, die vollständig Auskunft gibt: wer hat
* wann was getan, und bei Stufe 3 aufwärts auch, wer was nur gelesen hat. Nach
* Zusicherung 3 der README entsteht diese Spur bei jeder schreibenden
* Aktion, ohne Ausnahme und ohne Möglichkeit, sie zu unterdrücken.
*
* Lesen darf sie **nur die Abteilungsleitung**. Das ist keine Bequemlichkeit,
* sondern folgt aus dem, was drinsteht: 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 Sichtbarkeits-
* regeln von hinten aufzurollen.
*/
export const AUDIT_PAGE = 50
export type AuditFilter = {
actorId?: string
action?: string
targetType?: string
targetId?: string
from?: Date
to?: Date
/** Kennung der ältesten schon gezeigten Zeile, zum Weiterblättern. */
before?: bigint
}
export type AuditRow = {
id: string
at: Date
actorId: string
actorName: string
action: string
targetType: string
targetId: string
/** Aktenzeichen, Name oder Dokumentnummer — was man wiedererkennt. */
targetLabel: string | null
targetHref: string | null
clearanceAtTime: number
meta: Prisma.JsonValue
}
export async function queryAuditLog(
filter: AuditFilter,
): Promise<{ rows: AuditRow[]; more: boolean }> {
const where: Prisma.AuditLogWhereInput = {
...(filter.actorId ? { actorId: filter.actorId } : {}),
...(filter.action ? { action: filter.action } : {}),
...(filter.targetType ? { targetType: filter.targetType } : {}),
...(filter.targetId ? { targetId: filter.targetId } : {}),
...(filter.before ? { id: { lt: filter.before } } : {}),
...(filter.from || filter.to
? {
at: {
...(filter.from ? { gte: filter.from } : {}),
...(filter.to ? { lte: filter.to } : {}),
},
}
: {}),
}
// Eine Zeile mehr holen, als gezeigt wird: daran hängt der Knopf zum
// Weiterblättern, ohne dass eine zweite Zählabfrage nötig wäre.
const zeilen = await db.auditLog.findMany({
where,
orderBy: { id: 'desc' },
take: AUDIT_PAGE + 1,
})
const seite = zeilen.slice(0, AUDIT_PAGE)
const namen = await namenVon(seite.map((zeile) => zeile.actorId))
const ziele = await zieleVon(seite)
return {
rows: seite.map((zeile) => {
const ziel = ziele.get(`${zeile.targetType}:${zeile.targetId}`)
return {
id: zeile.id.toString(),
at: zeile.at,
actorId: zeile.actorId,
actorName: namen.get(zeile.actorId) ?? zeile.actorId,
action: zeile.action,
targetType: zeile.targetType,
targetId: zeile.targetId,
targetLabel: ziel?.label ?? null,
targetHref: ziel?.href ?? null,
clearanceAtTime: zeile.clearanceAtTime,
meta: zeile.meta,
}
}),
more: zeilen.length > AUDIT_PAGE,
}
}
async function namenVon(ids: string[]): Promise<Map<string, string>> {
if (ids.length === 0) return new Map()
const nutzer = await db.user.findMany({
where: { id: { in: [...new Set(ids)] } },
select: { id: true, displayName: true },
})
return new Map(nutzer.map((n) => [n.id, n.displayName]))
}
/**
* Aus Kennungen werden Aktenzeichen und Namen.
*
* Ohne das steht im Protokoll eine Spalte UUIDs, und niemand liest es. Der
* Nachschlag läuft **ohne** Sichtbarkeitsfilter — das Protokoll ist die
* Ausnahme, die es geben muss, damit die Leitung prüfen kann, ob die Regeln
* eingehalten wurden. Genau deshalb kommt hier auch nur die Leitung hin.
*/
async function zieleVon(
zeilen: { targetType: string; targetId: string }[],
): Promise<Map<string, { label: string; href: string | null }>> {
const je = (art: string): string[] => [
...new Set(zeilen.filter((z) => z.targetType === art).map((z) => z.targetId)),
]
const fallIds = je('Case')
const personIds = je('PersonnelFile')
const dokumentIds = je('Document')
const [faelle, personen, dokumente] = await Promise.all([
fallIds.length
? db.case.findMany({
where: { id: { in: fallIds } },
select: { id: true, caseNumber: true },
})
: Promise.resolve([]),
personIds.length
? db.personnelFile.findMany({
where: { id: { in: personIds } },
select: { id: true, displayName: true },
})
: Promise.resolve([]),
dokumentIds.length
? db.document.findMany({
where: { id: { in: dokumentIds } },
select: { id: true, documentNumber: true },
})
: Promise.resolve([]),
])
const karte = new Map<string, { label: string; href: string | null }>()
for (const akte of faelle) {
karte.set(`Case:${akte.id}`, {
label: akte.caseNumber,
href: `/faelle/${akte.caseNumber}`,
})
}
for (const person of personen) {
karte.set(`PersonnelFile:${person.id}`, {
label: person.displayName,
href: `/personalakten/${person.id}`,
})
}
for (const dokument of dokumente) {
karte.set(`Document:${dokument.id}`, {
label: dokument.documentNumber,
href: `/dokumente/${dokument.documentNumber}`,
})
}
return karte
}
/** Die Akteure, die überhaupt im Protokoll vorkommen — für die Auswahlliste. */
export async function auditActors(): Promise<{ id: string; name: string }[]> {
const gruppen = await db.auditLog.groupBy({ by: ['actorId'], _count: { actorId: true } })
const namen = await namenVon(gruppen.map((g) => g.actorId))
return gruppen
.map((g) => ({ id: g.actorId, name: namen.get(g.actorId) ?? g.actorId }))
.sort((a, b) => a.name.localeCompare(b.name, 'de'))
}
/** Die Aktionen, die tatsächlich vorkommen. Keine leeren Filtereinträge. */
export async function auditActions(): Promise<string[]> {
const gruppen = await db.auditLog.groupBy({ by: ['action'] })
return gruppen.map((g) => g.action).sort()
}