import { z } from "zod"; import { createTRPCRouter, controllerProcedure } from "../trpc.js"; // ─── 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 }) => { const { entityType, entityId, userId, action, source, startDate, endDate, search, limit, cursor } = 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" } }, ]; } const items = await ctx.db.auditLog.findMany({ where, include: { user: { select: { id: true, name: true, email: true } }, }, orderBy: { createdAt: "desc" }, take: limit + 1, ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), }); let nextCursor: string | undefined; if (items.length > limit) { const next = items.pop(); nextCursor = next?.id; } return { items, nextCursor }; }), /** * 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 ctx.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, }); }), /** * 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 }) => { 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 ctx.db.auditLog.findMany({ where, include: { user: { select: { id: true, name: true, email: true } }, }, orderBy: { createdAt: "desc" }, take: input.limit, }); // Group by date string (YYYY-MM-DD) 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; }), /** * 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 }; }), });