chore(repo): checkpoint current capakraken implementation state

This commit is contained in:
2026-03-29 12:47:12 +02:00
parent beae1a5d6e
commit 47e4d701ff
94 changed files with 4283 additions and 1710 deletions
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PermissionKey, type PermissionKey as PermissionKeyValue } from "@capakraken/shared";
import { PermissionKey, SystemRole, type PermissionKey as PermissionKeyValue } from "@capakraken/shared";
import {
ASSISTANT_CONFIRMATION_PREFIX,
canExecuteMutationTool,
@@ -12,8 +12,11 @@ import {
} from "../router/assistant.js";
import { TOOL_DEFINITIONS } from "../router/assistant-tools.js";
function getToolNames(permissions: PermissionKeyValue[]) {
return getAvailableAssistantTools(new Set(permissions)).map((tool) => tool.function.name);
function getToolNames(
permissions: PermissionKeyValue[],
userRole: SystemRole = SystemRole.ADMIN,
) {
return getAvailableAssistantTools(new Set(permissions), userRole).map((tool) => tool.function.name);
}
const TEST_USER_ID = "assistant-test-user";
@@ -187,6 +190,9 @@ describe("assistant router tool gating", () => {
expect(withoutAdvanced).not.toContain("find_best_project_resource");
expect(withAdvanced).toContain("find_best_project_resource");
expect(withAdvanced).toContain("get_chargeability_report");
expect(withAdvanced).toContain("get_resource_computation_graph");
expect(withAdvanced).toContain("get_project_computation_graph");
});
it("keeps user administration tools behind manageUsers", () => {
@@ -201,6 +207,93 @@ describe("assistant router tool gating", () => {
const names = getToolNames([PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS]);
expect(names).not.toContain("find_best_project_resource");
expect(names).not.toContain("get_chargeability_report");
expect(names).not.toContain("get_resource_computation_graph");
expect(names).not.toContain("get_project_computation_graph");
});
it("keeps controller-grade readmodels hidden from plain users while allowing controller roles", () => {
const controllerNames = getToolNames([
PermissionKey.VIEW_COSTS,
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
], SystemRole.CONTROLLER);
const userNames = getToolNames([
PermissionKey.VIEW_COSTS,
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
], SystemRole.USER);
expect(controllerNames).toContain("get_chargeability_report");
expect(controllerNames).toContain("get_resource_computation_graph");
expect(controllerNames).toContain("get_project_computation_graph");
expect(userNames).not.toContain("get_chargeability_report");
expect(userNames).not.toContain("get_resource_computation_graph");
expect(userNames).not.toContain("get_project_computation_graph");
});
it("keeps timeline write parity tools behind manager/admin role, manageAllocations, and advanced assistant access", () => {
const managerNames = getToolNames([
PermissionKey.MANAGE_ALLOCATIONS,
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
], SystemRole.MANAGER);
const userNames = getToolNames([
PermissionKey.MANAGE_ALLOCATIONS,
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
], SystemRole.USER);
const missingAdvancedNames = getToolNames([
PermissionKey.MANAGE_ALLOCATIONS,
], SystemRole.MANAGER);
expect(managerNames).toContain("update_timeline_allocation_inline");
expect(managerNames).toContain("apply_timeline_project_shift");
expect(managerNames).toContain("quick_assign_timeline_resource");
expect(managerNames).toContain("batch_quick_assign_timeline_resources");
expect(managerNames).toContain("batch_shift_timeline_allocations");
expect(userNames).not.toContain("update_timeline_allocation_inline");
expect(userNames).not.toContain("apply_timeline_project_shift");
expect(userNames).not.toContain("quick_assign_timeline_resource");
expect(userNames).not.toContain("batch_quick_assign_timeline_resources");
expect(userNames).not.toContain("batch_shift_timeline_allocations");
expect(missingAdvancedNames).not.toContain("update_timeline_allocation_inline");
expect(missingAdvancedNames).not.toContain("quick_assign_timeline_resource");
});
it("keeps holiday calendar mutation tools admin-only while leaving read tools available", () => {
const adminNames = getToolNames([], SystemRole.ADMIN);
const managerNames = getToolNames([], SystemRole.MANAGER);
expect(adminNames).toContain("list_holiday_calendars");
expect(adminNames).toContain("get_holiday_calendar");
expect(adminNames).toContain("preview_resolved_holiday_calendar");
expect(adminNames).toContain("create_holiday_calendar");
expect(managerNames).toContain("list_holiday_calendars");
expect(managerNames).toContain("get_holiday_calendar");
expect(managerNames).toContain("preview_resolved_holiday_calendar");
expect(managerNames).not.toContain("create_holiday_calendar");
expect(managerNames).not.toContain("update_holiday_calendar");
expect(managerNames).not.toContain("delete_holiday_calendar");
expect(managerNames).not.toContain("create_holiday_calendar_entry");
expect(managerNames).not.toContain("update_holiday_calendar_entry");
expect(managerNames).not.toContain("delete_holiday_calendar_entry");
});
it("keeps country and metro-city mutation tools admin-only while leaving read tools available", () => {
const adminNames = getToolNames([], SystemRole.ADMIN);
const managerNames = getToolNames([], SystemRole.MANAGER);
expect(adminNames).toContain("list_countries");
expect(adminNames).toContain("get_country");
expect(adminNames).toContain("create_country");
expect(adminNames).toContain("update_country");
expect(adminNames).toContain("create_metro_city");
expect(adminNames).toContain("update_metro_city");
expect(adminNames).toContain("delete_metro_city");
expect(managerNames).toContain("list_countries");
expect(managerNames).toContain("get_country");
expect(managerNames).not.toContain("create_country");
expect(managerNames).not.toContain("update_country");
expect(managerNames).not.toContain("create_metro_city");
expect(managerNames).not.toContain("update_metro_city");
expect(managerNames).not.toContain("delete_metro_city");
});
it("blocks mutation tools until the user confirms a prior assistant summary", () => {
@@ -397,5 +490,16 @@ describe("assistant router tool gating", () => {
expect(toolDescriptions.get("list_users")).toContain("manageUsers");
expect(toolDescriptions.get("create_task_for_user")).toContain("manageProjects");
expect(toolDescriptions.get("send_broadcast")).toContain("manageProjects");
expect(toolDescriptions.get("create_holiday_calendar")).toContain("Admin role");
expect(toolDescriptions.get("create_holiday_calendar_entry")).toContain("Admin role");
expect(toolDescriptions.get("get_chargeability_report")).toContain("controller/manager/admin");
expect(toolDescriptions.get("get_chargeability_report")).toContain("viewCosts");
expect(toolDescriptions.get("get_resource_computation_graph")).toContain("useAssistantAdvancedTools");
expect(toolDescriptions.get("get_project_computation_graph")).toContain("controller/manager/admin");
expect(toolDescriptions.get("update_timeline_allocation_inline")).toContain("manager/admin");
expect(toolDescriptions.get("apply_timeline_project_shift")).toContain("manageAllocations");
expect(toolDescriptions.get("quick_assign_timeline_resource")).toContain("useAssistantAdvancedTools");
expect(toolDescriptions.get("batch_quick_assign_timeline_resources")).toContain("manageAllocations");
expect(toolDescriptions.get("batch_shift_timeline_allocations")).toContain("manager/admin");
});
});
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { PermissionKey } from "@capakraken/shared";
import { AllocationStatus, PermissionKey, SystemRole } from "@capakraken/shared";
vi.mock("@capakraken/application", async (importOriginal) => {
const actual = await importOriginal<typeof import("@capakraken/application")>();
@@ -11,17 +11,43 @@ vi.mock("@capakraken/application", async (importOriginal) => {
};
});
vi.mock("../sse/event-bus.js", () => ({
emitAllocationCreated: vi.fn(),
emitAllocationDeleted: vi.fn(),
emitAllocationUpdated: vi.fn(),
emitProjectShifted: vi.fn(),
}));
vi.mock("../lib/budget-alerts.js", () => ({
checkBudgetThresholds: vi.fn(),
}));
vi.mock("../lib/cache.js", () => ({
invalidateDashboardCache: vi.fn(),
}));
import { executeTool, type ToolContext } from "../router/assistant-tools.js";
function createToolContext(
db: Record<string, unknown>,
permissions: PermissionKey[] = [],
userRole: SystemRole = SystemRole.ADMIN,
): ToolContext {
return {
db: db as ToolContext["db"],
userId: "user_1",
userRole: "ADMIN",
userRole,
permissions: new Set(permissions),
session: {
user: { email: "assistant@example.com", name: "Assistant User", image: null },
expires: "2026-03-29T00:00:00.000Z",
},
dbUser: {
id: "user_1",
systemRole: userRole,
permissionOverrides: null,
},
roleDefaults: null,
};
}
@@ -542,6 +568,686 @@ describe("assistant advanced tools and scoping", () => {
]);
});
it("updates timeline allocations inline through the real timeline router mutation", async () => {
const existingAssignment = {
id: "assignment_1",
demandRequirementId: null,
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 4,
percentage: 50,
role: "Compositor",
roleId: "role_comp",
dailyCostCents: 20000,
status: AllocationStatus.PROPOSED,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
resource: {
id: "resource_1",
displayName: "Alice",
eid: "E-001",
lcrCents: 5000,
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
saturday: 0,
sunday: 0,
},
},
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
roleEntity: { id: "role_comp", name: "Compositor", color: "#111111" },
demandRequirement: null,
};
const updatedAssignment = {
...existingAssignment,
hoursPerDay: 6,
endDate: new Date("2026-03-21"),
percentage: 75,
dailyCostCents: 30000,
metadata: { includeSaturday: true },
updatedAt: new Date("2026-03-14"),
};
const db = {
allocation: {
findUnique: vi.fn().mockResolvedValue(null),
},
demandRequirement: {
findUnique: vi.fn().mockResolvedValue(null),
},
assignment: {
findUnique: vi.fn().mockResolvedValue(existingAssignment),
update: vi.fn().mockResolvedValue(updatedAssignment),
},
resource: {
findUnique: vi.fn().mockResolvedValue({
id: "resource_1",
eid: "E-001",
displayName: "Alice",
lcrCents: 5000,
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
saturday: 0,
sunday: 0,
},
}),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
};
const ctx = createToolContext(
db,
[PermissionKey.MANAGE_ALLOCATIONS, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS],
SystemRole.MANAGER,
);
const result = await executeTool(
"update_timeline_allocation_inline",
JSON.stringify({
allocationId: "assignment_1",
hoursPerDay: 6,
endDate: "2026-03-21",
includeSaturday: true,
}),
ctx,
);
expect(result.action).toEqual({ type: "invalidate", scope: ["allocation", "timeline", "project"] });
expect(JSON.parse(result.content)).toEqual(
expect.objectContaining({
success: true,
allocation: expect.objectContaining({
id: "assignment_1",
hoursPerDay: 6,
endDate: "2026-03-21",
}),
}),
);
expect(db.assignment.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "assignment_1" },
}),
);
});
it("quick-assigns a timeline resource through the real timeline router mutation", async () => {
const createdAssignment = {
id: "assignment_quick_1",
demandRequirementId: null,
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 8,
percentage: 100,
role: "Team Member",
roleId: null,
dailyCostCents: 40000,
status: AllocationStatus.PROPOSED,
metadata: { source: "quickAssign" },
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
resource: {
id: "resource_1",
displayName: "Alice",
eid: "E-001",
lcrCents: 5000,
},
project: { id: "project_1", name: "Gelddruckmaschine", shortCode: "GDM" },
roleEntity: null,
demandRequirement: null,
};
const db = {
project: {
findUnique: vi.fn().mockResolvedValue({
id: "project_1",
name: "Gelddruckmaschine",
shortCode: "GDM",
status: "ACTIVE",
responsiblePerson: null,
}),
},
resource: {
findUnique: vi.fn().mockResolvedValue({
id: "resource_1",
eid: "E-001",
displayName: "Alice",
lcrCents: 5000,
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
saturday: 0,
sunday: 0,
},
}),
},
allocation: {
findMany: vi.fn().mockResolvedValue([]),
create: vi.fn(),
},
assignment: {
findMany: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue(createdAssignment),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
};
const ctx = createToolContext(
db,
[PermissionKey.MANAGE_ALLOCATIONS, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS],
SystemRole.MANAGER,
);
const result = await executeTool(
"quick_assign_timeline_resource",
JSON.stringify({
resourceIdentifier: "resource_1",
projectIdentifier: "project_1",
startDate: "2026-03-16",
endDate: "2026-03-20",
hoursPerDay: 8,
}),
ctx,
);
expect(result.action).toEqual({ type: "invalidate", scope: ["allocation", "timeline", "project"] });
expect(JSON.parse(result.content)).toEqual(
expect.objectContaining({
success: true,
allocation: expect.objectContaining({
id: "assignment_quick_1",
projectId: "project_1",
resourceId: "resource_1",
hoursPerDay: 8,
}),
}),
);
expect(db.assignment.create).toHaveBeenCalled();
});
it("batch quick-assigns timeline resources through the real timeline router mutation", async () => {
const db = {
project: {
findUnique: vi.fn().mockResolvedValue({
id: "project_1",
name: "Gelddruckmaschine",
shortCode: "GDM",
status: "ACTIVE",
responsiblePerson: null,
}),
},
resource: {
findUnique: vi.fn().mockImplementation(async ({ where }: { where: { id: string } }) => ({
id: where.id,
eid: `E-${where.id}`,
displayName: `Resource ${where.id}`,
lcrCents: 5000,
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
saturday: 0,
sunday: 0,
},
})),
},
assignment: {
findMany: vi.fn().mockResolvedValue([]),
create: vi
.fn()
.mockResolvedValueOnce({ id: "assignment_batch_1" })
.mockResolvedValueOnce({ id: "assignment_batch_2" }),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
};
const ctx = createToolContext(
db,
[PermissionKey.MANAGE_ALLOCATIONS, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS],
SystemRole.MANAGER,
);
const result = await executeTool(
"batch_quick_assign_timeline_resources",
JSON.stringify({
assignments: [
{
resourceIdentifier: "resource_1",
projectIdentifier: "project_1",
startDate: "2026-03-16",
endDate: "2026-03-20",
hoursPerDay: 8,
},
{
resourceIdentifier: "resource_2",
projectIdentifier: "project_1",
startDate: "2026-03-23",
endDate: "2026-03-27",
hoursPerDay: 6,
},
],
}),
ctx,
);
expect(result.action).toEqual({ type: "invalidate", scope: ["allocation", "timeline", "project"] });
expect(JSON.parse(result.content)).toEqual(
expect.objectContaining({
success: true,
count: 2,
}),
);
expect(db.assignment.create).toHaveBeenCalledTimes(2);
});
it("applies timeline project shifts through the real timeline router mutation", async () => {
const { listAssignmentBookings } = await import("@capakraken/application");
vi.mocked(listAssignmentBookings).mockResolvedValueOnce([]);
const db = {
project: {
findUnique: vi.fn().mockResolvedValue({
id: "project_1",
name: "Gelddruckmaschine",
shortCode: "GDM",
status: "ACTIVE",
responsiblePerson: null,
budgetCents: 100000,
winProbability: 100,
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
}),
update: vi.fn().mockResolvedValue({
id: "project_1",
startDate: new Date("2026-03-23"),
endDate: new Date("2026-03-27"),
}),
},
demandRequirement: {
findMany: vi.fn().mockResolvedValue([]),
},
assignment: {
findMany: vi.fn().mockResolvedValue([]),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
};
const ctx = createToolContext(
db,
[PermissionKey.MANAGE_ALLOCATIONS, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS],
SystemRole.MANAGER,
);
const result = await executeTool(
"apply_timeline_project_shift",
JSON.stringify({
projectIdentifier: "project_1",
newStartDate: "2026-03-23",
newEndDate: "2026-03-27",
}),
ctx,
);
expect(result.action).toEqual({ type: "invalidate", scope: ["allocation", "timeline", "project"] });
expect(JSON.parse(result.content)).toEqual(
expect.objectContaining({
success: true,
project: expect.objectContaining({
id: "project_1",
startDate: "2026-03-23",
endDate: "2026-03-27",
}),
validation: expect.objectContaining({
valid: true,
}),
}),
);
expect(db.project.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "project_1" },
}),
);
});
it("batch-shifts timeline allocations through the real timeline router mutation", async () => {
const existingAssignment = {
id: "assignment_1",
demandRequirementId: null,
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 4,
percentage: 50,
role: "Compositor",
roleId: "role_comp",
dailyCostCents: 20000,
status: AllocationStatus.PROPOSED,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
resource: {
id: "resource_1",
displayName: "Alice",
eid: "E-001",
lcrCents: 5000,
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
saturday: 0,
sunday: 0,
},
},
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
roleEntity: { id: "role_comp", name: "Compositor", color: "#111111" },
demandRequirement: null,
};
const db = {
allocation: {
findUnique: vi.fn().mockResolvedValue(null),
},
demandRequirement: {
findUnique: vi.fn().mockResolvedValue(null),
},
assignment: {
findUnique: vi.fn().mockResolvedValue(existingAssignment),
update: vi.fn().mockResolvedValue({
...existingAssignment,
startDate: new Date("2026-03-18"),
endDate: new Date("2026-03-22"),
}),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
};
const ctx = createToolContext(
db,
[PermissionKey.MANAGE_ALLOCATIONS, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS],
SystemRole.MANAGER,
);
const result = await executeTool(
"batch_shift_timeline_allocations",
JSON.stringify({
allocationIds: ["assignment_1"],
daysDelta: 2,
mode: "move",
}),
ctx,
);
expect(result.action).toEqual({ type: "invalidate", scope: ["allocation", "timeline", "project"] });
expect(JSON.parse(result.content)).toEqual(
expect.objectContaining({
success: true,
count: 1,
}),
);
expect(db.assignment.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "assignment_1" },
}),
);
});
it("returns the chargeability report readmodel through the assistant", async () => {
const { listAssignmentBookings } = await import("@capakraken/application");
vi.mocked(listAssignmentBookings).mockResolvedValue([
{
id: "assignment_confirmed",
projectId: "project_confirmed",
resourceId: "resource_1",
startDate: new Date("2026-03-02T00:00:00.000Z"),
endDate: new Date("2026-03-06T00:00:00.000Z"),
hoursPerDay: 4,
dailyCostCents: 0,
status: "CONFIRMED",
project: {
id: "project_confirmed",
name: "Confirmed Project",
shortCode: "CP",
status: "ACTIVE",
orderType: "CLIENT",
dynamicFields: null,
},
resource: { id: "resource_1", displayName: "Alice", chapter: "CGI" },
},
]);
const ctx = createToolContext(
{
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "resource_1",
eid: "E-001",
displayName: "Alice",
fte: 1,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_es",
federalState: null,
metroCityId: "city_1",
chargeabilityTarget: 80,
country: {
id: "country_es",
code: "ES",
dailyWorkingHours: 8,
scheduleRules: null,
},
orgUnit: { id: "org_1", name: "CGI" },
managementLevelGroup: { id: "mgmt_1", name: "Senior", targetPercentage: 0.8 },
managementLevel: { id: "level_1", name: "L7" },
metroCity: { id: "city_1", name: "Barcelona" },
},
]),
},
project: {
findMany: vi.fn().mockResolvedValue([
{ id: "project_confirmed", utilizationCategory: { code: "Chg" } },
]),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
holidayCalendar: {
findMany: vi.fn().mockResolvedValue([]),
},
},
[PermissionKey.VIEW_COSTS, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS],
);
const result = await executeTool(
"get_chargeability_report",
JSON.stringify({
startMonth: "2026-03",
endMonth: "2026-03",
resourceLimit: 10,
}),
ctx,
);
const parsed = JSON.parse(result.content) as {
monthKeys: string[];
groupTotals: Array<{ monthKey: string; chargeabilityPct: number; targetPct: number }>;
resourceCount: number;
returnedResourceCount: number;
truncated: boolean;
resources: Array<{
displayName: string;
targetPct: number;
months: Array<{ monthKey: string; sah: number; chargeabilityPct: number }>;
}>;
};
expect(parsed.monthKeys).toEqual(["2026-03"]);
expect(parsed.groupTotals).toEqual([
expect.objectContaining({
monthKey: "2026-03",
chargeabilityPct: expect.any(Number),
targetPct: 80,
}),
]);
expect(parsed.resourceCount).toBe(1);
expect(parsed.returnedResourceCount).toBe(1);
expect(parsed.truncated).toBe(false);
expect(parsed.resources).toEqual([
expect.objectContaining({
displayName: "Alice",
targetPct: 80,
months: [
expect.objectContaining({
monthKey: "2026-03",
sah: expect.any(Number),
chargeabilityPct: expect.any(Number),
}),
],
}),
]);
});
it("returns a filtered resource computation graph through the assistant", async () => {
const resourceRecord = {
id: "resource_augsburg",
displayName: "Bruce Banner",
eid: "bruce.banner",
fte: 1,
lcrCents: 5_000,
chargeabilityTarget: 80,
countryId: "country_de",
federalState: "BY",
metroCityId: "city_augsburg",
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
saturday: 0,
sunday: 0,
},
country: {
id: "country_de",
code: "DE",
name: "Deutschland",
dailyWorkingHours: 8,
scheduleRules: null,
},
metroCity: { id: "city_augsburg", name: "Augsburg" },
managementLevelGroup: {
id: "mlg_1",
name: "Senior",
targetPercentage: 0.8,
},
};
const ctx = createToolContext(
{
resource: {
findUnique: vi.fn().mockResolvedValue(resourceRecord),
findFirst: vi.fn(),
findUniqueOrThrow: vi.fn().mockResolvedValue(resourceRecord),
},
assignment: {
findMany: vi.fn().mockResolvedValue([]),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
holidayCalendar: {
findMany: vi.fn().mockResolvedValue([]),
},
calculationRule: {
findMany: vi.fn().mockResolvedValue([]),
},
},
[PermissionKey.VIEW_COSTS, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS],
);
const result = await executeTool(
"get_resource_computation_graph",
JSON.stringify({
resourceId: "resource_augsburg",
month: "2026-08",
domain: "SAH",
}),
ctx,
);
const parsed = JSON.parse(result.content) as {
resource: { id: string; displayName: string };
requestedDomain: string;
totalNodeCount: number;
selectedNodeCount: number;
nodes: Array<{ id: string; domain: string }>;
meta: {
countryCode: string | null;
federalState: string | null;
metroCityName: string | null;
resolvedHolidays: Array<{ name: string; scope: string }>;
};
};
expect(parsed.resource).toEqual({
id: "resource_augsburg",
eid: "bruce.banner",
displayName: "Bruce Banner",
});
expect(parsed.requestedDomain).toBe("SAH");
expect(parsed.totalNodeCount).toBeGreaterThan(parsed.selectedNodeCount);
expect(parsed.selectedNodeCount).toBeGreaterThan(0);
expect(parsed.nodes.every((node) => node.domain === "SAH")).toBe(true);
expect(parsed.meta).toMatchObject({
countryCode: "DE",
federalState: "BY",
metroCityName: "Augsburg",
});
expect(parsed.meta.resolvedHolidays).toEqual(expect.arrayContaining([
expect.objectContaining({
name: "Augsburger Friedensfest",
scope: "CITY",
}),
]));
});
it("scopes assistant notification listing to the current user", async () => {
const findMany = vi.fn().mockResolvedValue([]);
const ctx = createToolContext({
@@ -0,0 +1,181 @@
import { describe, expect, it, vi } from "vitest";
import { SystemRole } from "@capakraken/shared";
import { executeTool, type ToolContext } from "../router/assistant-tools.js";
function createToolContext(
db: Record<string, unknown>,
permissions: string[] = [],
userRole: SystemRole = SystemRole.ADMIN,
): ToolContext {
return {
db: db as ToolContext["db"],
userId: "user_1",
userRole,
permissions: new Set(permissions) as ToolContext["permissions"],
};
}
describe("assistant country tools", () => {
it("lists countries with schedule rules, active state, and metro cities", async () => {
const ctx = createToolContext({
country: {
findMany: vi.fn().mockResolvedValue([
{
id: "country_de",
code: "DE",
name: "Deutschland",
dailyWorkingHours: 8,
scheduleRules: null,
isActive: true,
metroCities: [{ id: "city_muc", name: "Munich" }],
},
]),
},
});
const result = await executeTool(
"list_countries",
JSON.stringify({ includeInactive: true }),
ctx,
);
const parsed = JSON.parse(result.content) as {
count: number;
countries: Array<{
code: string;
isActive: boolean;
metroCities: Array<{ id: string; name: string }>;
cities: string[];
}>;
};
expect(parsed.count).toBe(1);
expect(parsed.countries[0]).toMatchObject({
code: "DE",
isActive: true,
cities: ["Munich"],
metroCities: [{ id: "city_muc", name: "Munich" }],
});
});
it("gets a country by code and exposes schedule details and resource count", async () => {
const ctx = createToolContext({
country: {
findUnique: vi.fn().mockResolvedValue(null),
findFirst: vi
.fn()
.mockResolvedValueOnce({
id: "country_es",
code: "ES",
name: "Spain",
dailyWorkingHours: 8,
scheduleRules: {
type: "spain",
fridayHours: 6.5,
summerPeriod: { from: "07-01", to: "09-15" },
summerHours: 6.5,
regularHours: 9,
},
isActive: true,
metroCities: [{ id: "city_mad", name: "Madrid" }],
_count: { resources: 4 },
}),
},
});
const result = await executeTool(
"get_country",
JSON.stringify({ identifier: "ES" }),
ctx,
);
const parsed = JSON.parse(result.content) as {
code: string;
resourceCount: number | null;
scheduleRules: { type: string };
metroCities: Array<{ name: string }>;
};
expect(parsed).toMatchObject({
code: "ES",
resourceCount: 4,
scheduleRules: { type: "spain" },
metroCities: [{ name: "Madrid" }],
});
});
it("creates a country for admin users and returns an invalidation action", async () => {
const ctx = createToolContext({
country: {
findUnique: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue({
id: "country_es",
code: "ES",
name: "Spain",
dailyWorkingHours: 8,
scheduleRules: null,
isActive: true,
metroCities: [],
_count: { resources: 0 },
}),
},
});
const result = await executeTool(
"create_country",
JSON.stringify({ code: "ES", name: "Spain", dailyWorkingHours: 8 }),
ctx,
);
expect(result.action).toEqual({
type: "invalidate",
scope: ["country", "resource", "holidayCalendar", "vacation"],
});
expect(result.data).toMatchObject({
success: true,
country: { code: "ES", name: "Spain" },
});
});
it("refuses country mutations for non-admin users", async () => {
const ctx = createToolContext({ country: {} }, [], SystemRole.MANAGER);
const result = await executeTool(
"create_country",
JSON.stringify({ code: "ES", name: "Spain" }),
ctx,
);
expect(JSON.parse(result.content)).toEqual({
error: "Admin role required to perform this action.",
});
});
it("deletes metro cities only when no resources are assigned", async () => {
const ctx = createToolContext({
metroCity: {
findUnique: vi.fn().mockResolvedValue({
id: "city_ham",
name: "Hamburg",
_count: { resources: 0 },
}),
delete: vi.fn().mockResolvedValue(undefined),
},
});
const result = await executeTool(
"delete_metro_city",
JSON.stringify({ id: "city_ham" }),
ctx,
);
expect(result.action).toEqual({
type: "invalidate",
scope: ["country", "resource", "holidayCalendar", "vacation"],
});
expect(result.data).toMatchObject({
success: true,
message: "Deleted metro city: Hamburg",
});
});
});
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { SystemRole } from "@capakraken/shared";
vi.mock("@capakraken/application", async (importOriginal) => {
const actual = await importOriginal<typeof import("@capakraken/application")>();
@@ -8,16 +9,21 @@ vi.mock("@capakraken/application", async (importOriginal) => {
};
});
vi.mock("../lib/audit.js", () => ({
createAuditEntry: vi.fn().mockResolvedValue(undefined),
}));
import { executeTool, type ToolContext } from "../router/assistant-tools.js";
function createToolContext(
db: Record<string, unknown>,
permissions: string[] = [],
userRole: SystemRole = SystemRole.ADMIN,
): ToolContext {
return {
db: db as ToolContext["db"],
userId: "user_1",
userRole: "ADMIN",
userRole,
permissions: new Set(permissions) as ToolContext["permissions"],
};
}
@@ -107,6 +113,193 @@ describe("assistant holiday tools", () => {
);
});
it("lists holiday calendars with scope metadata and entry counts", async () => {
const ctx = createToolContext({
holidayCalendar: {
findMany: vi.fn().mockResolvedValue([
{
id: "cal_by",
name: "Bayern Feiertage",
scopeType: "STATE",
stateCode: "BY",
isActive: true,
priority: 10,
country: { id: "country_de", code: "DE", name: "Deutschland" },
metroCity: null,
_count: { entries: 2 },
entries: [
{
id: "entry_1",
date: new Date("2026-01-06T00:00:00.000Z"),
name: "Heilige Drei Koenige",
isRecurringAnnual: true,
source: "state",
},
],
},
]),
},
});
const result = await executeTool(
"list_holiday_calendars",
JSON.stringify({ countryCode: "DE", scopeType: "STATE", includeInactive: true }),
ctx,
);
const parsed = JSON.parse(result.content) as {
count: number;
calendars: Array<{
name: string;
scopeType: string;
stateCode: string | null;
entryCount: number;
country: { code: string };
}>;
};
expect(parsed.count).toBe(1);
expect(parsed.calendars).toHaveLength(1);
expect(parsed.calendars[0]).toMatchObject({
name: "Bayern Feiertage",
scopeType: "STATE",
stateCode: "BY",
entryCount: 2,
country: { code: "DE" },
});
});
it("previews resolved holiday calendars for a scope and shows the source calendar", async () => {
const ctx = createToolContext({
country: {
findUnique: vi.fn().mockResolvedValue({ code: "DE" }),
},
metroCity: {
findUnique: vi.fn().mockResolvedValue({ name: "Augsburg" }),
},
holidayCalendar: {
findMany: vi.fn().mockResolvedValue([
{
id: "cal_city",
name: "Augsburg lokal",
scopeType: "CITY",
priority: 5,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
entries: [
{
id: "entry_1",
date: new Date("2020-08-08T00:00:00.000Z"),
name: "Friedensfest lokal",
isRecurringAnnual: true,
source: "manual",
},
],
},
]),
},
});
const result = await executeTool(
"preview_resolved_holiday_calendar",
JSON.stringify({ countryId: "country_de", metroCityId: "city_augsburg", year: 2026 }),
ctx,
);
const parsed = JSON.parse(result.content) as {
count: number;
locationContext: { countryCode: string; metroCity: string | null; year: number };
holidays: Array<{ name: string; calendarName: string; scope: string; date: string }>;
};
expect(parsed.count).toBeGreaterThan(0);
expect(parsed.locationContext).toEqual(
expect.objectContaining({
countryCode: "DE",
metroCity: "Augsburg",
year: 2026,
}),
);
expect(parsed.holidays).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "Friedensfest lokal",
calendarName: "Augsburg lokal",
scope: "CITY",
date: "2026-08-08",
}),
]),
);
});
it("creates a holiday calendar through the assistant for admin users", async () => {
const ctx = createToolContext({
country: {
findUnique: vi.fn().mockResolvedValue({ id: "country_de", code: "DE", name: "Deutschland" }),
},
holidayCalendar: {
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue({
id: "cal_by",
name: "Bayern Feiertage",
scopeType: "STATE",
stateCode: "BY",
isActive: true,
priority: 10,
country: { id: "country_de", code: "DE", name: "Deutschland" },
metroCity: null,
entries: [],
}),
},
});
const result = await executeTool(
"create_holiday_calendar",
JSON.stringify({
name: "Bayern Feiertage",
scopeType: "STATE",
countryId: "country_de",
stateCode: "BY",
priority: 10,
}),
ctx,
);
const parsed = JSON.parse(result.content) as {
success: boolean;
message: string;
calendar: { name: string; stateCode: string | null };
};
expect(parsed.success).toBe(true);
expect(parsed.message).toContain("Created holiday calendar");
expect(parsed.calendar).toEqual(
expect.objectContaining({
name: "Bayern Feiertage",
stateCode: "BY",
}),
);
});
it("rejects holiday calendar mutations for non-admin assistant users", async () => {
const ctx = createToolContext({}, [], SystemRole.MANAGER);
const result = await executeTool(
"create_holiday_calendar",
JSON.stringify({
name: "Hamburg Feiertage",
scopeType: "STATE",
countryId: "country_de",
stateCode: "HH",
}),
ctx,
);
expect(JSON.parse(result.content)).toEqual(
expect.objectContaining({
error: "Admin role required to perform this action.",
}),
);
});
it("calculates chargeability with regional holidays excluded from booked and available hours", async () => {
const db = {
resource: {
@@ -19,9 +19,13 @@ vi.mock("@capakraken/staffing", () => ({
),
}));
vi.mock("@capakraken/application", () => ({
listAssignmentBookings: vi.fn().mockResolvedValue([]),
}));
vi.mock("@capakraken/application", async (importOriginal) => {
const actual = await importOriginal<typeof import("@capakraken/application")>();
return {
...actual,
listAssignmentBookings: vi.fn().mockResolvedValue([]),
};
});
const createCaller = createCallerFactory(staffingRouter);
+12 -439
View File
@@ -1,439 +1,12 @@
import { VacationStatus } from "@capakraken/db";
import { getPublicHolidays, type WeekdayAvailability } from "@capakraken/shared";
type CalendarScope = "COUNTRY" | "STATE" | "CITY";
type HolidayCalendarEntryRecord = {
date: Date;
isRecurringAnnual: boolean;
};
type HolidayCalendarRecord = {
entries: HolidayCalendarEntryRecord[];
};
type VacationRecord = {
resourceId: string;
startDate: Date;
endDate: Date;
type: string;
isHalfDay: boolean;
};
export type ResourceCapacityProfile = {
id: string;
availability: WeekdayAvailability;
countryId: string | null | undefined;
countryCode: string | null | undefined;
federalState: string | null | undefined;
metroCityId: string | null | undefined;
metroCityName: string | null | undefined;
};
export type ResourceDailyAvailabilityContext = {
absenceFractionsByDate: Map<string, number>;
holidayDates: Set<string>;
vacationFractionsByDate: Map<string, number>;
};
type ResourceCapacityDbClient = {
holidayCalendar?: {
findMany: (args: {
where: Record<string, unknown>;
include: { entries: true };
orderBy: Array<Record<string, "asc" | "desc">>;
}) => Promise<unknown[]>;
};
vacation?: {
findMany: (args: {
where: Record<string, unknown>;
select: Record<string, boolean | Record<string, boolean>>;
}) => Promise<unknown[]>;
};
};
const DAY_KEYS: (keyof WeekdayAvailability)[] = [
"sunday",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
];
const CITY_HOLIDAY_RULES: Array<{
countryCode: string;
cityName: string;
resolveDates: (year: number) => string[];
}> = [
{
countryCode: "DE",
cityName: "Augsburg",
resolveDates: (year) => [`${year}-08-08`],
},
];
function toIsoDate(value: Date): string {
return value.toISOString().slice(0, 10);
}
function normalizeCityName(cityName?: string | null): string | null {
const normalized = cityName?.trim().toLowerCase();
return normalized && normalized.length > 0 ? normalized : null;
}
function normalizeStateCode(stateCode?: string | null): string | null {
const normalized = stateCode?.trim().toUpperCase();
return normalized && normalized.length > 0 ? normalized : null;
}
export function getAvailabilityHoursForDate(
availability: WeekdayAvailability,
date: Date,
): number {
const key = DAY_KEYS[date.getUTCDay()];
return key ? (availability[key] ?? 0) : 0;
}
function listBuiltinHolidayDates(input: {
periodStart: Date;
periodEnd: Date;
countryCode: string | null | undefined;
federalState: string | null | undefined;
metroCityName: string | null | undefined;
}): Set<string> {
const dates = new Set<string>();
const startIso = toIsoDate(input.periodStart);
const endIso = toIsoDate(input.periodEnd);
const startYear = input.periodStart.getUTCFullYear();
const endYear = input.periodEnd.getUTCFullYear();
if (input.countryCode === "DE") {
for (let year = startYear; year <= endYear; year += 1) {
for (const holiday of getPublicHolidays(year, input.federalState ?? undefined)) {
if (holiday.date >= startIso && holiday.date <= endIso) {
dates.add(holiday.date);
}
}
}
}
const normalizedCityName = normalizeCityName(input.metroCityName);
if (input.countryCode && normalizedCityName) {
for (const rule of CITY_HOLIDAY_RULES) {
if (
rule.countryCode === input.countryCode
&& normalizeCityName(rule.cityName) === normalizedCityName
) {
for (let year = startYear; year <= endYear; year += 1) {
for (const date of rule.resolveDates(year)) {
if (date >= startIso && date <= endIso) {
dates.add(date);
}
}
}
}
}
}
return dates;
}
function resolveCalendarEntryDates(
calendars: HolidayCalendarRecord[],
periodStart: Date,
periodEnd: Date,
): Set<string> {
const dates = new Set<string>();
const startIso = toIsoDate(periodStart);
const endIso = toIsoDate(periodEnd);
const startYear = periodStart.getUTCFullYear();
const endYear = periodEnd.getUTCFullYear();
for (const calendar of calendars) {
for (const entry of calendar.entries) {
const baseDate = new Date(entry.date);
for (let year = startYear; year <= endYear; year += 1) {
const effectiveDate = entry.isRecurringAnnual
? new Date(Date.UTC(year, baseDate.getUTCMonth(), baseDate.getUTCDate()))
: baseDate;
const isoDate = toIsoDate(effectiveDate);
if (isoDate >= startIso && isoDate <= endIso) {
dates.add(isoDate);
}
if (!entry.isRecurringAnnual) {
break;
}
}
}
}
return dates;
}
async function loadCustomHolidayDates(
db: ResourceCapacityDbClient,
input: {
periodStart: Date;
periodEnd: Date;
countryId: string | null | undefined;
federalState: string | null | undefined;
metroCityId: string | null | undefined;
},
): Promise<Set<string>> {
if (!input.countryId || typeof db.holidayCalendar?.findMany !== "function") {
return new Set();
}
const stateCode = normalizeStateCode(input.federalState);
const metroCityId = input.metroCityId?.trim() || null;
const calendars = await db.holidayCalendar.findMany({
where: {
isActive: true,
countryId: input.countryId,
OR: [
{ scopeType: "COUNTRY" as CalendarScope },
...(stateCode ? [{ scopeType: "STATE" as CalendarScope, stateCode }] : []),
...(metroCityId ? [{ scopeType: "CITY" as CalendarScope, metroCityId }] : []),
],
},
include: { entries: true },
orderBy: [{ priority: "asc" }, { createdAt: "asc" }],
});
return resolveCalendarEntryDates(
calendars as HolidayCalendarRecord[],
input.periodStart,
input.periodEnd,
);
}
function buildProfileKey(profile: ResourceCapacityProfile): string {
return JSON.stringify({
countryId: profile.countryId ?? null,
countryCode: profile.countryCode ?? null,
federalState: profile.federalState ?? null,
metroCityId: profile.metroCityId ?? null,
metroCityName: profile.metroCityName ?? null,
});
}
export async function loadResourceDailyAvailabilityContexts(
db: ResourceCapacityDbClient,
resources: ResourceCapacityProfile[],
periodStart: Date,
periodEnd: Date,
): Promise<Map<string, ResourceDailyAvailabilityContext>> {
const profileHolidayCache = new Map<string, Promise<Set<string>>>();
const resourceIds = resources.map((resource) => resource.id);
const vacations = resourceIds.length > 0 && typeof db.vacation?.findMany === "function"
? await db.vacation.findMany({
where: {
resourceId: { in: resourceIds },
status: VacationStatus.APPROVED,
startDate: { lte: periodEnd },
endDate: { gte: periodStart },
},
select: {
resourceId: true,
startDate: true,
endDate: true,
type: true,
isHalfDay: true,
},
})
: [];
const vacationsByResourceId = new Map<string, VacationRecord[]>();
for (const vacation of vacations as VacationRecord[]) {
const items = vacationsByResourceId.get(vacation.resourceId) ?? [];
items.push(vacation);
vacationsByResourceId.set(vacation.resourceId, items);
}
const contexts = new Map<string, ResourceDailyAvailabilityContext>();
for (const resource of resources) {
const profileKey = buildProfileKey(resource);
const holidayPromise = profileHolidayCache.get(profileKey)
?? (async () => {
const builtin = listBuiltinHolidayDates({
periodStart,
periodEnd,
countryCode: resource.countryCode,
federalState: resource.federalState,
metroCityName: resource.metroCityName,
});
const custom = await loadCustomHolidayDates(db, {
periodStart,
periodEnd,
countryId: resource.countryId,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
});
return new Set([...builtin, ...custom]);
})();
if (!profileHolidayCache.has(profileKey)) {
profileHolidayCache.set(profileKey, holidayPromise);
}
const holidayDates = new Set(await holidayPromise);
const absenceFractionsByDate = new Map<string, number>();
const vacationFractionsByDate = new Map<string, number>();
const resourceVacations = vacationsByResourceId.get(resource.id) ?? [];
for (const vacation of resourceVacations) {
const overlapStart = new Date(Math.max(vacation.startDate.getTime(), periodStart.getTime()));
const overlapEnd = new Date(Math.min(vacation.endDate.getTime(), periodEnd.getTime()));
if (overlapStart > overlapEnd) {
continue;
}
const cursor = new Date(overlapStart);
cursor.setUTCHours(0, 0, 0, 0);
const end = new Date(overlapEnd);
end.setUTCHours(0, 0, 0, 0);
while (cursor <= end) {
const isoDate = toIsoDate(cursor);
const fraction = vacation.isHalfDay ? 0.5 : 1;
if (vacation.type === "PUBLIC_HOLIDAY") {
holidayDates.add(isoDate);
}
if (vacation.type !== "PUBLIC_HOLIDAY") {
const existingVacation = vacationFractionsByDate.get(isoDate) ?? 0;
vacationFractionsByDate.set(isoDate, Math.max(existingVacation, fraction));
}
const existing = absenceFractionsByDate.get(isoDate) ?? 0;
if (vacation.type === "PUBLIC_HOLIDAY" || !holidayDates.has(isoDate)) {
absenceFractionsByDate.set(isoDate, Math.max(existing, fraction));
}
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
}
for (const isoDate of holidayDates) {
const existing = absenceFractionsByDate.get(isoDate) ?? 0;
absenceFractionsByDate.set(isoDate, Math.max(existing, 1));
}
contexts.set(resource.id, {
absenceFractionsByDate,
holidayDates,
vacationFractionsByDate,
});
}
return contexts;
}
function calculateDayAvailabilityFraction(
context: ResourceDailyAvailabilityContext | undefined,
isoDate: string,
): number {
const fraction = context?.absenceFractionsByDate.get(isoDate) ?? 0;
return Math.max(0, 1 - fraction);
}
export function calculateEffectiveDayAvailability(input: {
availability: WeekdayAvailability;
date: Date;
context: ResourceDailyAvailabilityContext | undefined;
}): number {
const baseHours = getAvailabilityHoursForDate(input.availability, input.date);
if (baseHours <= 0) {
return 0;
}
return baseHours * calculateDayAvailabilityFraction(input.context, toIsoDate(input.date));
}
export function calculateEffectiveAvailableHours(input: {
availability: WeekdayAvailability;
periodStart: Date;
periodEnd: Date;
context: ResourceDailyAvailabilityContext | undefined;
}): number {
let hours = 0;
const cursor = new Date(input.periodStart);
cursor.setUTCHours(0, 0, 0, 0);
const end = new Date(input.periodEnd);
end.setUTCHours(0, 0, 0, 0);
while (cursor <= end) {
hours += calculateEffectiveDayAvailability({
availability: input.availability,
date: cursor,
context: input.context,
});
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
return hours;
}
export function countEffectiveWorkingDays(input: {
availability: WeekdayAvailability;
periodStart: Date;
periodEnd: Date;
context: ResourceDailyAvailabilityContext | undefined;
}): number {
let days = 0;
const cursor = new Date(input.periodStart);
cursor.setUTCHours(0, 0, 0, 0);
const end = new Date(input.periodEnd);
end.setUTCHours(0, 0, 0, 0);
while (cursor <= end) {
if (calculateEffectiveDayAvailability({
availability: input.availability,
date: cursor,
context: input.context,
}) > 0) {
days += 1;
}
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
return days;
}
export function calculateEffectiveBookedHours(input: {
availability: WeekdayAvailability;
startDate: Date;
endDate: Date;
hoursPerDay: number;
periodStart: Date;
periodEnd: Date;
context: ResourceDailyAvailabilityContext | undefined;
}): number {
const overlapStart = new Date(Math.max(input.startDate.getTime(), input.periodStart.getTime()));
const overlapEnd = new Date(Math.min(input.endDate.getTime(), input.periodEnd.getTime()));
if (overlapStart > overlapEnd) {
return 0;
}
let hours = 0;
const cursor = new Date(overlapStart);
cursor.setUTCHours(0, 0, 0, 0);
const end = new Date(overlapEnd);
end.setUTCHours(0, 0, 0, 0);
while (cursor <= end) {
const dayBaseHours = getAvailabilityHoursForDate(input.availability, cursor);
if (dayBaseHours > 0) {
hours += input.hoursPerDay * calculateDayAvailabilityFraction(input.context, toIsoDate(cursor));
}
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
return hours;
}
export {
calculateEffectiveBookedHours,
calculateEffectiveAvailableHours,
calculateEffectiveDayAvailability,
countEffectiveWorkingDays,
getAvailabilityHoursForDate,
loadResourceDailyAvailabilityContexts,
} from "@capakraken/application";
export type {
ResourceCapacityProfile,
ResourceDailyAvailabilityContext,
} from "@capakraken/application";
File diff suppressed because it is too large Load Diff
+65 -5
View File
@@ -1,12 +1,12 @@
/**
* AI Assistant router — provides a chat endpoint that uses OpenAI Function Calling
* to answer questions about plANARCHY data and modify resources/projects.
* to answer questions about CapaKraken data and modify resources/projects.
*/
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { AssistantApprovalStatus, type PrismaClient } from "@capakraken/db";
import { PermissionKey, resolvePermissions, type PermissionOverrides, type SystemRole } from "@capakraken/shared";
import { PermissionKey, resolvePermissions, type PermissionOverrides, SystemRole } from "@capakraken/shared";
import { createTRPCRouter, protectedProcedure } from "../trpc.js";
import { createAiClient, isAiConfigured, loggedAiCall, parseAiError } from "../ai-client.js";
import { ADVANCED_ASSISTANT_TOOLS, MUTATION_TOOLS, TOOL_DEFINITIONS, executeTool, type ToolContext, type ToolAction } from "./assistant-tools.js";
@@ -112,6 +112,11 @@ const TOOL_PERMISSION_MAP: Record<string, string> = {
create_allocation: "manageAllocations",
cancel_allocation: "manageAllocations",
update_allocation_status: "manageAllocations",
update_timeline_allocation_inline: "manageAllocations",
apply_timeline_project_shift: "manageAllocations",
quick_assign_timeline_resource: "manageAllocations",
batch_quick_assign_timeline_resources: "manageAllocations",
batch_shift_timeline_allocations: "manageAllocations",
create_demand: "manageAllocations",
fill_demand: "manageAllocations",
// Vacation management
@@ -127,16 +132,71 @@ const TOOL_PERMISSION_MAP: Record<string, string> = {
};
/** Tools that require cost visibility */
const COST_TOOLS = new Set(["get_budget_status", "get_chargeability", "resolve_rate", "list_rate_cards", "get_estimate_detail", "find_best_project_resource"]);
const COST_TOOLS = new Set([
"get_budget_status",
"get_chargeability",
"get_chargeability_report",
"get_resource_computation_graph",
"get_project_computation_graph",
"resolve_rate",
"list_rate_cards",
"get_estimate_detail",
"find_best_project_resource",
]);
export function getAvailableAssistantTools(permissions: Set<PermissionKey>) {
/** Tools that follow controllerProcedure access rules in the main API. */
const CONTROLLER_ONLY_TOOLS = new Set([
"get_chargeability_report",
"get_resource_computation_graph",
"get_project_computation_graph",
]);
/** Tools that follow managerProcedure access rules in the main API. */
const MANAGER_ONLY_TOOLS = new Set([
"update_timeline_allocation_inline",
"apply_timeline_project_shift",
"quick_assign_timeline_resource",
"batch_quick_assign_timeline_resources",
"batch_shift_timeline_allocations",
]);
/** Tools that are intentionally limited to ADMIN because the backing routers are admin-only today. */
const ADMIN_ONLY_TOOLS = new Set([
"create_country",
"update_country",
"create_metro_city",
"update_metro_city",
"delete_metro_city",
"create_holiday_calendar",
"update_holiday_calendar",
"delete_holiday_calendar",
"create_holiday_calendar_entry",
"update_holiday_calendar_entry",
"delete_holiday_calendar_entry",
]);
export function getAvailableAssistantTools(permissions: Set<PermissionKey>, userRole: string) {
return TOOL_DEFINITIONS.filter((tool) => {
const toolName = tool.function.name;
const requiredPerm = TOOL_PERMISSION_MAP[toolName];
const hasControllerAccess = userRole === SystemRole.ADMIN
|| userRole === SystemRole.MANAGER
|| userRole === SystemRole.CONTROLLER;
const hasManagerAccess = userRole === SystemRole.ADMIN
|| userRole === SystemRole.MANAGER;
if (requiredPerm && !permissions.has(requiredPerm as PermissionKey)) {
return false;
}
if (ADMIN_ONLY_TOOLS.has(toolName) && userRole !== "ADMIN") {
return false;
}
if (MANAGER_ONLY_TOOLS.has(toolName) && !hasManagerAccess) {
return false;
}
if (CONTROLLER_ONLY_TOOLS.has(toolName) && !hasControllerAccess) {
return false;
}
if (COST_TOOLS.has(toolName) && !permissions.has(PermissionKey.VIEW_COSTS)) {
return false;
}
@@ -597,7 +657,7 @@ export const assistantRouter = createTRPCRouter({
}
// 4. Filter tools based on granular permissions
const availableTools = getAvailableAssistantTools(permissions);
const availableTools = getAvailableAssistantTools(permissions, userRole);
// 5. Function calling loop
const toolCtx: ToolContext = {