import { z } from "zod"; import { createTRPCRouter, controllerProcedure } from "../trpc.js"; type AuditUser = { id: string; name: string | null; email: string | null } | null | undefined; type AuditEntryShape = { id: string; entityType: string; entityId: string; entityName?: string | null; action: string; userId?: string | null; source?: string | null; summary?: string | null; createdAt: Date; user?: AuditUser; }; type AuditDetailEntryShape = AuditEntryShape & { changes?: unknown; }; function formatAuditListEntry(entry: AuditEntryShape) { return { id: entry.id, entityType: entry.entityType, entityId: entry.entityId, entityName: entry.entityName ?? null, action: entry.action, userId: entry.userId ?? null, source: entry.source ?? null, summary: entry.summary ?? null, createdAt: entry.createdAt.toISOString(), user: entry.user ? { id: entry.user.id, name: entry.user.name, email: entry.user.email, } : null, }; } function formatAuditDetailEntry(entry: AuditDetailEntryShape) { return { ...formatAuditListEntry(entry), changes: entry.changes ?? null, }; } type AuditListInput = { entityType?: string; entityId?: string; userId?: string; action?: string; source?: string; startDate?: Date; endDate?: Date; search?: string; limit: number; cursor?: string; }; type AuditTimelineInput = { startDate?: Date; endDate?: Date; limit: number; }; function toAuditListInput(input: { entityType?: string | undefined; entityId?: string | undefined; userId?: string | undefined; action?: string | undefined; source?: string | undefined; startDate?: Date | undefined; endDate?: Date | undefined; search?: string | undefined; limit: number; cursor?: string | undefined; }): AuditListInput { return { limit: input.limit, ...(input.entityType !== undefined ? { entityType: input.entityType } : {}), ...(input.entityId !== undefined ? { entityId: input.entityId } : {}), ...(input.userId !== undefined ? { userId: input.userId } : {}), ...(input.action !== undefined ? { action: input.action } : {}), ...(input.source !== undefined ? { source: input.source } : {}), ...(input.startDate !== undefined ? { startDate: input.startDate } : {}), ...(input.endDate !== undefined ? { endDate: input.endDate } : {}), ...(input.search !== undefined ? { search: input.search } : {}), ...(input.cursor !== undefined ? { cursor: input.cursor } : {}), }; } function toAuditTimelineInput(input: { startDate?: Date | undefined; endDate?: Date | undefined; limit: number; }): AuditTimelineInput { return { limit: input.limit, ...(input.startDate !== undefined ? { startDate: input.startDate } : {}), ...(input.endDate !== undefined ? { endDate: input.endDate } : {}), }; } function buildAuditListWhere(input: Omit) { const { entityType, entityId, userId, action, source, startDate, endDate, search } = input; const where: Record = {}; if (entityType) where.entityType = entityType; if (entityId) where.entityId = entityId; if (userId) where.userId = userId; if (action) where.action = action; if (source) where.source = source; if (startDate || endDate) { const createdAt: Record = {}; if (startDate) createdAt.gte = startDate; if (endDate) createdAt.lte = endDate; where.createdAt = createdAt; } if (search) { where.OR = [ { entityName: { contains: search, mode: "insensitive" } }, { summary: { contains: search, mode: "insensitive" } }, { entityType: { contains: search, mode: "insensitive" } }, ]; } if (!startDate && !endDate && !entityId) { const thirtyDaysAgo = new Date(); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); where.createdAt = { ...(where.createdAt as Record ?? {}), gte: thirtyDaysAgo }; } return where; } async function listAuditEntries( db: { auditLog: { findMany: Function } }, input: AuditListInput, ) { const items = await db.auditLog.findMany({ where: buildAuditListWhere(input), select: { id: true, entityType: true, entityId: true, entityName: true, action: true, userId: true, source: true, summary: true, createdAt: true, user: { select: { id: true, name: true, email: true } }, }, orderBy: { createdAt: "desc" }, take: input.limit + 1, ...(input.cursor ? { cursor: { id: input.cursor }, skip: 1 } : {}), }); let nextCursor: string | undefined; if (items.length > input.limit) { const next = items.pop(); nextCursor = next?.id; } return { items, nextCursor }; } async function getAuditEntryById( db: { auditLog: { findUniqueOrThrow: Function } }, id: string, ) { return db.auditLog.findUniqueOrThrow({ where: { id }, include: { user: { select: { id: true, name: true, email: true } } }, }); } async function getAuditEntriesByEntity( db: { auditLog: { findMany: Function } }, input: { entityType: string; entityId: string; limit: number }, ) { return db.auditLog.findMany({ where: { entityType: input.entityType, entityId: input.entityId, }, include: { user: { select: { id: true, name: true, email: true } }, }, orderBy: { createdAt: "desc" }, take: input.limit, }); } async function getAuditTimeline( db: { auditLog: { findMany: Function } }, input: AuditTimelineInput, ) { const where: Record = {}; if (input.startDate || input.endDate) { const createdAt: Record = {}; if (input.startDate) createdAt.gte = input.startDate; if (input.endDate) createdAt.lte = input.endDate; where.createdAt = createdAt; } const entries = await db.auditLog.findMany({ where, include: { user: { select: { id: true, name: true, email: true } }, }, orderBy: { createdAt: "desc" }, take: input.limit, }); const grouped: Record = {}; for (const entry of entries) { const dateKey = entry.createdAt.toISOString().slice(0, 10); if (!grouped[dateKey]) grouped[dateKey] = []; grouped[dateKey].push(entry); } return grouped; } // ─── Router ─────────────────────────────────────────────────────────────────── export const auditLogRouter = createTRPCRouter({ /** * Paginated, filterable list of audit log entries. * Cursor-based pagination using createdAt + id. */ list: controllerProcedure .input( z.object({ entityType: z.string().optional(), entityId: z.string().optional(), userId: z.string().optional(), action: z.string().optional(), source: z.string().optional(), startDate: z.date().optional(), endDate: z.date().optional(), search: z.string().optional(), limit: z.number().min(1).max(100).default(50), cursor: z.string().optional(), // id of the last item }), ) .query(async ({ ctx, input }) => { return listAuditEntries(ctx.db, toAuditListInput({ entityType: input.entityType, entityId: input.entityId, userId: input.userId, action: input.action, source: input.source, startDate: input.startDate, endDate: input.endDate, search: input.search, limit: input.limit, cursor: input.cursor, })); }), listDetail: controllerProcedure .input( z.object({ entityType: z.string().optional(), entityId: z.string().optional(), userId: z.string().optional(), action: z.string().optional(), source: z.string().optional(), startDate: z.date().optional(), endDate: z.date().optional(), search: z.string().optional(), limit: z.number().min(1).max(100).default(50), cursor: z.string().optional(), }), ) .query(async ({ ctx, input }) => { const result = await listAuditEntries(ctx.db, toAuditListInput({ entityType: input.entityType, entityId: input.entityId, userId: input.userId, action: input.action, source: input.source, startDate: input.startDate, endDate: input.endDate, search: input.search, limit: input.limit, cursor: input.cursor, })); return { items: result.items.map(formatAuditListEntry), nextCursor: result.nextCursor ?? null, }; }), /** * Get a single audit entry with full changes JSONB (for expand/detail view). */ getById: controllerProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { return getAuditEntryById(ctx.db, input.id); }), getByIdDetail: controllerProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const entry = await getAuditEntryById(ctx.db, input.id); return formatAuditDetailEntry(entry); }), /** * Get all audit entries for a specific entity (e.g. a project or resource). */ getByEntity: controllerProcedure .input( z.object({ entityType: z.string(), entityId: z.string(), limit: z.number().min(1).max(200).default(50), }), ) .query(async ({ ctx, input }) => { return getAuditEntriesByEntity(ctx.db, input); }), getByEntityDetail: controllerProcedure .input( z.object({ entityType: z.string(), entityId: z.string(), limit: z.number().min(1).max(200).default(50), }), ) .query(async ({ ctx, input }) => { const entries = await getAuditEntriesByEntity(ctx.db, input); return { entityType: input.entityType, entityId: input.entityId, entityName: entries[0]?.entityName ?? null, itemCount: entries.length, items: entries.map(formatAuditDetailEntry), }; }), /** * Timeline view: entries grouped by date (YYYY-MM-DD). */ getTimeline: controllerProcedure .input( z.object({ startDate: z.date().optional(), endDate: z.date().optional(), limit: z.number().min(1).max(500).default(200), }), ) .query(async ({ ctx, input }) => { return getAuditTimeline(ctx.db, toAuditTimelineInput({ startDate: input.startDate, endDate: input.endDate, limit: input.limit, })); }), getTimelineDetail: controllerProcedure .input( z.object({ startDate: z.date().optional(), endDate: z.date().optional(), limit: z.number().min(1).max(500).default(200), }), ) .query(async ({ ctx, input }) => { const timeline = await getAuditTimeline(ctx.db, toAuditTimelineInput({ startDate: input.startDate, endDate: input.endDate, limit: input.limit, })); return Object.fromEntries( Object.entries(timeline).map(([dateKey, entries]) => [ dateKey, entries.map(formatAuditDetailEntry), ]), ); }), /** * Activity summary: counts by entity type, action, and user for a date range. */ getActivitySummary: controllerProcedure .input( z.object({ startDate: z.date().optional(), endDate: z.date().optional(), }), ) .query(async ({ ctx, input }) => { const where: Record = {}; if (input.startDate || input.endDate) { const createdAt: Record = {}; if (input.startDate) createdAt.gte = input.startDate; if (input.endDate) createdAt.lte = input.endDate; where.createdAt = createdAt; } // Run aggregation queries in parallel const [byEntityTypeRaw, byActionRaw, byUserRaw, total] = await Promise.all([ ctx.db.auditLog.groupBy({ by: ["entityType"], where, _count: { id: true }, }), ctx.db.auditLog.groupBy({ by: ["action"], where, _count: { id: true }, }), ctx.db.auditLog.groupBy({ by: ["userId"], where, _count: { id: true }, orderBy: { _count: { id: "desc" } }, take: 20, }), ctx.db.auditLog.count({ where }), ]); // Convert to simple Record const byEntityType: Record = {}; for (const row of byEntityTypeRaw) { byEntityType[row.entityType] = row._count.id; } const byAction: Record = {}; for (const row of byActionRaw) { byAction[row.action] = row._count.id; } // Resolve user names for the top users const userIds = byUserRaw .map((row) => row.userId) .filter((id): id is string => id !== null); const users = userIds.length > 0 ? await ctx.db.user.findMany({ where: { id: { in: userIds } }, select: { id: true, name: true, email: true }, }) : []; const userMap = new Map(users.map((u) => [u.id, u.name ?? u.email])); const byUser = byUserRaw .filter((row) => row.userId !== null) .map((row) => ({ name: userMap.get(row.userId!) ?? "Unknown", count: row._count.id, })); return { byEntityType, byAction, byUser, total }; }), });