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.
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
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
|
||||
* harter Regel 3 aus CLAUDE.md 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()
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { db } from '@/lib/db'
|
||||
import { writeAudit } from '@/lib/audit'
|
||||
import { writeAudit } from '@/lib/audit/write'
|
||||
import { visibleEntryWhere } from '@/lib/access/caseFilter'
|
||||
import { canEditCase } from '@/lib/access/caseWrite'
|
||||
import { AccessError } from '@/lib/cases/service'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { db } from '@/lib/db'
|
||||
import { writeAudit } from '@/lib/audit'
|
||||
import { writeAudit } from '@/lib/audit/write'
|
||||
import { visibleCaseWhere } from '@/lib/access/caseFilter'
|
||||
import { canEditCase } from '@/lib/access/caseWrite'
|
||||
import { AccessError, must } from '@/lib/errors'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { db } from '@/lib/db'
|
||||
import { writeAudit } from '@/lib/audit'
|
||||
import { writeAudit } from '@/lib/audit/write'
|
||||
import { visibleCaseWhere } from '@/lib/access/caseFilter'
|
||||
import { visiblePersonnelWhere } from '@/lib/access/personnelFilter'
|
||||
import { canEditCase } from '@/lib/access/caseWrite'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { db } from '@/lib/db'
|
||||
import { writeAudit } from '@/lib/audit'
|
||||
import { writeAudit } from '@/lib/audit/write'
|
||||
import { canEditCase } from '@/lib/access/caseWrite'
|
||||
import { loadVisibleCase } from '@/lib/cases/service'
|
||||
import { AccessError } from '@/lib/errors'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { db } from '@/lib/db'
|
||||
import { writeAudit } from '@/lib/audit'
|
||||
import { writeAudit } from '@/lib/audit/write'
|
||||
import { visibleCaseWhere } from '@/lib/access/caseFilter'
|
||||
import { canCreateCase, canEditCase, canReclassify } from '@/lib/access/caseWrite'
|
||||
import { nextNumber } from '@/lib/numbering'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { db } from '@/lib/db'
|
||||
import { writeAudit } from '@/lib/audit'
|
||||
import { writeAudit } from '@/lib/audit/write'
|
||||
import { AccessError, must } from '@/lib/errors'
|
||||
import { visibleCaseWhere } from '@/lib/access/caseFilter'
|
||||
import { visibleMessageWhere } from '@/lib/access/chatFilter'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { db } from '@/lib/db'
|
||||
import { writeAudit } from '@/lib/audit'
|
||||
import { writeAudit } from '@/lib/audit/write'
|
||||
import { visibleDocumentWhere, visiblePersonnelWhere } from '@/lib/access/personnelFilter'
|
||||
import { visibleCaseWhere } from '@/lib/access/caseFilter'
|
||||
import {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { db } from '@/lib/db'
|
||||
import { writeAudit } from '@/lib/audit'
|
||||
import { writeAudit } from '@/lib/audit/write'
|
||||
import { AccessError } from '@/lib/errors'
|
||||
import type { EffectivePermissions } from '@/lib/auth/permissions'
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
import { db } from '@/lib/db'
|
||||
import { getEnv } from '@/lib/env'
|
||||
import { writeAudit, READ_AUDIT_FROM_LEVEL } from '@/lib/audit'
|
||||
import { writeAudit, READ_AUDIT_FROM_LEVEL } from '@/lib/audit/write'
|
||||
import { AccessError } from '@/lib/errors'
|
||||
import { loadVisibleCase } from '@/lib/cases/service'
|
||||
import { loadVisibleDocument } from '@/lib/documents/service'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
import { db } from '@/lib/db'
|
||||
import { writeAudit } from '@/lib/audit'
|
||||
import { writeAudit } from '@/lib/audit/write'
|
||||
import { AccessError, must } from '@/lib/errors'
|
||||
import { visibleAttachmentWhere } from '@/lib/access/attachmentFilter'
|
||||
import { canEditCase } from '@/lib/access/caseWrite'
|
||||
|
||||
@@ -1,3 +1,78 @@
|
||||
export const auditText = {
|
||||
heading: 'Protokoll',
|
||||
intro:
|
||||
'Wer was wo getan hat. Jede schreibende Aktion steht hier, dazu jeder Lesezugriff ab Stufe 3. Einträge lassen sich nicht ändern und nicht entfernen.',
|
||||
when: 'Zeitpunkt',
|
||||
who: 'Wer',
|
||||
what: 'Was',
|
||||
where: 'Wo',
|
||||
clearance: 'Stufe',
|
||||
filterActor: 'Person',
|
||||
filterAction: 'Aktion',
|
||||
filterTarget: 'Art',
|
||||
filterFrom: 'Ab',
|
||||
filterTo: 'Bis',
|
||||
apply: 'Filtern',
|
||||
reset: 'Alles zeigen',
|
||||
all: 'Alle',
|
||||
empty: 'Für diesen Ausschnitt steht nichts im Protokoll.',
|
||||
older: 'Ältere anzeigen',
|
||||
gone: 'nicht mehr auffindbar',
|
||||
ofUser: 'Nur von dieser Person',
|
||||
ofTarget: 'Nur zu diesem Vorgang',
|
||||
actions: {
|
||||
'case.read': 'Fallakte gelesen',
|
||||
'case.create': 'Fallakte angelegt',
|
||||
'case.update': 'Fallakte geändert',
|
||||
'case.reclassify': 'Fallakte umklassifiziert',
|
||||
'case.status': 'Status geändert',
|
||||
'entry.create': 'Vermerk angelegt',
|
||||
'entry.edit': 'Vermerk geändert',
|
||||
'entry.retract': 'Vermerk zurückgezogen',
|
||||
'entry.restore': 'Vermerk wiederhergestellt',
|
||||
'case.participant.add': 'Beteiligten eingetragen',
|
||||
'case.participant.remove': 'Beteiligten ausgetragen',
|
||||
'case.link': 'Akten verknüpft',
|
||||
'case.unlink': 'Verknüpfung gelöst',
|
||||
'personnel.read': 'Personalakte gelesen',
|
||||
'personnel.create': 'Personalakte angelegt',
|
||||
'personnel.update': 'Personalakte geändert',
|
||||
'personnel.reclassify': 'Personalakte umklassifiziert',
|
||||
'personnel.assign': 'Person zugeordnet',
|
||||
'personnel.unassign': 'Zuordnung aufgehoben',
|
||||
'document.read': 'Dokument gelesen',
|
||||
'document.create': 'Dokument angelegt',
|
||||
'document.update': 'Dokument geändert',
|
||||
'document.finalize': 'Dokument abgeschlossen',
|
||||
'document.retract': 'Dokument zurückgezogen',
|
||||
'document.reclassify': 'Dokument umklassifiziert',
|
||||
'attachment.upload': 'Datei hochgeladen',
|
||||
'attachment.read': 'Datei abgerufen',
|
||||
'attachment.release': 'Datei freigegeben',
|
||||
'chat.adopt': 'Nachricht als Vermerk übernommen',
|
||||
'roles.update': 'Rollenzuordnung geändert',
|
||||
'export.pdf': 'PDF erzeugt',
|
||||
'export.approve': 'Export freigegeben',
|
||||
'export.reject': 'Export abgelehnt',
|
||||
'export.download': 'Export heruntergeladen',
|
||||
'export.database': 'Datenbankabzug erzeugt',
|
||||
} as Record<string, string>,
|
||||
targets: {
|
||||
Case: 'Fallakte',
|
||||
CaseEntry: 'Vermerk',
|
||||
CaseSection: 'Abschnitt',
|
||||
PersonnelFile: 'Personalakte',
|
||||
Document: 'Dokument',
|
||||
DocumentSection: 'Dokumentabschnitt',
|
||||
Attachment: 'Datei',
|
||||
ChatMessage: 'Nachricht',
|
||||
ExportJob: 'Export',
|
||||
RoleMapping: 'Rollenzuordnung',
|
||||
ChangePolicy: 'Änderungsrichtlinie',
|
||||
Database: 'Datenbank',
|
||||
} as Record<string, string>,
|
||||
} as const
|
||||
|
||||
export const adminText = {
|
||||
heading: 'Admin',
|
||||
intro: 'Rollen, Vorlagen, Regeln und das Protokoll der Abteilung.',
|
||||
|
||||
@@ -64,9 +64,16 @@ export const sectionText = {
|
||||
heading: 'Abschnitte',
|
||||
empty: 'Die Vorlage dieser Fallart hat keine Abschnitte.',
|
||||
save: 'Abschnitt speichern',
|
||||
saveAll: 'Abschnitte speichern',
|
||||
saving: 'Wird gespeichert',
|
||||
saved: 'Gespeichert',
|
||||
draftKept: 'Entwurf lokal gesichert',
|
||||
unchanged: 'Nichts geändert',
|
||||
changedCount: (n: number) => (n === 1 ? '1 Feld geändert' : `${n} Felder geändert`),
|
||||
savedCount: (n: number) => (n === 1 ? '1 Feld gespeichert' : `${n} Felder gespeichert`),
|
||||
partly: (gespeichert: number, offen: number) =>
|
||||
`${gespeichert} gespeichert, ${offen} offen. Die offenen stehen unten mit beiden Fassungen.`,
|
||||
discard: 'Änderungen verwerfen',
|
||||
version: (v: number) => `v${v}`,
|
||||
history: 'Fassungen',
|
||||
conflict:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Aktenchat und freie Kanäle.
|
||||
* Der Chat einer Akte.
|
||||
*
|
||||
* Eigene Datei und nicht bei den Fallakten, obwohl der Aktenchat an einer
|
||||
* Akte hängt: die Fallakte lädt ihre Texte sofort, den Chat erst beim
|
||||
|
||||
@@ -43,6 +43,7 @@ import { documentText, attachmentText } from '@/lib/i18n/documents'
|
||||
import { exportText } from '@/lib/i18n/export'
|
||||
import {
|
||||
adminText,
|
||||
auditText,
|
||||
roleAdminText,
|
||||
templateAdminText,
|
||||
settingsText,
|
||||
@@ -55,6 +56,7 @@ export const de = {
|
||||
auth: authText,
|
||||
overview: overviewText,
|
||||
admin: adminText,
|
||||
audit: auditText,
|
||||
roleAdmin: roleAdminText,
|
||||
clearance: clearanceText,
|
||||
case: caseText,
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
|
||||
export const documentText = {
|
||||
saveAll: 'Dokument speichern',
|
||||
one: 'Dokument',
|
||||
many: 'Dokumente',
|
||||
number: 'Dokumentnummer',
|
||||
|
||||
@@ -179,6 +179,8 @@ export const paletteText = {
|
||||
{ href: '/faelle/neu', label: 'Neue Fallakte' },
|
||||
{ href: '/personalakten', label: 'Personalakten' },
|
||||
{ href: '/suche', label: 'Suche' },
|
||||
{ href: '/admin', label: 'Admin' },
|
||||
{ href: '/admin/protokoll', label: 'Protokoll' },
|
||||
{ href: '/einstellungen', label: 'Benachrichtigungen' },
|
||||
],
|
||||
} as const
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { db } from '@/lib/db'
|
||||
import { READ_AUDIT_FROM_LEVEL } from '@/lib/audit'
|
||||
import { READ_AUDIT_FROM_LEVEL } from '@/lib/audit/write'
|
||||
import { visibleCaseWhere } from '@/lib/access/caseFilter'
|
||||
import { visiblePersonnelWhere } from '@/lib/access/personnelFilter'
|
||||
import { resolvePermissions } from '@/lib/auth/effective'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { db } from '@/lib/db'
|
||||
import { writeAudit } from '@/lib/audit'
|
||||
import { writeAudit } from '@/lib/audit/write'
|
||||
import { visibleCaseWhere } from '@/lib/access/caseFilter'
|
||||
import { visiblePersonnelWhere } from '@/lib/access/personnelFilter'
|
||||
import { canWritePersonnel } from '@/lib/access/documentWrite'
|
||||
|
||||
Reference in New Issue
Block a user