feat(platform): harden access scoping and delivery baseline
This commit is contained in:
@@ -1,6 +1,235 @@
|
||||
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;
|
||||
}
|
||||
|
||||
// ─── Router ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const auditLogRouter = createTRPCRouter({
|
||||
@@ -24,65 +253,52 @@ export const auditLogRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { entityType, entityId, userId, action, source, startDate, endDate, search, limit, cursor } = 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,
|
||||
}));
|
||||
}),
|
||||
|
||||
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" } },
|
||||
];
|
||||
}
|
||||
|
||||
// Default to last 30 days if no date filter to avoid full table scan
|
||||
if (!startDate && !endDate && !entityId) {
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
where.createdAt = { ...(where.createdAt as Record<string, Date> ?? {}), gte: thirtyDaysAgo };
|
||||
}
|
||||
|
||||
const items = await ctx.db.auditLog.findMany({
|
||||
where,
|
||||
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 } },
|
||||
// Exclude 'changes' from list query — fetch on demand when expanding
|
||||
},
|
||||
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 };
|
||||
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,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
@@ -91,10 +307,14 @@ export const auditLogRouter = createTRPCRouter({
|
||||
getById: controllerProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.db.auditLog.findUniqueOrThrow({
|
||||
where: { id: input.id },
|
||||
include: { user: { select: { id: true, name: true, email: true } } },
|
||||
});
|
||||
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);
|
||||
}),
|
||||
|
||||
/**
|
||||
@@ -109,17 +329,26 @@ export const auditLogRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.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,
|
||||
});
|
||||
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),
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
@@ -134,33 +363,33 @@ export const auditLogRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const where: Record<string, unknown> = {};
|
||||
return getAuditTimeline(ctx.db, toAuditTimelineInput({
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
limit: input.limit,
|
||||
}));
|
||||
}),
|
||||
|
||||
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 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<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;
|
||||
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),
|
||||
]),
|
||||
);
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user