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 }; }); } 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), // captured by writeAuditLog from the request (spec §26); null for worker/script writes ip: row.ipAddress, userAgent: row.userAgent, }; }