import { z } from 'zod' import { router, adminProcedure, superAdminProcedure } from '../trpc' import { logAudit } from '../utils/audit' export const auditRouter = router({ /** * List audit logs with filtering and pagination */ list: adminProcedure .input( z.object({ userId: z.string().optional(), action: z.string().optional(), entityType: z.string().optional(), entityId: z.string().optional(), startDate: z.date().optional(), endDate: z.date().optional(), page: z.number().int().min(1).default(1), perPage: z.number().int().min(1).max(100).default(50), }) ) .query(async ({ ctx, input }) => { const { userId, action, entityType, entityId, startDate, endDate, page, perPage } = input const skip = (page - 1) * perPage const where: Record = {} if (userId) where.userId = userId if (action) where.action = action if (entityType) where.entityType = entityType if (entityId) where.entityId = entityId if (startDate || endDate) { where.timestamp = {} if (startDate) (where.timestamp as Record).gte = startDate if (endDate) (where.timestamp as Record).lte = endDate } const [logs, total] = await Promise.all([ ctx.prisma.auditLog.findMany({ where, skip, take: perPage, orderBy: { timestamp: 'desc' }, include: { user: { select: { name: true, email: true } }, }, }), ctx.prisma.auditLog.count({ where }), ]) return { logs, total, page, perPage, totalPages: Math.ceil(total / perPage), } }), /** * Get audit logs for a specific entity */ getByEntity: adminProcedure .input( z.object({ entityType: z.string(), entityId: z.string(), }) ) .query(async ({ ctx, input }) => { return ctx.prisma.auditLog.findMany({ where: { entityType: input.entityType, entityId: input.entityId, }, orderBy: { timestamp: 'desc' }, include: { user: { select: { name: true, email: true } }, }, }) }), /** * Get audit logs for a specific user */ getByUser: adminProcedure .input( z.object({ userId: z.string(), limit: z.number().int().min(1).max(100).default(50), }) ) .query(async ({ ctx, input }) => { return ctx.prisma.auditLog.findMany({ where: { userId: input.userId }, orderBy: { timestamp: 'desc' }, take: input.limit, }) }), /** * Get recent activity summary */ getRecentActivity: adminProcedure .input(z.object({ limit: z.number().int().min(1).max(50).default(20) })) .query(async ({ ctx, input }) => { return ctx.prisma.auditLog.findMany({ orderBy: { timestamp: 'desc' }, take: input.limit, include: { user: { select: { name: true, email: true } }, }, }) }), /** * Get action statistics */ getStats: adminProcedure .input( z.object({ startDate: z.date().optional(), endDate: z.date().optional(), }) ) .query(async ({ ctx, input }) => { const where: Record = {} if (input.startDate || input.endDate) { where.timestamp = {} if (input.startDate) (where.timestamp as Record).gte = input.startDate if (input.endDate) (where.timestamp as Record).lte = input.endDate } const [byAction, byEntity, byUser] = await Promise.all([ ctx.prisma.auditLog.groupBy({ by: ['action'], where, _count: true, orderBy: { _count: { action: 'desc' } }, }), ctx.prisma.auditLog.groupBy({ by: ['entityType'], where, _count: true, orderBy: { _count: { entityType: 'desc' } }, }), ctx.prisma.auditLog.groupBy({ by: ['userId'], where, _count: true, orderBy: { _count: { userId: 'desc' } }, take: 10, }), ]) // Get user names for top users const userIds = byUser .map((u) => u.userId) .filter((id): id is string => id !== null) const users = await ctx.prisma.user.findMany({ where: { id: { in: userIds } }, select: { id: true, name: true, email: true }, }) const userMap = new Map(users.map((u) => [u.id, u])) return { byAction: byAction.map((a) => ({ action: a.action, count: a._count, })), byEntity: byEntity.map((e) => ({ entityType: e.entityType, count: e._count, })), byUser: byUser.map((u) => ({ userId: u.userId, user: u.userId ? userMap.get(u.userId) : null, count: u._count, })), } }), // ========================================================================= // Anomaly Detection & Session Tracking (F14) // ========================================================================= /** * Detect anomalous activity patterns within a time window */ getAnomalies: adminProcedure .input( z.object({ timeWindowMinutes: z.number().int().min(1).max(1440).default(60), }) ) .query(async ({ ctx, input }) => { // Load anomaly rules from settings const rulesSetting = await ctx.prisma.systemSettings.findUnique({ where: { key: 'audit_anomaly_rules' }, }) const rules = rulesSetting?.value ? JSON.parse(rulesSetting.value) as { rapid_changes_per_minute?: number bulk_operations_threshold?: number } : { rapid_changes_per_minute: 30, bulk_operations_threshold: 50 } const rapidThreshold = rules.rapid_changes_per_minute || 30 const bulkThreshold = rules.bulk_operations_threshold || 50 const windowStart = new Date() windowStart.setMinutes(windowStart.getMinutes() - input.timeWindowMinutes) // Get action counts per user in the time window const userActivity = await ctx.prisma.auditLog.groupBy({ by: ['userId'], where: { timestamp: { gte: windowStart }, userId: { not: null }, }, _count: true, }) // Filter for users exceeding thresholds const suspiciousUserIds = userActivity .filter((u) => u._count >= bulkThreshold) .map((u) => u.userId) .filter((id): id is string => id !== null) // Get user details const users = suspiciousUserIds.length > 0 ? await ctx.prisma.user.findMany({ where: { id: { in: suspiciousUserIds } }, select: { id: true, name: true, email: true, role: true }, }) : [] const userMap = new Map(users.map((u) => [u.id, u])) const anomalies = userActivity .filter((u) => u._count >= bulkThreshold) .map((u) => ({ userId: u.userId, user: u.userId ? userMap.get(u.userId) || null : null, actionCount: u._count, timeWindowMinutes: input.timeWindowMinutes, actionsPerMinute: u._count / input.timeWindowMinutes, isRapid: (u._count / input.timeWindowMinutes) >= rapidThreshold, isBulk: u._count >= bulkThreshold, })) .sort((a, b) => b.actionCount - a.actionCount) return { anomalies, thresholds: { rapidChangesPerMinute: rapidThreshold, bulkOperationsThreshold: bulkThreshold, }, timeWindow: { start: windowStart, end: new Date(), minutes: input.timeWindowMinutes, }, } }), /** * Get all audit logs for a specific session */ getSessionTimeline: adminProcedure .input(z.object({ sessionId: z.string() })) .query(async ({ ctx, input }) => { const logs = await ctx.prisma.auditLog.findMany({ where: { sessionId: input.sessionId }, orderBy: { timestamp: 'asc' }, include: { user: { select: { id: true, name: true, email: true } }, }, }) return logs }), /** * Get current audit retention configuration */ getRetentionConfig: adminProcedure.query(async ({ ctx }) => { const setting = await ctx.prisma.systemSettings.findUnique({ where: { key: 'audit_retention_days' }, }) return { retentionDays: setting?.value ? parseInt(setting.value, 10) : 365, } }), /** * Update audit retention configuration (super admin only) */ updateRetentionConfig: superAdminProcedure .input(z.object({ retentionDays: z.number().int().min(30) })) .mutation(async ({ ctx, input }) => { const setting = await ctx.prisma.systemSettings.upsert({ where: { key: 'audit_retention_days' }, update: { value: input.retentionDays.toString(), updatedBy: ctx.user.id, }, create: { key: 'audit_retention_days', value: input.retentionDays.toString(), category: 'AUDIT_CONFIG', updatedBy: ctx.user.id, }, }) // Audit log try { await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'UPDATE_RETENTION_CONFIG', entityType: 'SystemSettings', entityId: setting.id, detailsJson: { retentionDays: input.retentionDays }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) } catch { // Never throw on audit failure } return { retentionDays: input.retentionDays } }), })