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> { 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> { 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() 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 { const gruppen = await db.auditLog.groupBy({ by: ['action'] }) return gruppen.map((g) => g.action).sort() }