refactor(api): extract audit log router helpers

This commit is contained in:
2026-03-31 12:09:24 +02:00
parent d4682ff0ac
commit 91243e4091
2 changed files with 319 additions and 293 deletions
@@ -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<string, Date> = {};
if (startDate) createdAt.gte = startDate;
if (endDate) createdAt.lte = endDate;
return createdAt;
}
function buildAuditListWhere(input: Omit<AuditListInput, "limit" | "cursor">) {
const { entityType, entityId, userId, action, source, startDate, endDate, search } = input;
const where: Record<string, unknown> = {};
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<string, Date> | 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<string, unknown> = {};
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<string, typeof entries> = {};
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<string, unknown> = {};
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<string, number> = {};
for (const row of byEntityTypeRaw) {
byEntityType[row.entityType] = row._count.id;
}
const byAction: Record<string, number> = {};
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 };
}
+12 -293
View File
@@ -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<AuditListInput, "limit" | "cursor">) {
const { entityType, entityId, userId, action, source, startDate, endDate, search } = input;
const where: Record<string, unknown> = {};
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<string, Date> = {};
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<string, Date> ?? {}), 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<string, unknown> = {};
if (input.startDate || input.endDate) {
const createdAt: Record<string, Date> = {};
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<string, typeof entries> = {};
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<string, unknown> = {};
if (input.startDate || input.endDate) {
const createdAt: Record<string, Date> = {};
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<string, number>
const byEntityType: Record<string, number> = {};
for (const row of byEntityTypeRaw) {
byEntityType[row.entityType] = row._count.id;
}
const byAction: Record<string, number> = {};
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);
}),
});