feat: Activity History system — full audit coverage, UI, AI tools
Infrastructure (Phase 1): - AuditLog schema: add source, entityName, summary fields + index - createAuditEntry() helper: auto-diff, auto-summary, fire-and-forget - auditLog query router: list, getByEntity, getTimeline, getActivitySummary Audit Coverage (Phase 2 — 14 routers, 50+ mutations): - vacation: create, approve, reject, cancel, batch ops (8 mutations) - user: create, updateRole, setPermissions, resetPermissions (5 mutations) - entitlement: set, bulkSet (3 mutations) - client: create, update, delete, batchUpdateSortOrder - org-unit: create, update, deactivate - country: create, update, createCity, updateCity, deleteCity - management-level: createGroup, updateGroup, createLevel, updateLevel, deleteLevel - settings: updateSystemSettings (sensitive fields sanitized), testSmtp - blueprint: create, update, updateRolePresets, delete, batchDelete, setGlobal - rate-card: create, update, deactivate, addLine, updateLine, deleteLine, replaceLines - calculation-rules: create, update, delete - effort-rule: create, update, delete - experience-multiplier: create, update, delete - utilization-category: create, update Admin UI (Phase 3): - /admin/activity-log page with global searchable timeline - Filters: entity type, action, user, date range, text search - Expandable before/after diff view per entry - Summary cards showing top entity types by change count - EntityHistory reusable component for entity detail pages - Sidebar nav link with clock icon AI Assistant (Phase 4): - query_change_history tool: "Who changed project X?" - get_entity_timeline tool: "What happened to resource Y?" Regression: 283 engine + 37 staffing tests pass. TypeScript clean. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
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<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" } },
|
||||
];
|
||||
}
|
||||
|
||||
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<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 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;
|
||||
}),
|
||||
|
||||
/**
|
||||
* 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<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 };
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user