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
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die README; an keiner Stelle Bedeutung, nur Zeichensetzung.
184 lines
5.9 KiB
TypeScript
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()
|
|
}
|