L6 Benachrichtigungen & Audit: Audit-Viewer mit Filter und Diff

- /settings/audit (audit:read): Filter Zeitraum, Benutzer, Aktion, Objektart,
  Objekt-ID; Pagination; Detail-Popup mit before/after-Diff, Ergebnis, IP/User-Agent
  (derzeit nicht erfasst, Fundament-Bedarf).
- Service services/audit/viewer.ts, strikt mandantengebunden über ctx.db.
- Audit-Entity-Labels für alle Craftvia-Entitäten.
- Test scripts/test-benachrichtigungen-inbox-audit.ts (46 Prüfungen): Posteingang,
  Mandantentrennung, Rollen/Scope, Mailkonfiguration, Audit-Viewer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 12:12:51 +02:00
co-authored by Claude Opus 5
parent 879012415e
commit dfbc92bb4d
4 changed files with 538 additions and 0 deletions
@@ -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<unknown>, 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);
});
+201
View File
@@ -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<string, string | string[] | undefined>;
export default async function AuditLogPage({ searchParams }: { searchParams: Promise<SP> }) {
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<ReturnType<typeof getAuditEntry>> | 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<Record<keyof AuditFilter | "detail", string | number | undefined>>) => {
const q = new URLSearchParams();
const merged = { ...data.filter, detail: undefined, ...patch } as Record<string, string | number | undefined>;
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 (
<main className="flex-1 p-4 md:p-6">
<Link href="/settings" className="inline-flex min-h-11 items-center gap-1.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground">
<ArrowLeft className="size-4" aria-hidden /> {t("audit.back")}
</Link>
<PageHead crumb={t("audit.crumb")} title={t("audit.title")} sub={t("audit.sub")} />
<form method="get" className="shadow-card mb-4 grid gap-3 rounded-xl border bg-card p-4 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-7">
<label className="flex flex-col gap-1 text-[12px] font-semibold text-muted-foreground">
{t("audit.from")}
<input type="date" name="from" defaultValue={f.from ?? ""} className={fieldCls} />
</label>
<label className="flex flex-col gap-1 text-[12px] font-semibold text-muted-foreground">
{t("audit.to")}
<input type="date" name="to" defaultValue={f.to ?? ""} className={fieldCls} />
</label>
<label className="flex flex-col gap-1 text-[12px] font-semibold text-muted-foreground">
{t("audit.actor")}
<select name="actorId" defaultValue={f.actorId ?? ""} className={fieldCls}>
<option value="">{t("audit.actorAll")}</option>
{data.actors.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
</select>
</label>
<label className="flex flex-col gap-1 text-[12px] font-semibold text-muted-foreground">
{t("audit.action")}
<select name="action" defaultValue={f.action ?? ""} className={fieldCls}>
<option value="">{t("audit.actionAll")}</option>
{data.facets.actions.map((a) => <option key={a} value={a}>{actionLabel(a)}</option>)}
</select>
</label>
<label className="flex flex-col gap-1 text-[12px] font-semibold text-muted-foreground">
{t("audit.entity")}
<select name="entity" defaultValue={f.entity ?? ""} className={fieldCls}>
<option value="">{t("audit.entityAll")}</option>
{data.facets.entities.map((e) => <option key={e} value={e}>{entityLabel(e)}</option>)}
</select>
</label>
<label className="flex flex-col gap-1 text-[12px] font-semibold text-muted-foreground">
{t("audit.entityId")}
<input name="entityId" defaultValue={f.entityId ?? ""} className={fieldCls} />
</label>
<div className="flex items-end gap-2">
<Button type="submit" className="min-h-11">{t("audit.apply")}</Button>
<Link href="/settings/audit" className="flex min-h-11 items-center px-2 text-[13px] font-semibold text-muted-foreground hover:text-foreground">
{t("audit.reset")}
</Link>
</div>
</form>
<p className="mb-2 text-[12.5px] text-muted-foreground">{t("audit.total", { count: data.total })}</p>
{data.rows.length === 0 ? (
<p className="shadow-card rounded-xl border bg-card p-6 text-sm text-muted-foreground">{t("audit.empty")}</p>
) : (
<div className="shadow-card overflow-x-auto rounded-xl border bg-card">
<table className="w-full min-w-[720px] text-sm">
<thead>
<tr className="border-b text-left text-[12px] text-muted-foreground">
<th className="px-4 py-2.5 font-semibold">{t("audit.time")}</th>
<th className="px-4 py-2.5 font-semibold">{t("audit.actor")}</th>
<th className="px-4 py-2.5 font-semibold">{t("audit.action")}</th>
<th className="px-4 py-2.5 font-semibold">{t("audit.object")}</th>
<th className="px-4 py-2.5 font-semibold">{t("audit.result")}</th>
<th className="px-4 py-2.5"><span className="sr-only">{t("audit.details")}</span></th>
</tr>
</thead>
<tbody>
{data.rows.map((r) => (
<tr key={r.id} className="border-b align-top last:border-0">
<td className="px-4 py-2 text-xs whitespace-nowrap text-muted-foreground">
{format.dateTime(r.createdAt, { dateStyle: "medium", timeStyle: "medium" })}
</td>
<td className="px-4 py-2">{r.actorId ? (actorNames.get(r.actorId) ?? r.actorId.slice(0, 8)) : t("audit.system")}</td>
<td className="px-4 py-2">{actionLabel(r.action)}</td>
<td className="px-4 py-2">
<span className="font-medium">{entityLabel(r.entity)}</span>
{r.entityId && <span className="ml-1 font-mono text-[11px] text-muted-foreground">#{r.entityId.slice(0, 12)}</span>}
{r.scope === "platform" && <span className="ml-1 text-[11px] text-muted-foreground">· {t("audit.platform")}</span>}
</td>
<td className="px-4 py-2">
{r.action === "denied" ? (
<Pill tone="risk"><CircleX className="size-3.5" aria-hidden /> {t("audit.resultDenied")}</Pill>
) : (
<Pill tone="ok"><CircleCheck className="size-3.5" aria-hidden /> {t("audit.resultOk")}</Pill>
)}
</td>
<td className="px-4 py-2 text-right">
<Link href={href({ detail: r.id, page: data.page })} scroll={false} className="inline-flex min-h-11 items-center font-semibold text-[var(--primary)]">
{t("audit.details")}
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{data.pages > 1 && (
<nav className="mt-4 flex items-center justify-between text-[13px]" aria-label={t("audit.title")}>
{data.page > 1 ? <Link href={href({ page: data.page - 1 })} className="flex min-h-11 items-center font-semibold text-[var(--primary)]">← {t("audit.previous")}</Link> : <span />}
<span className="text-muted-foreground">{t("audit.page", { page: data.page, pages: data.pages })}</span>
{data.page < data.pages ? <Link href={href({ page: data.page + 1 })} className="flex min-h-11 items-center font-semibold text-[var(--primary)]">{t("audit.next")} →</Link> : <span />}
</nav>
)}
{detail && (
<Modal title={t("audit.detailTitle")} sub={`${actionLabel(detail.action)} · ${entityLabel(detail.entity)}`} closeHref={href({ page: data.page })} closeLabel={t("audit.close")}>
<div className="max-h-[70vh] overflow-y-auto p-5">
<dl className="grid gap-x-6 gap-y-2 text-[13px] sm:grid-cols-2">
<div><dt className="text-muted-foreground">{t("audit.time")}</dt><dd>{format.dateTime(detail.createdAt, { dateStyle: "medium", timeStyle: "medium" })}</dd></div>
<div><dt className="text-muted-foreground">{t("audit.actor")}</dt><dd>{detail.actorName ?? (detail.actorId ?? t("audit.system"))}</dd></div>
<div><dt className="text-muted-foreground">{t("audit.entity")}</dt><dd>{entityLabel(detail.entity)}{detail.scope === "platform" ? ` · ${t("audit.platform")}` : ""}</dd></div>
<div><dt className="text-muted-foreground">{t("audit.entityId")}</dt><dd className="font-mono text-[12px] break-all">{detail.entityId ?? "—"}</dd></div>
<div><dt className="text-muted-foreground">{t("audit.result")}</dt><dd>{detail.action === "denied" ? t("audit.resultDenied") : t("audit.resultOk")}</dd></div>
<div><dt className="text-muted-foreground">{t("audit.ip")}</dt><dd>{detail.ip ?? t("audit.notCaptured")}</dd></div>
<div className="sm:col-span-2"><dt className="text-muted-foreground">{t("audit.userAgent")}</dt><dd className="break-all">{detail.userAgent ?? t("audit.notCaptured")}</dd></div>
</dl>
{detail.diff.length === 0 ? (
<p className="mt-5 text-sm text-muted-foreground">{t("audit.noValues")}</p>
) : (
<div className="mt-5 overflow-x-auto rounded-lg border">
<table className="w-full min-w-[560px] text-[12.5px]">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="px-3 py-2 font-semibold">{t("audit.field")}</th>
<th className="px-3 py-2 font-semibold">{t("audit.before")}</th>
<th className="px-3 py-2 font-semibold">{t("audit.after")}</th>
</tr>
</thead>
<tbody>
{detail.diff.map((d) => (
<tr key={d.key} className={d.changed ? "border-b bg-[var(--ui-primary-soft)] align-top last:border-0" : "border-b align-top last:border-0"}>
<td className="px-3 py-2 font-medium">
{d.key}
{d.changed && <span className="ml-1.5 text-[10.5px] font-bold text-[var(--primary)] uppercase">{t("audit.changed")}</span>}
</td>
<td className="px-3 py-2 font-mono break-all whitespace-pre-wrap text-muted-foreground">{d.before ?? "—"}</td>
<td className="px-3 py-2 font-mono break-all whitespace-pre-wrap">{d.after ?? "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</Modal>
)}
</main>
);
}
+11
View File
@@ -65,6 +65,17 @@ const ENTITY_LABEL: Record<string, string> = {
import: "Auftragsimport", import: "Auftragsimport",
report: "Bericht", report: "Bericht",
emergency: "Notdienst", 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", document: "Dokument",
}; };
+128
View File
@@ -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<typeof auditFilterSchema>;
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<string, unknown>) : null;
const a = after && typeof after === "object" && !Array.isArray(after) ? (after as Record<string, unknown>) : 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<string, unknown>;
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),
};
}