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:
2026-03-22 22:39:30 +01:00
parent 3d117708ff
commit 66878f18f4
25 changed files with 2255 additions and 156 deletions
+103 -11
View File
@@ -10,6 +10,7 @@ import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { createTRPCRouter, controllerProcedure, managerProcedure } from "../trpc.js";
import { ROLE_BRIEF_SELECT } from "../db/selects.js";
import { createAuditEntry } from "../lib/audit.js";
const lineSelect = {
id: true,
@@ -96,7 +97,7 @@ export const rateCardRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => {
const { lines, ...cardData } = input;
return ctx.db.rateCard.create({
const rateCard = await ctx.db.rateCard.create({
data: {
name: cardData.name,
currency: cardData.currency,
@@ -123,17 +124,30 @@ export const rateCardRouter = createTRPCRouter({
lines: { select: lineSelect },
},
});
void createAuditEntry({
db: ctx.db,
entityType: "RateCard",
entityId: rateCard.id,
entityName: rateCard.name,
action: "CREATE",
userId: ctx.dbUser?.id,
after: { name: cardData.name, currency: cardData.currency, lineCount: lines.length },
source: "ui",
});
return rateCard;
}),
update: managerProcedure
.input(z.object({ id: z.string(), data: UpdateRateCardSchema }))
.mutation(async ({ ctx, input }) => {
await findUniqueOrThrow(
const before = await findUniqueOrThrow(
ctx.db.rateCard.findUnique({ where: { id: input.id } }),
"Rate card",
);
return ctx.db.rateCard.update({
const updated = await ctx.db.rateCard.update({
where: { id: input.id },
data: {
...(input.data.name !== undefined ? { name: input.data.name } : {}),
@@ -149,15 +163,42 @@ export const rateCardRouter = createTRPCRouter({
client: { select: { id: true, name: true, code: true } },
},
});
void createAuditEntry({
db: ctx.db,
entityType: "RateCard",
entityId: input.id,
entityName: updated.name,
action: "UPDATE",
userId: ctx.dbUser?.id,
before: before as unknown as Record<string, unknown>,
after: updated as unknown as Record<string, unknown>,
source: "ui",
});
return updated;
}),
deactivate: managerProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
return ctx.db.rateCard.update({
const deactivated = await ctx.db.rateCard.update({
where: { id: input.id },
data: { isActive: false },
});
void createAuditEntry({
db: ctx.db,
entityType: "RateCard",
entityId: input.id,
entityName: deactivated.name,
action: "DELETE",
userId: ctx.dbUser?.id,
source: "ui",
summary: "Deactivated rate card",
});
return deactivated;
}),
// ─── Line CRUD ─────────────────────────────────────────────────────────────
@@ -165,12 +206,12 @@ export const rateCardRouter = createTRPCRouter({
addLine: managerProcedure
.input(z.object({ rateCardId: z.string(), line: CreateRateCardLineSchema }))
.mutation(async ({ ctx, input }) => {
await findUniqueOrThrow(
const rateCard = await findUniqueOrThrow(
ctx.db.rateCard.findUnique({ where: { id: input.rateCardId } }),
"Rate card",
);
return ctx.db.rateCardLine.create({
const line = await ctx.db.rateCardLine.create({
data: {
rateCardId: input.rateCardId,
...(input.line.roleId !== undefined ? { roleId: input.line.roleId } : {}),
@@ -186,12 +227,25 @@ export const rateCardRouter = createTRPCRouter({
},
select: lineSelect,
});
void createAuditEntry({
db: ctx.db,
entityType: "RateCardLine",
entityId: line.id,
entityName: `${rateCard.name}${input.line.chapter ?? "line"}`,
action: "CREATE",
userId: ctx.dbUser?.id,
after: { rateCardId: input.rateCardId, costRateCents: input.line.costRateCents, billRateCents: input.line.billRateCents },
source: "ui",
});
return line;
}),
updateLine: managerProcedure
.input(z.object({ lineId: z.string(), data: UpdateRateCardLineSchema }))
.mutation(async ({ ctx, input }) => {
await findUniqueOrThrow(
const before = await findUniqueOrThrow(
ctx.db.rateCardLine.findUnique({ where: { id: input.lineId } }),
"Rate card line",
);
@@ -208,22 +262,46 @@ export const rateCardRouter = createTRPCRouter({
if (input.data.machineRateCents !== undefined) updateData.machineRateCents = input.data.machineRateCents;
if (input.data.attributes !== undefined) updateData.attributes = input.data.attributes as Prisma.InputJsonValue;
return ctx.db.rateCardLine.update({
const updated = await ctx.db.rateCardLine.update({
where: { id: input.lineId },
data: updateData,
select: lineSelect,
});
void createAuditEntry({
db: ctx.db,
entityType: "RateCardLine",
entityId: input.lineId,
action: "UPDATE",
userId: ctx.dbUser?.id,
before: before as unknown as Record<string, unknown>,
after: updated as unknown as Record<string, unknown>,
source: "ui",
});
return updated;
}),
deleteLine: managerProcedure
.input(z.object({ lineId: z.string() }))
.mutation(async ({ ctx, input }) => {
await findUniqueOrThrow(
const line = await findUniqueOrThrow(
ctx.db.rateCardLine.findUnique({ where: { id: input.lineId } }),
"Rate card line",
);
await ctx.db.rateCardLine.delete({ where: { id: input.lineId } });
void createAuditEntry({
db: ctx.db,
entityType: "RateCardLine",
entityId: input.lineId,
action: "DELETE",
userId: ctx.dbUser?.id,
before: line as unknown as Record<string, unknown>,
source: "ui",
});
return { deleted: true };
}),
@@ -235,12 +313,12 @@ export const rateCardRouter = createTRPCRouter({
lines: z.array(CreateRateCardLineSchema),
}))
.mutation(async ({ ctx, input }) => {
await findUniqueOrThrow(
const rateCard = await findUniqueOrThrow(
ctx.db.rateCard.findUnique({ where: { id: input.rateCardId } }),
"Rate card",
);
return ctx.db.$transaction(async (tx) => {
const result = await ctx.db.$transaction(async (tx) => {
await tx.rateCardLine.deleteMany({ where: { rateCardId: input.rateCardId } });
const created = await Promise.all(
@@ -266,6 +344,20 @@ export const rateCardRouter = createTRPCRouter({
return created;
});
void createAuditEntry({
db: ctx.db,
entityType: "RateCard",
entityId: input.rateCardId,
entityName: rateCard.name,
action: "UPDATE",
userId: ctx.dbUser?.id,
after: { replacedLineCount: result.length },
source: "ui",
summary: `Replaced all lines with ${result.length} new lines`,
});
return result;
}),
// ─── Rate resolution ───────────────────────────────────────────────────────