feat(api): add audit helpers, tool registry, shared tool manifest types, and UI primitives

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-09 21:14:26 +02:00
parent 1a67af6761
commit 43de66e982
8 changed files with 904 additions and 0 deletions
@@ -0,0 +1,551 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { listAssignmentBookings } from "@capakraken/application";
import { PermissionKey, SystemRole } from "@capakraken/shared";
vi.mock("@capakraken/application", async (importOriginal) => {
const actual = await importOriginal<typeof import("@capakraken/application")>();
return {
...actual,
approveEstimateVersion: vi.fn(),
cloneEstimate: vi.fn(),
commitDispoImportBatch: vi.fn(),
countPlanningEntries: vi.fn().mockResolvedValue({ countsByRoleId: new Map() }),
createEstimateExport: vi.fn(),
createEstimatePlanningHandoff: vi.fn(),
createEstimateRevision: vi.fn(),
assessDispoImportReadiness: vi.fn(),
loadResourceDailyAvailabilityContexts: vi.fn().mockResolvedValue(new Map()),
getDashboardDemand: vi.fn().mockResolvedValue([]),
getDashboardBudgetForecast: vi.fn().mockResolvedValue([]),
getDashboardOverview: vi.fn(),
getDashboardSkillGapSummary: vi.fn().mockResolvedValue({
roleGaps: [],
totalOpenPositions: 0,
skillSupplyTop10: [],
resourcesByRole: [],
}),
getDashboardProjectHealth: vi.fn().mockResolvedValue([]),
getDashboardPeakTimes: vi.fn().mockResolvedValue([]),
getDashboardTopValueResources: vi.fn().mockResolvedValue([]),
getEstimateById: vi.fn(),
listAssignmentBookings: vi.fn().mockResolvedValue([]),
stageDispoImportBatch: vi.fn(),
submitEstimateVersion: vi.fn(),
updateEstimateDraft: vi.fn(),
};
});
import { executeTool } from "../router/assistant-tools.js";
import { createToolContext } from "./assistant-tools-insights-scenarios-test-helpers.js";
describe("assistant tools workflow scenarios", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(listAssignmentBookings).mockResolvedValue([]);
});
it("capacity search then resource check: finds capacity and then checks a specific resource from the result", async () => {
vi.mocked(listAssignmentBookings).mockResolvedValue([]);
const resourceRecord = {
id: "res_1",
displayName: "Bruce Banner",
eid: "EMP-001",
fte: 1,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8, saturday: 0, sunday: 0 },
countryId: "country_de",
federalState: null,
metroCityId: null,
country: { code: "DE", dailyWorkingHours: 8 },
metroCity: null,
areaRole: { name: "Pipeline TD" },
chapter: "Delivery",
};
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([resourceRecord]),
findUnique: vi.fn().mockResolvedValue(resourceRecord),
findFirst: vi.fn().mockResolvedValue(null),
},
assignment: {
findMany: vi.fn().mockResolvedValue([]),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
systemSettings: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const ctx = createToolContext(db, {
userRole: SystemRole.CONTROLLER,
permissions: [PermissionKey.VIEW_PLANNING],
});
// Step 1: find capacity for the period
const capacityResult = await executeTool(
"find_capacity",
JSON.stringify({
startDate: "2026-05-01",
endDate: "2026-05-30",
minHoursPerDay: 4,
chapter: "Delivery",
}),
ctx,
);
const capacityParsed = JSON.parse(capacityResult.content) as {
results: Array<{ id: string; name: string; eid: string }>;
totalFound: number;
};
expect(db.resource.findMany).toHaveBeenCalled();
expect(capacityParsed.results).toHaveLength(1);
expect(capacityParsed.results[0].name).toBe("Bruce Banner");
// Step 2: use resource ID from result to check availability in a specific date window
const foundResourceId = capacityParsed.results[0].id;
expect(foundResourceId).toBe("res_1");
const availabilityResult = await executeTool(
"check_resource_availability",
JSON.stringify({
resourceId: foundResourceId,
startDate: "2026-05-05",
endDate: "2026-05-09",
}),
ctx,
);
const availabilityParsed = JSON.parse(availabilityResult.content) as {
workingDays: number;
periodAvailableHours: number;
periodBookedHours: number;
periodRemainingHours: number;
isFullyAvailable: boolean;
};
// 2026-05-05 (Tue) to 2026-05-09 (Sat) = 4 working days (Tue-Fri)
expect(availabilityParsed.workingDays).toBe(4);
expect(availabilityParsed.periodAvailableHours).toBe(32);
expect(availabilityParsed.periodBookedHours).toBe(0);
expect(availabilityParsed.periodRemainingHours).toBe(32);
expect(availabilityParsed.isFullyAvailable).toBe(true);
});
it("vacation balance then upcoming vacations: retrieves balance and then lists upcoming vacations for the same resource", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-01T00:00:00.000Z"));
try {
const db = {
resource: {
findUnique: vi.fn().mockResolvedValue({
id: "res_2",
eid: "EMP-002",
displayName: "Tony Stark",
userId: "user_2",
chapter: "Engineering",
federalState: null,
countryId: "country_de",
metroCityId: null,
country: { code: "DE", name: "Germany" },
metroCity: null,
}),
},
holidayCalendar: {
findMany: vi.fn().mockResolvedValue([]),
},
systemSettings: {
findUnique: vi.fn().mockResolvedValue({ vacationDefaultDays: 28 }),
},
vacationEntitlement: {
findUnique: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue({
id: "ent_2026_res2",
resourceId: "res_2",
year: 2026,
entitledDays: 28,
carryoverDays: 0,
usedDays: 0,
pendingDays: 0,
}),
update: vi.fn().mockResolvedValue({
id: "ent_2026_res2",
resourceId: "res_2",
year: 2026,
entitledDays: 28,
carryoverDays: 0,
usedDays: 0,
pendingDays: 0,
}),
},
vacation: {
findMany: vi.fn().mockImplementation(async (args: { where?: { type?: unknown; status?: unknown; startDate?: unknown } } = {}) => {
if (args?.where?.status === "APPROVED" && args?.where?.startDate) {
// list_vacations_upcoming query
return [
{
id: "vac_1",
resourceId: "res_2",
status: "APPROVED",
type: "ANNUAL",
startDate: new Date("2026-05-10T00:00:00.000Z"),
endDate: new Date("2026-05-14T00:00:00.000Z"),
isHalfDay: false,
halfDayPart: null,
resource: {
id: "res_2",
displayName: "Tony Stark",
eid: "EMP-002",
lcrCents: 9_000,
chapter: "Engineering",
},
requestedBy: null,
approvedBy: null,
},
];
}
// vacation balance queries (by type)
return [];
}),
},
};
const ctx = createToolContext(db, { userRole: SystemRole.ADMIN });
// Step 1: get vacation balance for the resource
const balanceResult = await executeTool(
"get_vacation_balance",
JSON.stringify({ resourceId: "res_2", year: 2026 }),
ctx,
);
const balanceParsed = JSON.parse(balanceResult.content) as {
resource: string;
eid: string;
year: number;
entitlement: number;
remaining: number;
};
expect(balanceParsed.resource).toBe("Tony Stark");
expect(balanceParsed.eid).toBe("EMP-002");
expect(balanceParsed.year).toBe(2026);
expect(balanceParsed.entitlement).toBe(28);
expect(balanceParsed.remaining).toBe(28);
// Step 2: list upcoming vacations for the same resource (filter by name)
const upcomingResult = await executeTool(
"list_vacations_upcoming",
JSON.stringify({ resourceName: "Tony", daysAhead: 30, limit: 10 }),
ctx,
);
const upcomingParsed = JSON.parse(upcomingResult.content) as Array<{
resource: string;
eid: string;
start: string;
end: string;
}>;
expect(upcomingParsed).toHaveLength(1);
expect(upcomingParsed[0].resource).toBe("Tony Stark");
expect(upcomingParsed[0].eid).toBe("EMP-002");
expect(upcomingParsed[0].start).toBe("2026-05-10");
expect(upcomingParsed[0].end).toBe("2026-05-14");
} finally {
vi.useRealTimers();
}
});
it("demand listing then staffing suggestions: lists open demands and then retrieves suggestions for the project", async () => {
vi.mocked(listAssignmentBookings).mockResolvedValue([]);
const projectRecord = {
id: "project_1",
shortCode: "GDM",
name: "Gelddruckmaschine",
status: "ACTIVE",
responsiblePerson: null,
startDate: new Date("2026-06-01T00:00:00.000Z"),
endDate: new Date("2026-06-30T00:00:00.000Z"),
};
const db = {
demandRequirement: {
findMany: vi.fn().mockResolvedValue([
{
id: "demand_1",
projectId: "project_1",
startDate: new Date("2026-06-01T00:00:00.000Z"),
endDate: new Date("2026-06-30T00:00:00.000Z"),
hoursPerDay: 8,
percentage: 100,
role: "Pipeline TD",
roleId: null,
headcount: 2,
status: "OPEN",
metadata: null,
createdAt: new Date("2026-05-01T00:00:00.000Z"),
updatedAt: new Date("2026-05-01T00:00:00.000Z"),
project: {
id: "project_1",
name: "Gelddruckmaschine",
shortCode: "GDM",
status: "ACTIVE",
endDate: new Date("2026-06-30T00:00:00.000Z"),
},
roleEntity: null,
assignments: [],
},
]),
},
project: {
findUnique: vi.fn().mockResolvedValue(projectRecord),
findFirst: vi.fn().mockResolvedValue(null),
},
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "res_3",
displayName: "Natasha Romanoff",
eid: "EMP-003",
fte: 1,
chapter: "VFX",
skills: [{ skill: "Houdini", category: "FX", proficiency: 4, isMainSkill: true }],
lcrCents: 7_500,
chargeabilityTarget: 80,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8, saturday: 0, sunday: 0 },
valueScore: 85,
countryId: null,
federalState: null,
metroCityId: null,
country: null,
metroCity: null,
areaRole: { name: "Pipeline TD" },
},
]),
},
systemSettings: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const ctx = createToolContext(db, {
userRole: SystemRole.CONTROLLER,
permissions: [PermissionKey.VIEW_PLANNING, PermissionKey.VIEW_COSTS],
});
// Step 1: list demands (no status filter — AllocationStatus enum doesn't include OPEN)
const demandsResult = await executeTool(
"list_demands",
JSON.stringify({}),
ctx,
);
const demandsParsed = JSON.parse(demandsResult.content) as Array<{
id: string;
project: string;
role: string;
status: string;
headcount: number;
remaining: number;
}>;
expect(demandsParsed).toHaveLength(1);
expect(demandsParsed[0].id).toBe("demand_1");
expect(demandsParsed[0].project).toBe("Gelddruckmaschine");
expect(demandsParsed[0].status).toBe("OPEN");
expect(demandsParsed[0].headcount).toBe(2);
expect(demandsParsed[0].remaining).toBe(2);
// Step 2: get staffing suggestions for the project from the demand
const projectId = demandsParsed[0].id.startsWith("demand_") ? "project_1" : demandsParsed[0].id;
const suggestionsResult = await executeTool(
"get_staffing_suggestions",
JSON.stringify({
projectId,
startDate: "2026-06-01",
endDate: "2026-06-30",
limit: 5,
}),
ctx,
);
const suggestionsParsed = JSON.parse(suggestionsResult.content) as {
project: string;
suggestions: Array<{ id: string; name: string }>;
};
expect(suggestionsParsed.project).toBe("Gelddruckmaschine (GDM)");
expect(Array.isArray(suggestionsParsed.suggestions)).toBe(true);
});
it("budget status check: retrieves budget utilization for a project", async () => {
vi.mocked(listAssignmentBookings).mockResolvedValue([
{
projectId: "project_2",
status: "CONFIRMED",
dailyCostCents: 12_000,
hoursPerDay: 8,
startDate: new Date("2026-07-01T00:00:00.000Z"),
endDate: new Date("2026-07-02T00:00:00.000Z"),
project: {
id: "project_2",
status: "ACTIVE",
},
},
{
projectId: "project_2",
status: "CONFIRMED",
dailyCostCents: 8_000,
hoursPerDay: 8,
startDate: new Date("2026-07-03T00:00:00.000Z"),
endDate: new Date("2026-07-03T00:00:00.000Z"),
project: {
id: "project_2",
status: "ACTIVE",
},
},
] as Awaited<ReturnType<typeof listAssignmentBookings>>);
const db = {
project: {
findUnique: vi
.fn()
.mockResolvedValueOnce({
id: "project_2",
name: "Raketenbauprojekt",
shortCode: "RBP",
status: "ACTIVE",
responsiblePerson: "Elon",
})
.mockResolvedValueOnce({
id: "project_2",
name: "Raketenbauprojekt",
shortCode: "RBP",
budgetCents: 500_000,
winProbability: 100,
startDate: new Date("2026-07-01T00:00:00.000Z"),
endDate: new Date("2026-07-31T00:00:00.000Z"),
}),
findFirst: vi.fn(),
},
assignment: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const ctx = createToolContext(db, {
userRole: SystemRole.CONTROLLER,
permissions: [PermissionKey.VIEW_COSTS],
});
const result = await executeTool(
"get_budget_status",
JSON.stringify({ projectId: "project_2" }),
ctx,
);
const parsed = JSON.parse(result.content) as {
project: string;
code: string;
budget: string;
confirmed: string;
proposed: string;
allocated: string;
remaining: string;
utilization: string;
};
expect(vi.mocked(listAssignmentBookings)).toHaveBeenCalledWith(db, {
projectIds: ["project_2"],
});
expect(parsed.project).toBe("Raketenbauprojekt");
expect(parsed.code).toBe("RBP");
expect(parsed.budget).toBe("5.000,00 EUR");
// Booking 1: July 1-2 (2 days) × 12000 cents = 24000 cents = 240 EUR
// Booking 2: July 3 (1 day) × 8000 cents = 8000 cents = 80 EUR
// Total confirmed: 320 EUR
expect(parsed.confirmed).toBe("320,00 EUR");
expect(parsed.proposed).toBe("0,00 EUR");
expect(parsed.allocated).toBe("320,00 EUR");
expect(parsed.remaining).toBe("4.680,00 EUR");
expect(parsed.utilization).toBe("6.4%");
});
it("search resources with availability filter: finds resources in a chapter", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "res_4",
eid: "EMP-004",
displayName: "Wanda Maximoff",
chapter: "FX",
fte: 1,
lcrCents: 8_000,
chargeabilityTarget: 80,
isActive: true,
areaRole: { name: "FX Artist" },
country: { code: "DE", name: "Germany" },
metroCity: null,
orgUnit: null,
},
{
id: "res_5",
eid: "EMP-005",
displayName: "Pietro Maximoff",
chapter: "FX",
fte: 0.8,
lcrCents: 7_200,
chargeabilityTarget: 75,
isActive: true,
areaRole: { name: "Motion Designer" },
country: { code: "DE", name: "Germany" },
metroCity: null,
orgUnit: null,
},
]),
},
};
const ctx = createToolContext(db, {
userRole: SystemRole.CONTROLLER,
permissions: [PermissionKey.VIEW_ALL_RESOURCES],
});
const result = await executeTool(
"search_resources",
JSON.stringify({ orgUnit: undefined, country: "DE", limit: 10 }),
ctx,
);
const parsed = JSON.parse(result.content) as Array<{
id: string;
name: string;
eid: string;
chapter: string;
role: string;
}>;
expect(db.resource.findMany).toHaveBeenCalled();
expect(parsed).toHaveLength(2);
const wanda = parsed.find((r) => r.id === "res_4");
expect(wanda).toBeDefined();
expect(wanda?.name).toBe("Wanda Maximoff");
expect(wanda?.eid).toBe("EMP-004");
expect(wanda?.chapter).toBe("FX");
expect(wanda?.role).toBe("FX Artist");
const pietro = parsed.find((r) => r.id === "res_5");
expect(pietro).toBeDefined();
expect(pietro?.name).toBe("Pietro Maximoff");
expect(pietro?.eid).toBe("EMP-005");
});
});
+34
View File
@@ -0,0 +1,34 @@
import type { PrismaClient } from "@capakraken/db";
import { createAuditEntry } from "./audit.js";
type AuditAction = "CREATE" | "UPDATE" | "DELETE" | "SHIFT" | "IMPORT";
type AuditSource = "ui" | "api" | "ai" | "import" | "cron";
interface BoundAuditParams {
entityType: string;
entityId: string;
entityName?: string;
action: AuditAction;
before?: Record<string, unknown>;
after?: Record<string, unknown>;
summary?: string;
metadata?: Record<string, unknown>;
}
/**
* Creates a fire-and-forget audit logger with db, userId, and source pre-bound.
* Use at the top of a procedure after resolving the current user.
*
* @example
* const audit = makeAuditLogger(ctx.db, userRecord?.id);
* audit({ entityType: "Vacation", entityId: v.id, action: "UPDATE", after: v });
*/
export function makeAuditLogger(
db: PrismaClient,
userId: string | undefined,
source: AuditSource = "ui",
): (params: BoundAuditParams) => void {
return (params) => {
void createAuditEntry({ db, ...(userId !== undefined ? { userId } : {}), source, ...params });
};
}
@@ -0,0 +1,134 @@
import type { ToolManifest } from "@capakraken/shared";
/**
* Rich metadata registry for AI assistant tools.
* Complements the OpenAI function schema (ToolDef) with category, mutation flag,
* and intent descriptions that improve model tool selection and documentation.
*
* Start: 10 high-traffic tools. Add entries as needed — the registry is additive
* and does not affect tool execution.
*/
export const TOOL_REGISTRY: ToolManifest[] = [
// ─── Planning ──────────────────────────────────────────────────────────────
{
name: "create_allocation",
category: "planning",
isMutation: true,
intent: "Assign a resource to a project for a specific date range at a utilization percentage",
examples: [
"Book Alice on Project X from April to June at 80%",
"Assign the lead developer to the backend project starting next Monday at full capacity",
],
},
{
name: "cancel_allocation",
category: "planning",
isMutation: true,
intent: "Remove a resource booking from a project",
examples: [
"Cancel Bob's allocation on Project Y",
"Remove the allocation for resource 123 on the frontend project",
],
},
{
name: "list_allocations",
category: "planning",
isMutation: false,
intent: "List all allocations for a project or resource within a date range",
examples: [
"Show me all bookings for the Alpha project this quarter",
"What projects is Alice allocated to in May?",
],
},
// ─── Resource ──────────────────────────────────────────────────────────────
{
name: "search_resources",
category: "resource",
isMutation: false,
intent: "Search for resources by name, skill, chapter, or seniority",
examples: [
"Find all senior developers in the Berlin chapter",
"Who has React skills and is available next month?",
],
},
{
name: "check_resource_availability",
category: "resource",
isMutation: false,
intent: "Check how many hours a resource has free in a date range",
examples: [
"Is Alice available for a new project in Q3?",
"How much capacity does the frontend chapter have next month?",
],
},
// ─── Staffing ──────────────────────────────────────────────────────────────
{
name: "get_staffing_suggestions",
category: "staffing",
isMutation: false,
requiresAdvanced: true,
intent: "Get ranked resource suggestions for an open demand, scored by skill match, availability, and cost",
examples: [
"Who should I assign to fill the senior designer role on Project Z?",
"Find the best available backend engineers for a 3-month engagement starting in June",
],
},
{
name: "fill_demand",
category: "staffing",
isMutation: true,
intent: "Create an allocation to fill an open staffing demand",
examples: [
"Fill the open designer demand with Alice",
"Assign Bob to satisfy the pending backend demand",
],
},
// ─── Vacation ──────────────────────────────────────────────────────────────
{
name: "create_vacation",
category: "vacation",
isMutation: true,
intent: "Submit a vacation or leave request for a resource",
examples: [
"Request 2 weeks of annual leave for Alice starting August 1st",
"Book sick leave for Bob for today",
],
},
{
name: "approve_vacation",
category: "vacation",
isMutation: true,
intent: "Approve a pending vacation request",
examples: [
"Approve Alice's vacation request",
"Accept the leave request with ID abc123",
],
},
// ─── Reporting ─────────────────────────────────────────────────────────────
{
name: "get_chargeability_report",
category: "reporting",
isMutation: false,
requiresAdvanced: true,
intent: "Generate a chargeability report showing billable vs. non-billable utilization across resources or chapters",
examples: [
"Show me chargeability for the engineering chapter in Q1",
"What is the billable rate for the team last month?",
],
},
];
/** Lookup map for O(1) manifest access by tool name. */
export const TOOL_REGISTRY_MAP: ReadonlyMap<string, ToolManifest> = new Map(
TOOL_REGISTRY.map((m) => [m.name, m]),
);
/** Get the manifest for a given tool name, or undefined if not registered. */
export function getToolManifest(name: string): ToolManifest | undefined {
return TOOL_REGISTRY_MAP.get(name);
}
/** Filter the registry by category. */
export function getToolsByCategory(category: ToolManifest["category"]): ToolManifest[] {
return TOOL_REGISTRY.filter((m) => m.category === category);
}