diff --git a/scripts/test-benachrichtigungen-inbox-audit.ts b/scripts/test-benachrichtigungen-inbox-audit.ts new file mode 100644 index 0000000..17e3837 --- /dev/null +++ b/scripts/test-benachrichtigungen-inbox-audit.ts @@ -0,0 +1,198 @@ +// L6 Benachrichtigungen & Audit — Posteingang (nur eigene, Mandantentrennung, Rollen), +// Einstellungen je Nutzer, Mandanten-Mailkonfiguration (tenant:manage, Validierung), +// Audit-Viewer (nur mit audit:read, mandantengebunden, Filter, Diff). +// +// Lauf: npx tsx scripts/test-benachrichtigungen-inbox-audit.ts + +import "dotenv/config"; +import { ZodError } from "zod"; +import { prisma, dbForTenant } from "../src/server/db"; +import { provisionTenant } from "../src/server/provision"; +import { ROLE_DEFS, type RoleKey } from "../src/server/rbac"; +import { ServiceError, type ServiceCtx } from "../src/server/services/context"; +import { bellSummary, listNotifications, markAllRead, markRead, safeLink } from "../src/server/services/notifications/inbox"; +import { getPreferences } from "../src/server/services/notifications/preferences"; +import { getMailSettings, splitAddressList, updateMailSettings } from "../src/server/services/notifications/mail-settings"; +import { diffAudit, getAuditEntry, queryAuditLog } from "../src/server/services/audit/viewer"; + +let failures = 0; +const ok = (cond: boolean, msg: string) => { + console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`); + if (!cond) failures++; +}; + +async function expectCode(fn: () => Promise, code: ServiceError["code"] | "zod", msg: string) { + try { + await fn(); + ok(false, `${msg} — kein Fehler`); + } catch (err) { + const actual = err instanceof ServiceError ? err.code : err instanceof ZodError ? "zod" : (err as Error).message; + ok(actual === code, `${msg}${actual === code ? "" : ` — erhalten: ${actual}`}`); + } +} + +const SLUG_A = "zz-l6-inbox-a"; +const SLUG_B = "zz-l6-inbox-b"; +const MAIL_DOMAIN = "zz-l6-inbox.test"; + +async function cleanup() { + const tenants = await prisma.tenant.findMany({ where: { slug: { in: [SLUG_A, SLUG_B] } }, select: { id: true } }); + const ids = tenants.map((t) => t.id); + if (ids.length) { + const where = { tenantId: { in: ids } }; + await prisma.notification.deleteMany({ where }); + await prisma.notificationPreference.deleteMany({ where }); + await prisma.mailLog.deleteMany({ where }); + await prisma.auditLog.deleteMany({ where }); + await prisma.tenantModule.deleteMany({ where }); + await prisma.tenantSettings.deleteMany({ where }); + await prisma.user.deleteMany({ where }); + await prisma.role.deleteMany({ where }); + await prisma.tenant.deleteMany({ where: { id: { in: ids } } }); + } + await prisma.identity.deleteMany({ where: { email: { endsWith: `@${MAIL_DOMAIN}` }, memberships: { none: {} } } }); +} + +async function createUser(tenantId: string, local: string, name: string, role: RoleKey) { + const email = `${local}@${MAIL_DOMAIN}`; + const identity = await prisma.identity.upsert({ where: { email }, update: {}, create: { email, passwordHash: "x" } }); + const roleRow = await prisma.role.findUniqueOrThrow({ where: { tenantId_key: { tenantId, key: role } } }); + return prisma.user.create({ + data: { tenantId, identityId: identity.id, email, name, status: "ACTIVE", userRoles: { create: [{ roleId: roleRow.id }] } }, + }); +} + +const ctxFor = (tenantId: string, userId: string, role: RoleKey): ServiceCtx => ({ + db: dbForTenant(tenantId), + tenantId, + userId, + permissions: new Set(ROLE_DEFS[role].permissions), +}); + +async function main() { + await cleanup(); + + const tA = await provisionTenant(prisma, { name: "L6 Inbox A", slug: SLUG_A, admin: { email: `admin-a@${MAIL_DOMAIN}`, name: "Admin A", password: "Zz-Test-1234!" } }); + const tB = await provisionTenant(prisma, { name: "L6 Inbox B", slug: SLUG_B, admin: { email: `admin-b@${MAIL_DOMAIN}`, name: "Admin B", password: "Zz-Test-1234!" } }); + const adminA = await prisma.user.findUniqueOrThrow({ where: { tenantId_email: { tenantId: tA.id, email: `admin-a@${MAIL_DOMAIN}` } } }); + const adminB = await prisma.user.findUniqueOrThrow({ where: { tenantId_email: { tenantId: tB.id, email: `admin-b@${MAIL_DOMAIN}` } } }); + const backoffice = await createUser(tA.id, "backoffice-a", "Bea Backoffice", "backoffice"); + const tech1 = await createUser(tA.id, "tech1-a", "Max Monteur", "technician"); + const tech2 = await createUser(tA.id, "tech2-a", "Ole Ohneteam", "technician"); + + const adminACtx = ctxFor(tA.id, adminA.id, "tenant-admin"); + const adminBCtx = ctxFor(tB.id, adminB.id, "tenant-admin"); + const boCtx = ctxFor(tA.id, backoffice.id, "backoffice"); + const tech1Ctx = ctxFor(tA.id, tech1.id, "technician"); + const tech2Ctx = ctxFor(tA.id, tech2.id, "technician"); + + const mk = (tenantId: string, userId: string, type: string, title: string, readAt: Date | null = null) => + prisma.notification.create({ data: { tenantId, userId, type, title, message: "Text", entityType: "work_order", entityId: "wo-zz", link: "/m/orders/wo-zz", readAt } }); + const n1 = await mk(tA.id, tech1.id, "work_order.assigned", "Neuer Auftrag A-1"); + await mk(tA.id, tech1.id, "work_order.changed", "Auftrag A-1 geändert"); + await mk(tA.id, tech1.id, "work_order.cancelled", "Auftrag A-2 storniert", new Date()); + const nB = await mk(tB.id, adminB.id, "work_order.assigned", "Fremd B-1"); + + // ---------- 1) Posteingang: nur eigene Benachrichtigungen ---------- + const list1 = await listNotifications(tech1Ctx, {}); + ok(list1.total === 3, "(1) Monteur sieht seine 3 Benachrichtigungen"); + ok((await listNotifications(tech1Ctx, { status: "unread" })).total === 2, "(1) Filter ungelesen"); + ok((await listNotifications(tech1Ctx, { status: "read" })).total === 1, "(1) Filter gelesen"); + ok((await listNotifications(tech1Ctx, { type: "work_order.changed" })).total === 1, "(1) Filter Typ"); + ok((await listNotifications(tech1Ctx, { type: "no.such_type", status: "bogus" })).total === 3, "(1) ungültige Filterwerte fallen auf Standard zurück"); + ok((await listNotifications(tech2Ctx, {})).total === 0, "(1) anderer Monteur sieht keine fremden Benachrichtigungen"); + ok((await listNotifications(adminACtx, {})).total === 0, "(1) auch der Mandantenadmin sieht keine fremden Benachrichtigungen"); + const bell = await bellSummary(tech1Ctx); + ok(bell.unread === 2 && bell.latest.length === 3, "(1) Glocke: Zähler ungelesen + letzte Einträge"); + + // ---------- 2) Rollen/Scope: fremde Benachrichtigung → not_found ---------- + await expectCode(() => markRead(tech2Ctx, n1.id), "not_found", "(2) Monteur ohne Zuordnung kann fremde Benachrichtigung nicht als gelesen markieren"); + await expectCode(() => markRead(adminBCtx, n1.id), "not_found", "(2) Mandant B kann Benachrichtigung von A nicht ändern"); + await expectCode(() => markRead(tech1Ctx, nB.id), "not_found", "(2) Mandant A kann Benachrichtigung von B nicht ändern"); + ok((await prisma.notification.findUniqueOrThrow({ where: { id: n1.id } })).readAt === null, "(2) fremder Zugriff hat nichts verändert"); + await expectCode(() => listNotifications({ ...tech1Ctx, permissions: new Set() }, {}), "forbidden", "(2) ohne notification:read → forbidden"); + + // ---------- 3) Mandantentrennung markAllRead ---------- + await markAllRead(adminBCtx); + ok((await prisma.notification.count({ where: { tenantId: tA.id, readAt: null } })) === 2, "(3) markAllRead in B lässt A unberührt"); + ok((await prisma.notification.findUniqueOrThrow({ where: { id: nB.id } })).readAt !== null, "(3) markAllRead in B markiert eigene"); + + // ---------- 4) markRead eigene + Audit + sicherer Link ---------- + const res = await markRead(tech1Ctx, n1.id); + ok(res.link === "/m/orders/wo-zz", "(4) markRead liefert relativen Link"); + ok((await prisma.notification.findUniqueOrThrow({ where: { id: n1.id } })).readAt !== null, "(4) markRead setzt readAt"); + ok((await prisma.auditLog.count({ where: { tenantId: tA.id, entity: "notification", entityId: n1.id, actorId: tech1.id } })) === 1, "(4) markRead schreibt Audit-Eintrag"); + ok(safeLink("https://evil.test") === null && safeLink("//evil.test") === null && safeLink("/\\evil") === null && safeLink("/work-orders/1") === "/work-orders/1", + "(4) safeLink lässt nur relative Pfade zu (kein Open Redirect)"); + + // ---------- 5) Einstellungen je Nutzer ---------- + const prefs = await getPreferences(tech1Ctx); + ok(prefs.every((p) => p.email) && prefs.filter((p) => p.mandatory).map((p) => p.type).sort().join() === "emergency.completed,emergency.created", + "(5) Standard: alles an, Notdienst als Pflicht markiert"); + + // ---------- 6) Mandanten-Mailkonfiguration ---------- + await expectCode(() => updateMailSettings(tech1Ctx, { mailFromName: "X", mailReplyTo: "", emergencyRecipients: "", billingRecipients: "" }), "forbidden", + "(6) Monteur darf Mailkonfiguration nicht ändern"); + await expectCode(() => updateMailSettings(boCtx, { mailFromName: "X", mailReplyTo: "", emergencyRecipients: "", billingRecipients: "" }), "forbidden", + "(6) Backoffice ohne tenant:manage darf Mailkonfiguration nicht ändern"); + await expectCode(() => getMailSettings(tech1Ctx), "forbidden", "(6) Monteur darf Mailkonfiguration nicht lesen"); + ok(splitAddressList("A@x.de, b@x.de;\nA@x.de c@x.de").join() === "a@x.de,b@x.de,c@x.de", "(6) Adressliste: trennt, normalisiert, dedupliziert"); + const saved = await updateMailSettings(adminACtx, { + mailFromName: "Musterbau Büro", + mailReplyTo: "Buero@Musterbau.test", + emergencyRecipients: "notdienst@musterbau.test\nchef@musterbau.test", + billingRecipients: "abrechnung@musterbau.test", + }); + ok(saved.mailReplyTo === "buero@musterbau.test" && saved.emergencyRecipients.length === 2, "(6) Admin speichert Mailkonfiguration"); + const rowA = await prisma.tenantSettings.findUniqueOrThrow({ where: { tenantId: tA.id } }); + ok(rowA.mailFromName === "Musterbau Büro" && rowA.billingRecipients.join() === "abrechnung@musterbau.test", "(6) Werte in tenant_settings persistiert"); + ok((await prisma.auditLog.count({ where: { tenantId: tA.id, entity: "tenant_mail_settings", actorId: adminA.id } })) === 1, "(6) Audit-Eintrag mit before/after"); + const sB = await getMailSettings(adminBCtx); + ok(sB.emergencyRecipients.length === 0 && sB.mailFromName === null, "(6) Mandant B sieht die Mailkonfiguration von A nicht"); + await expectCode(() => updateMailSettings(adminACtx, { mailFromName: "Evil\r\nBcc: x@y.z", mailReplyTo: "", emergencyRecipients: "", billingRecipients: "" }), "zod", + "(6) Absendername mit Zeilenumbruch abgelehnt (Header-Injection)"); + await expectCode(() => updateMailSettings(adminACtx, { mailFromName: "", mailReplyTo: "", emergencyRecipients: "kein-mail", billingRecipients: "" }), "zod", + "(6) ungültige Empfängeradresse abgelehnt"); + await expectCode(() => updateMailSettings(adminACtx, { mailFromName: "", mailReplyTo: "", emergencyRecipients: Array.from({ length: 21 }, (_, i) => `n${i}@x.de`).join("\n"), billingRecipients: "" }), "zod", + "(6) mehr als 20 Empfänger abgelehnt"); + + // ---------- 7) Audit-Viewer ---------- + await prisma.auditLog.create({ data: { tenantId: tB.id, actorId: adminB.id, action: "update", entity: "zz_marker", entityId: "b-secret", before: { a: 1 }, after: { a: 2 } } }); + const markerA = await prisma.auditLog.create({ + data: { tenantId: tA.id, actorId: backoffice.id, action: "update", entity: "work_order", entityId: "wo-zz-audit", before: { status: "planned", title: "Alt" }, after: { status: "assigned", title: "Alt", team: "Nord" } }, + }); + await expectCode(() => queryAuditLog(tech1Ctx, {}), "forbidden", "(7) Monteur ohne audit:read → forbidden"); + await expectCode(() => queryAuditLog(ctxFor(tA.id, tech2.id, "team-lead"), {}), "forbidden", "(7) Teamleiter ohne audit:read → forbidden"); + await expectCode(() => getAuditEntry(tech1Ctx, markerA.id), "forbidden", "(7) Detail ohne audit:read → forbidden"); + const all = await queryAuditLog(boCtx, {}); + ok(all.total > 0, "(7) Backoffice mit audit:read sieht Einträge"); + const allIds = (await queryAuditLog(boCtx, { page: 1 })).rows.map((r) => r.id); + const foreign = await prisma.auditLog.count({ where: { id: { in: allIds }, NOT: { tenantId: tA.id } } }); + ok(foreign === 0, "(7) Audit-Viewer liefert ausschließlich Einträge des eigenen Mandanten"); + ok((await queryAuditLog(boCtx, { entity: "zz_marker" })).total === 0, "(7) Eintrag von Mandant B ist in A nicht auffindbar"); + const bEntry = await prisma.auditLog.findFirstOrThrow({ where: { tenantId: tB.id, entity: "zz_marker" } }); + await expectCode(() => getAuditEntry(boCtx, bEntry.id), "not_found", "(7) Detail eines B-Eintrags aus A → not_found"); + ok((await queryAuditLog(boCtx, { entity: "work_order", entityId: "wo-zz" })).total === 1, "(7) Filter Objektart + Objekt-ID"); + ok((await queryAuditLog(boCtx, { actorId: backoffice.id, action: "update" })).rows.every((r) => r.actorId === backoffice.id && r.action === "update"), "(7) Filter Benutzer + Aktion"); + const today = new Date().toISOString().slice(0, 10); + ok((await queryAuditLog(boCtx, { from: today, to: today, entity: "work_order" })).total === 1, "(7) Filter Zeitraum (heute) findet Eintrag"); + ok((await queryAuditLog(boCtx, { from: "2000-01-01", to: "2000-01-02" })).total === 0, "(7) Filter Zeitraum (Vergangenheit) leer"); + ok(all.facets.entities.includes("work_order") && !all.facets.entities.includes("zz_marker"), "(7) Objektart-Auswahl nur aus eigenem Mandanten"); + const detail = await getAuditEntry(boCtx, markerA.id); + const byKey = Object.fromEntries(detail.diff.map((d) => [d.key, d])); + ok(byKey.status?.changed === true && byKey.title?.changed === false && byKey.team?.before === null && byKey.team?.after === "Nord", "(7) Detail: before/after-Diff korrekt"); + ok(detail.actorName === "Bea Backoffice" && detail.ip === null && detail.userAgent === null, "(7) Detail: Akteurname, IP/User-Agent nicht erfasst"); + ok(diffAudit(undefined, undefined).length === 0 && diffAudit(null, { x: [1] })[0]?.after === "[1]", "(7) diffAudit: leere und verschachtelte Werte"); +} + +main() + .catch((err) => { + console.error(err); + failures++; + }) + .finally(async () => { + await cleanup().catch((e) => console.error("cleanup:", e)); + await prisma.$disconnect(); + console.log(failures ? `\n${failures} Fehler.` : "\nAlle Prüfungen bestanden."); + process.exit(failures ? 1 : 0); + }); diff --git a/src/app/(app)/settings/audit/page.tsx b/src/app/(app)/settings/audit/page.tsx new file mode 100644 index 0000000..e0ebfc5 --- /dev/null +++ b/src/app/(app)/settings/audit/page.tsx @@ -0,0 +1,201 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { ArrowLeft, CircleCheck, CircleX } from "lucide-react"; +import { getFormatter, getTranslations } from "next-intl/server"; +import { Modal } from "@/components/modal"; +import { PageHead, Pill } from "@/components/mockup-ui"; +import { Button } from "@/components/ui/button"; +import { getAuditEntry, queryAuditLog, type AuditFilter } from "@/server/services/audit/viewer"; +import { ServiceError } from "@/server/services/context"; +import { pageCtx } from "@/server/services/notifications/page-ctx"; + +const fieldCls = "h-11 rounded-md border border-input bg-card px-3 text-sm"; + +type SP = Record; + +export default async function AuditLogPage({ searchParams }: { searchParams: Promise }) { + const ctx = await pageCtx(); + if (!ctx.permissions.has("audit:read")) redirect("/dashboard"); + const sp = await searchParams; + const one = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v); + + const data = await queryAuditLog(ctx, { + from: one(sp.from), to: one(sp.to), actorId: one(sp.actorId), action: one(sp.action), + entity: one(sp.entity), entityId: one(sp.entityId), page: one(sp.page), + }); + const detailId = one(sp.detail); + let detail: Awaited> | null = null; + if (detailId) { + try { + detail = await getAuditEntry(ctx, detailId); + } catch (err) { + if (!(err instanceof ServiceError)) throw err; + } + } + + const [t, format] = await Promise.all([getTranslations("notifications"), getFormatter()]); + const actorNames = new Map(data.actors.map((a) => [a.id, a.name])); + const actionLabel = (a: string) => (t.has(`audit.actions.${a}`) ? t(`audit.actions.${a}`) : a); + const entityLabel = (e: string) => (t.has(`audit.entities.${e}`) ? t(`audit.entities.${e}`) : e); + + const href = (patch: Partial>) => { + const q = new URLSearchParams(); + const merged = { ...data.filter, detail: undefined, ...patch } as Record; + for (const [k, v] of Object.entries(merged)) if (v !== undefined && v !== "" && !(k === "page" && v === 1)) q.set(k, String(v)); + const s = q.toString(); + return `/settings/audit${s ? `?${s}` : ""}`; + }; + const f = data.filter; + + return ( +
+ + {t("audit.back")} + + + +
+ + + + + + +
+ + + {t("audit.reset")} + +
+
+ +

{t("audit.total", { count: data.total })}

+ + {data.rows.length === 0 ? ( +

{t("audit.empty")}

+ ) : ( +
+ + + + + + + + + + + + + {data.rows.map((r) => ( + + + + + + + + + ))} + +
{t("audit.time")}{t("audit.actor")}{t("audit.action")}{t("audit.object")}{t("audit.result")}{t("audit.details")}
+ {format.dateTime(r.createdAt, { dateStyle: "medium", timeStyle: "medium" })} + {r.actorId ? (actorNames.get(r.actorId) ?? r.actorId.slice(0, 8)) : t("audit.system")}{actionLabel(r.action)} + {entityLabel(r.entity)} + {r.entityId && #{r.entityId.slice(0, 12)}} + {r.scope === "platform" && · {t("audit.platform")}} + + {r.action === "denied" ? ( + {t("audit.resultDenied")} + ) : ( + {t("audit.resultOk")} + )} + + + {t("audit.details")} + +
+
+ )} + + {data.pages > 1 && ( + + )} + + {detail && ( + +
+
+
{t("audit.time")}
{format.dateTime(detail.createdAt, { dateStyle: "medium", timeStyle: "medium" })}
+
{t("audit.actor")}
{detail.actorName ?? (detail.actorId ?? t("audit.system"))}
+
{t("audit.entity")}
{entityLabel(detail.entity)}{detail.scope === "platform" ? ` · ${t("audit.platform")}` : ""}
+
{t("audit.entityId")}
{detail.entityId ?? "—"}
+
{t("audit.result")}
{detail.action === "denied" ? t("audit.resultDenied") : t("audit.resultOk")}
+
{t("audit.ip")}
{detail.ip ?? t("audit.notCaptured")}
+
{t("audit.userAgent")}
{detail.userAgent ?? t("audit.notCaptured")}
+
+ + {detail.diff.length === 0 ? ( +

{t("audit.noValues")}

+ ) : ( +
+ + + + + + + + + + {detail.diff.map((d) => ( + + + + + + ))} + +
{t("audit.field")}{t("audit.before")}{t("audit.after")}
+ {d.key} + {d.changed && {t("audit.changed")}} + {d.before ?? "—"}{d.after ?? "—"}
+
+ )} +
+
+ )} +
+ ); +} diff --git a/src/components/audit-trail.tsx b/src/components/audit-trail.tsx index b9d10d6..6d4fadc 100644 --- a/src/components/audit-trail.tsx +++ b/src/components/audit-trail.tsx @@ -65,6 +65,17 @@ const ENTITY_LABEL: Record = { import: "Auftragsimport", report: "Bericht", emergency: "Notdienst", + signature: "Unterschrift", + import_job: "Auftragsimport", + material_usage: "Material", + time_entry: "Arbeitszeit", + work_session: "Einsatzzeit", + photo: "Foto", + voice_note: "Sprachnotiz", + notification: "Benachrichtigung", + notification_settings: "Benachrichtigungseinstellungen", + tenant_mail_settings: "E-Mail-Versand", + sync_operation: "Synchronisation", document: "Dokument", }; diff --git a/src/server/services/audit/viewer.ts b/src/server/services/audit/viewer.ts new file mode 100644 index 0000000..e91d53d --- /dev/null +++ b/src/server/services/audit/viewer.ts @@ -0,0 +1,128 @@ +import type { Prisma } from "@prisma/client"; +import { z } from "zod"; +import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context"; + +/** + * Read-only audit log viewer (spec §26). Requires `audit:read`; always tenant-bound via ctx.db + * (the tenant client filters AuditLog by tenantId). Audit rows are never modified here. + */ + +export const AUDIT_PAGE_SIZE = 50; + +const dateStr = z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional() + .catch(undefined); +const optStr = (max: number) => + z + .string() + .trim() + .max(max) + .transform((v) => v || undefined) + .optional() + .catch(undefined); + +export const auditFilterSchema = z.object({ + from: dateStr, + to: dateStr, + actorId: optStr(64), + action: optStr(40), + entity: optStr(60), + entityId: optStr(128), + page: z.coerce.number().int().min(1).max(100_000).catch(1), +}); +export type AuditFilter = z.infer; + +export function auditWhere(f: AuditFilter): Prisma.AuditLogWhereInput { + const createdAt: Prisma.DateTimeFilter = {}; + if (f.from) createdAt.gte = new Date(`${f.from}T00:00:00.000Z`); + if (f.to) createdAt.lt = new Date(new Date(`${f.to}T00:00:00.000Z`).getTime() + 86_400_000); + return { + ...(f.from || f.to ? { createdAt } : {}), + ...(f.actorId ? { actorId: f.actorId } : {}), + ...(f.action ? { action: f.action } : {}), + ...(f.entity ? { entity: f.entity } : {}), + ...(f.entityId ? { entityId: { contains: f.entityId } } : {}), + }; +} + +export async function queryAuditLog(ctx: ServiceCtx, input: unknown) { + assertCan(ctx, "audit:read"); + const filter = auditFilterSchema.parse(input ?? {}); + const where = auditWhere(filter); + const [rows, total, actions, entities, users] = await Promise.all([ + ctx.db.auditLog.findMany({ + where, + orderBy: { createdAt: "desc" }, + skip: (filter.page - 1) * AUDIT_PAGE_SIZE, + take: AUDIT_PAGE_SIZE, + select: { id: true, createdAt: true, actorId: true, action: true, entity: true, entityId: true, scope: true }, + }), + ctx.db.auditLog.count({ where }), + ctx.db.auditLog.findMany({ distinct: ["action"], select: { action: true }, orderBy: { action: "asc" } }), + ctx.db.auditLog.findMany({ distinct: ["entity"], select: { entity: true }, orderBy: { entity: "asc" } }), + ctx.db.user.findMany({ select: { id: true, name: true }, orderBy: { name: "asc" } }), + ]); + return { + rows, + total, + page: filter.page, + pages: Math.max(1, Math.ceil(total / AUDIT_PAGE_SIZE)), + filter, + facets: { actions: actions.map((a) => a.action), entities: entities.map((e) => e.entity) }, + actors: users, + }; +} + +export type DiffRow = { key: string; before: string | null; after: string | null; changed: boolean }; + +function show(v: unknown): string | null { + if (v === undefined) return null; + if (v === null) return "null"; + return typeof v === "string" ? v : JSON.stringify(v); +} + +/** Field-wise before/after comparison (top-level keys; nested values rendered as JSON). */ +export function diffAudit(before: unknown, after: unknown): DiffRow[] { + const b = before && typeof before === "object" && !Array.isArray(before) ? (before as Record) : null; + const a = after && typeof after === "object" && !Array.isArray(after) ? (after as Record) : null; + if (!b && !a) { + if (before === undefined && after === undefined) return []; + if (before == null && after == null) return []; + return [{ key: "value", before: show(before ?? undefined), after: show(after ?? undefined), changed: show(before) !== show(after) }]; + } + const keys = [...new Set([...Object.keys(b ?? {}), ...Object.keys(a ?? {})])].sort(); + return keys.map((key) => { + const bv = show(b?.[key]); + const av = show(a?.[key]); + return { key, before: bv, after: av, changed: bv !== av }; + }); +} + +/** Extract request metadata if a writer stored it (writeAuditLog does not capture it yet). */ +function requestMeta(...sources: unknown[]): { ip: string | null; userAgent: string | null } { + for (const s of sources) { + if (s && typeof s === "object") { + const o = s as Record; + const ip = typeof o.ip === "string" ? o.ip : null; + const userAgent = typeof o.userAgent === "string" ? o.userAgent : null; + if (ip || userAgent) return { ip, userAgent }; + } + } + return { ip: null, userAgent: null }; +} + +export async function getAuditEntry(ctx: ServiceCtx, id: unknown) { + assertCan(ctx, "audit:read"); + const entryId = z.string().min(1).max(64).parse(id); + const row = await ctx.db.auditLog.findFirst({ where: { id: entryId } }); + if (!row) throw new ServiceError("not_found", "audit entry not found"); + const actor = row.actorId ? await ctx.db.user.findFirst({ where: { id: row.actorId }, select: { name: true } }) : null; + return { + ...row, + actorName: actor?.name ?? null, + diff: diffAudit(row.before ?? undefined, row.after ?? undefined), + ...requestMeta(row.after, row.before), + }; +}