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:
@@ -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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user