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:
@@ -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 ───────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user