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:
@@ -11,6 +11,7 @@ import { sendEmail } from "../lib/email.js";
|
||||
import { anonymizeResource, anonymizeUser, getAnonymizationDirectory } from "../lib/anonymization.js";
|
||||
import { checkVacationConflicts, checkBatchVacationConflicts } from "../lib/vacation-conflicts.js";
|
||||
import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
|
||||
import { createAuditEntry } from "../lib/audit.js";
|
||||
|
||||
/** Types that consume from annual leave balance */
|
||||
const BALANCE_TYPES = [VacationType.ANNUAL, VacationType.OTHER];
|
||||
@@ -219,6 +220,17 @@ export const vacationRouter = createTRPCRouter({
|
||||
|
||||
emitVacationCreated({ id: vacation.id, resourceId: vacation.resourceId, status: vacation.status });
|
||||
|
||||
void createAuditEntry({
|
||||
db: ctx.db,
|
||||
entityType: "Vacation",
|
||||
entityId: vacation.id,
|
||||
entityName: `${vacation.resource?.displayName ?? "Unknown"} - ${vacation.type}`,
|
||||
action: "CREATE",
|
||||
userId: userRecord.id,
|
||||
after: vacation as unknown as Record<string, unknown>,
|
||||
source: "ui",
|
||||
});
|
||||
|
||||
// Create approval tasks for managers when a non-manager submits a vacation request
|
||||
if (status === VacationStatus.PENDING) {
|
||||
const resourceName = vacation.resource?.displayName ?? "Unknown";
|
||||
@@ -291,6 +303,20 @@ export const vacationRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status });
|
||||
|
||||
void createAuditEntry({
|
||||
db: ctx.db,
|
||||
entityType: "Vacation",
|
||||
entityId: updated.id,
|
||||
entityName: `Vacation ${updated.id}`,
|
||||
action: "UPDATE",
|
||||
...(userRecord?.id ? { userId: userRecord.id } : {}),
|
||||
before: existing as unknown as Record<string, unknown>,
|
||||
after: updated as unknown as Record<string, unknown>,
|
||||
source: "ui",
|
||||
summary: `Approved vacation (was ${existing.status})`,
|
||||
});
|
||||
|
||||
void dispatchWebhooks(ctx.db, "vacation.approved", {
|
||||
id: updated.id,
|
||||
resourceId: updated.resourceId,
|
||||
@@ -361,6 +387,19 @@ export const vacationRouter = createTRPCRouter({
|
||||
},
|
||||
});
|
||||
|
||||
void createAuditEntry({
|
||||
db: ctx.db,
|
||||
entityType: "Vacation",
|
||||
entityId: updated.id,
|
||||
entityName: `Vacation ${updated.id}`,
|
||||
action: "UPDATE",
|
||||
...(userRecord?.id ? { userId: userRecord.id } : {}),
|
||||
before: existing as unknown as Record<string, unknown>,
|
||||
after: updated as unknown as Record<string, unknown>,
|
||||
source: "ui",
|
||||
summary: `Rejected vacation${input.rejectionReason ? `: ${input.rejectionReason}` : ""}`,
|
||||
});
|
||||
|
||||
void notifyVacationStatus(ctx.db, updated.id, updated.resourceId, VacationStatus.REJECTED, input.rejectionReason);
|
||||
|
||||
return updated;
|
||||
@@ -404,6 +443,18 @@ export const vacationRouter = createTRPCRouter({
|
||||
emitVacationUpdated({ id: v.id, resourceId: v.resourceId, status: VacationStatus.APPROVED });
|
||||
void notifyVacationStatus(ctx.db, v.id, v.resourceId, VacationStatus.APPROVED);
|
||||
|
||||
void createAuditEntry({
|
||||
db: ctx.db,
|
||||
entityType: "Vacation",
|
||||
entityId: v.id,
|
||||
entityName: `Vacation ${v.id}`,
|
||||
action: "UPDATE",
|
||||
...(userRecord?.id ? { userId: userRecord.id } : {}),
|
||||
after: { status: VacationStatus.APPROVED } as unknown as Record<string, unknown>,
|
||||
source: "ui",
|
||||
summary: "Batch approved vacation",
|
||||
});
|
||||
|
||||
// Mark approval tasks as DONE
|
||||
await ctx.db.notification.updateMany({
|
||||
where: {
|
||||
@@ -461,6 +512,18 @@ export const vacationRouter = createTRPCRouter({
|
||||
emitVacationUpdated({ id: v.id, resourceId: v.resourceId, status: VacationStatus.REJECTED });
|
||||
void notifyVacationStatus(ctx.db, v.id, v.resourceId, VacationStatus.REJECTED, input.rejectionReason);
|
||||
|
||||
void createAuditEntry({
|
||||
db: ctx.db,
|
||||
entityType: "Vacation",
|
||||
entityId: v.id,
|
||||
entityName: `Vacation ${v.id}`,
|
||||
action: "UPDATE",
|
||||
...(userRecord?.id ? { userId: userRecord.id } : {}),
|
||||
after: { status: VacationStatus.REJECTED, rejectionReason: input.rejectionReason } as unknown as Record<string, unknown>,
|
||||
source: "ui",
|
||||
summary: `Batch rejected vacation${input.rejectionReason ? `: ${input.rejectionReason}` : ""}`,
|
||||
});
|
||||
|
||||
// Mark approval tasks as DONE
|
||||
await ctx.db.notification.updateMany({
|
||||
where: {
|
||||
@@ -523,6 +586,20 @@ export const vacationRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status });
|
||||
|
||||
void createAuditEntry({
|
||||
db: ctx.db,
|
||||
entityType: "Vacation",
|
||||
entityId: updated.id,
|
||||
entityName: `Vacation ${updated.id}`,
|
||||
action: "UPDATE",
|
||||
userId: userRecord.id,
|
||||
before: existing as unknown as Record<string, unknown>,
|
||||
after: updated as unknown as Record<string, unknown>,
|
||||
source: "ui",
|
||||
summary: `Cancelled vacation (was ${existing.status})`,
|
||||
});
|
||||
|
||||
return updated;
|
||||
}),
|
||||
|
||||
@@ -687,6 +764,18 @@ export const vacationRouter = createTRPCRouter({
|
||||
}
|
||||
}
|
||||
|
||||
void createAuditEntry({
|
||||
db: ctx.db,
|
||||
entityType: "Vacation",
|
||||
entityId: `public-holidays-${input.year}`,
|
||||
entityName: `Public Holidays ${input.year}${input.federalState ? ` (${input.federalState})` : ""}`,
|
||||
action: "CREATE",
|
||||
userId: adminUser.id,
|
||||
after: { created, holidays: holidays.length, resources: resources.length, year: input.year, federalState: input.federalState } as unknown as Record<string, unknown>,
|
||||
source: "ui",
|
||||
summary: `Batch created ${created} public holidays for ${resources.length} resources (${input.year})`,
|
||||
});
|
||||
|
||||
return { created, holidays: holidays.length, resources: resources.length };
|
||||
}),
|
||||
|
||||
@@ -729,6 +818,20 @@ export const vacationRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status });
|
||||
|
||||
void createAuditEntry({
|
||||
db: ctx.db,
|
||||
entityType: "Vacation",
|
||||
entityId: updated.id,
|
||||
entityName: `Vacation ${updated.id}`,
|
||||
action: "UPDATE",
|
||||
userId: userRecord.id,
|
||||
before: existing as unknown as Record<string, unknown>,
|
||||
after: updated as unknown as Record<string, unknown>,
|
||||
source: "ui",
|
||||
summary: `Updated vacation status to ${input.status}`,
|
||||
});
|
||||
|
||||
return updated;
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user