From 91243e4091ad692b15cc4b4cce96d36d5d583195 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 12:09:24 +0200 Subject: [PATCH] refactor(api): extract audit log router helpers --- packages/api/src/router/audit-log-support.ts | 307 +++++++++++++++++++ packages/api/src/router/audit-log.ts | 305 +----------------- 2 files changed, 319 insertions(+), 293 deletions(-) create mode 100644 packages/api/src/router/audit-log-support.ts diff --git a/packages/api/src/router/audit-log-support.ts b/packages/api/src/router/audit-log-support.ts new file mode 100644 index 0000000..1e860c6 --- /dev/null +++ b/packages/api/src/router/audit-log-support.ts @@ -0,0 +1,307 @@ +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; +}; + +export type AuditListInput = { + entityType?: string; + entityId?: string; + userId?: string; + action?: string; + source?: string; + startDate?: Date; + endDate?: Date; + search?: string; + limit: number; + cursor?: string; +}; + +export type AuditTimelineInput = { + startDate?: Date; + endDate?: Date; + limit: number; +}; + +export 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, + }; +} + +export function formatAuditDetailEntry(entry: AuditDetailEntryShape) { + return { + ...formatAuditListEntry(entry), + changes: entry.changes ?? null, + }; +} + +export 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 } : {}), + }; +} + +export 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 buildCreatedAtWhere(startDate?: Date, endDate?: Date) { + if (!startDate && !endDate) { + return undefined; + } + + const createdAt: Record = {}; + if (startDate) createdAt.gte = startDate; + if (endDate) createdAt.lte = endDate; + return createdAt; +} + +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; + + const createdAt = buildCreatedAtWhere(startDate, endDate); + if (createdAt) { + 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 | undefined), gte: thirtyDaysAgo }; + } + + return where; +} + +export 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 }; +} + +export async function getAuditEntryById( + db: { auditLog: { findUniqueOrThrow: Function } }, + id: string, +) { + return db.auditLog.findUniqueOrThrow({ + where: { id }, + include: { user: { select: { id: true, name: true, email: true } } }, + }); +} + +export 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, + }); +} + +export async function getAuditTimeline( + db: { auditLog: { findMany: Function } }, + input: AuditTimelineInput, +) { + const where: Record = {}; + const createdAt = buildCreatedAtWhere(input.startDate, input.endDate); + if (createdAt) { + 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; +} + +export async function getAuditActivitySummary( + db: { + auditLog: { groupBy: Function; count: Function }; + user: { findMany: Function }; + }, + input: { + startDate?: Date | undefined; + endDate?: Date | undefined; + }, +) { + const where: Record = {}; + const createdAt = buildCreatedAtWhere(input.startDate, input.endDate); + if (createdAt) { + where.createdAt = createdAt; + } + + const [byEntityTypeRaw, byActionRaw, byUserRaw, total] = await Promise.all([ + db.auditLog.groupBy({ + by: ["entityType"], + where, + _count: { id: true }, + }), + db.auditLog.groupBy({ + by: ["action"], + where, + _count: { id: true }, + }), + db.auditLog.groupBy({ + by: ["userId"], + where, + _count: { id: true }, + orderBy: { _count: { id: "desc" } }, + take: 20, + }), + db.auditLog.count({ where }), + ]); + + 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; + } + + const userIds = byUserRaw + .map((row: { userId: string | null }) => row.userId) + .filter((id: string | null): id is string => id !== null); + + const users = userIds.length > 0 + ? await db.user.findMany({ + where: { id: { in: userIds } }, + select: { id: true, name: true, email: true }, + }) + : []; + + const userMap = new Map(users.map((user: { id: string; name: string | null; email: string | null }) => [ + user.id, + user.name ?? user.email, + ])); + + const byUser = byUserRaw + .filter((row: { userId: string | null }) => row.userId !== null) + .map((row: { userId: string | null; _count: { id: number } }) => ({ + name: userMap.get(row.userId!) ?? "Unknown", + count: row._count.id, + })); + + return { byEntityType, byAction, byUser, total }; +} diff --git a/packages/api/src/router/audit-log.ts b/packages/api/src/router/audit-log.ts index c1a8b61..a4c3a82 100644 --- a/packages/api/src/router/audit-log.ts +++ b/packages/api/src/router/audit-log.ts @@ -1,234 +1,16 @@ 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; -} +import { + formatAuditDetailEntry, + formatAuditListEntry, + getAuditActivitySummary, + getAuditEntriesByEntity, + getAuditEntryById, + getAuditTimeline, + listAuditEntries, + toAuditListInput, + toAuditTimelineInput, +} from "./audit-log-support.js"; // ─── Router ─────────────────────────────────────────────────────────────────── @@ -403,69 +185,6 @@ export const auditLogRouter = createTRPCRouter({ }), ) .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 }; + return getAuditActivitySummary(ctx.db, input); }), });