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 = {
+14
View File
@@ -117,6 +117,20 @@ export {
type RecomputeResourceValueScoresInput,
} from "./use-cases/resource/index.js";
export {
calculateEffectiveAllocationCostCents,
calculateEffectiveAllocationHours,
calculateEffectiveAvailableHours,
calculateEffectiveBookedHours,
calculateEffectiveDayAvailability,
countEffectiveWorkingDays,
enumerateIsoDates,
getAvailabilityHoursForDate,
loadResourceDailyAvailabilityContexts,
type ResourceCapacityProfile,
type ResourceDailyAvailabilityContext,
} from "./lib/resource-capacity.js";
export {
assessDispoImportReadiness,
parseMandatoryDispoReferenceWorkbook,
@@ -0,0 +1,508 @@
import { VacationStatus } from "@capakraken/db";
import { getPublicHolidays, type WeekdayAvailability } from "@capakraken/shared";
const MILLISECONDS_PER_DAY = 86_400_000;
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 function calculateEffectiveAllocationHours(input: {
availability: WeekdayAvailability;
startDate: Date;
endDate: Date;
hoursPerDay: number;
periodStart: Date;
periodEnd: Date;
context: ResourceDailyAvailabilityContext | undefined;
}): number {
return calculateEffectiveBookedHours(input);
}
export function calculateEffectiveAllocationCostCents(input: {
availability: WeekdayAvailability;
startDate: Date;
endDate: Date;
dailyCostCents: number;
periodStart: Date;
periodEnd: Date;
context: ResourceDailyAvailabilityContext | undefined;
}): number {
let costCents = 0;
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;
}
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 baseHours = getAvailabilityHoursForDate(input.availability, cursor);
if (baseHours > 0) {
costCents += input.dailyCostCents * calculateDayAvailabilityFraction(input.context, toIsoDate(cursor));
}
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
return Math.round(costCents);
}
export function enumerateIsoDates(
periodStart: Date,
periodEnd: Date,
): string[] {
const dates: string[] = [];
const cursor = new Date(periodStart);
cursor.setUTCHours(0, 0, 0, 0);
const end = new Date(periodEnd);
end.setUTCHours(0, 0, 0, 0);
while (cursor <= end) {
dates.push(toIsoDate(cursor));
cursor.setTime(cursor.getTime() + MILLISECONDS_PER_DAY);
}
return dates;
}
@@ -1,459 +1,11 @@
import { VacationStatus } from "@capakraken/db";
import { getPublicHolidays, type WeekdayAvailability } from "@capakraken/shared";
const MILLISECONDS_PER_DAY = 86_400_000;
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;
};
type ResourceHolidayProfile = {
id: string;
availability: WeekdayAvailability;
countryId: string | null | undefined;
countryCode: string | null | undefined;
federalState: string | null | undefined;
metroCityId: string | null | undefined;
metroCityName: string | null | undefined;
};
type DashboardHolidayDbClient = {
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[]>;
};
};
type DailyAvailabilityContext = {
holidayDates: Set<string>;
absenceFractionsByDate: Map<string, number>;
};
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;
}
function getDailyAvailabilityHours(
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: DashboardHolidayDbClient,
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 buildHolidayProfileKey(profile: ResourceHolidayProfile): 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 loadDailyAvailabilityContexts(
db: DashboardHolidayDbClient,
resources: ResourceHolidayProfile[],
periodStart: Date,
periodEnd: Date,
): Promise<Map<string, DailyAvailabilityContext>> {
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, DailyAvailabilityContext>();
for (const resource of resources) {
const profileKey = buildHolidayProfileKey(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 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);
}
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, {
holidayDates,
absenceFractionsByDate,
});
}
return contexts;
}
function calculateDayAvailabilityFraction(
context: DailyAvailabilityContext | undefined,
isoDate: string,
): number {
const fraction = context?.absenceFractionsByDate.get(isoDate) ?? 0;
return Math.max(0, 1 - fraction);
}
export function calculateEffectiveAvailableHours(input: {
availability: WeekdayAvailability;
periodStart: Date;
periodEnd: Date;
context: DailyAvailabilityContext | 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) {
const baseHours = getDailyAvailabilityHours(input.availability, cursor);
if (baseHours > 0) {
hours += baseHours * calculateDayAvailabilityFraction(input.context, toIsoDate(cursor));
}
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
return hours;
}
export function calculateEffectiveAllocationHours(input: {
availability: WeekdayAvailability;
startDate: Date;
endDate: Date;
hoursPerDay: number;
periodStart: Date;
periodEnd: Date;
context: DailyAvailabilityContext | undefined;
}): number {
let hours = 0;
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;
}
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 baseHours = getDailyAvailabilityHours(input.availability, cursor);
if (baseHours > 0) {
hours += input.hoursPerDay * calculateDayAvailabilityFraction(input.context, toIsoDate(cursor));
}
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
return hours;
}
export function calculateEffectiveAllocationCostCents(input: {
availability: WeekdayAvailability;
startDate: Date;
endDate: Date;
dailyCostCents: number;
periodStart: Date;
periodEnd: Date;
context: DailyAvailabilityContext | undefined;
}): number {
let costCents = 0;
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;
}
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 baseHours = getDailyAvailabilityHours(input.availability, cursor);
if (baseHours > 0) {
costCents += input.dailyCostCents * calculateDayAvailabilityFraction(input.context, toIsoDate(cursor));
}
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
return Math.round(costCents);
}
export function enumerateIsoDates(
periodStart: Date,
periodEnd: Date,
): string[] {
const dates: string[] = [];
const cursor = new Date(periodStart);
cursor.setUTCHours(0, 0, 0, 0);
const end = new Date(periodEnd);
end.setUTCHours(0, 0, 0, 0);
while (cursor <= end) {
dates.push(toIsoDate(cursor));
cursor.setTime(cursor.getTime() + MILLISECONDS_PER_DAY);
}
return dates;
}
export type { DailyAvailabilityContext, ResourceHolidayProfile };
export {
calculateEffectiveAllocationCostCents,
calculateEffectiveAvailableHours,
calculateEffectiveBookedHours as calculateEffectiveAllocationHours,
enumerateIsoDates,
loadResourceDailyAvailabilityContexts as loadDailyAvailabilityContexts,
} from "../../lib/resource-capacity.js";
export type {
ResourceCapacityProfile as ResourceHolidayProfile,
ResourceDailyAvailabilityContext as DailyAvailabilityContext,
} from "../../lib/resource-capacity.js";
+1
View File
@@ -12,6 +12,7 @@
"db:push": "node ../../scripts/with-env.mjs prisma db push --schema ./prisma/schema.prisma",
"db:migrate": "node ../../scripts/with-env.mjs prisma migrate dev --schema ./prisma/schema.prisma",
"db:migrate:deploy": "node ../../scripts/with-env.mjs prisma migrate deploy --schema ./prisma/schema.prisma",
"db:validate": "node ../../scripts/with-env.mjs prisma validate --schema ./prisma/schema.prisma",
"db:seed": "node ../../scripts/with-env.mjs tsx src/seed.ts",
"db:seed:holiday-demo-resources": "node ../../scripts/with-env.mjs tsx src/seed-holiday-demo-resources.ts",
"db:seed:holidays": "node ../../scripts/with-env.mjs tsx src/seed-holiday-calendars.ts",
+1 -1
View File
@@ -1,4 +1,4 @@
// Planarchy — Prisma Schema
// CapaKraken — Prisma Schema
// All monetary values stored as integer cents to avoid float precision issues.
generator client {
+3 -3
View File
@@ -86,11 +86,11 @@ test("assertDestructiveDbAllowed rejects missing destructive allow flag", () =>
);
});
test("assertSafeSeedTarget rejects legacy planarchy disposable databases", () => {
test("assertSafeSeedTarget rejects unexpected legacy disposable databases", () => {
setEnv({
DATABASE_URL: "postgresql://tester:secret@localhost:5432/planarchy_test",
DATABASE_URL: "postgresql://tester:secret@localhost:5432/legacy_test",
ALLOW_DESTRUCTIVE_DB_TOOLS: "true",
CONFIRM_DESTRUCTIVE_DB_NAME: "planarchy_test",
CONFIRM_DESTRUCTIVE_DB_NAME: "legacy_test",
});
assert.throws(
+1 -1
View File
@@ -6,7 +6,7 @@ interface DestructiveGuardOptions {
requireConfirmation?: boolean;
}
const PROTECTED_DATABASE_NAMES = new Set(["capakraken", "planarchy"]);
const PROTECTED_DATABASE_NAMES = new Set(["capakraken"]);
function parseDatabaseUrl(rawUrl: string) {
const parsed = new URL(rawUrl);
+4 -4
View File
@@ -1,5 +1,5 @@
/**
* Generate samples/PlanarchyExamples.xlsx from the live database.
* Generate samples/CapaKrakenExamples.xlsx from the live database.
*
* Run from repo root:
* DATABASE_URL=postgresql://capakraken:capakraken_dev@localhost:5433/capakraken \
@@ -334,7 +334,7 @@ async function buildSummarySheet(wb: ExcelJS.Workbook) {
] as ExcelJS.Column[];
const title = ws.getCell("A1");
title.value = "Planarchy — Seed Data Summary";
title.value = "CapaKraken — Seed Data Summary";
title.font = { bold: true, size: 14, color: { argb: COLORS.headerBg } };
ws.mergeCells("A1:B1");
ws.getRow(1).height = 28;
@@ -367,7 +367,7 @@ async function main() {
console.log("Connecting to database...");
const wb = new ExcelJS.Workbook();
wb.creator = "Planarchy";
wb.creator = "CapaKraken";
wb.created = new Date();
wb.modified = new Date();
@@ -377,7 +377,7 @@ async function main() {
await buildProjectsSheet(wb);
await buildAllocationsSheet(wb);
const outPath = path.resolve(__dirname, "../../../samples/PlanarchyExamples.xlsx");
const outPath = path.resolve(__dirname, "../../../samples/CapaKrakenExamples.xlsx");
await wb.xlsx.writeFile(outPath);
console.log(`Excel written to: ${outPath}`);
}
+1 -1
View File
@@ -420,7 +420,7 @@ export async function runImportDispoBatch(options: ImportDispoBatchOptions) {
ensureCommitAllowed(options, stageResult.readiness);
console.log("");
console.log("Committing staged rows into live Planarchy tables...");
console.log("Committing staged rows into live CapaKraken tables...");
const commitResult = await dispoImport.commitDispoImportBatch(prisma, {
allowTbdUnresolved: options.allowTbdUnresolved,
+1 -1
View File
@@ -28,7 +28,7 @@ function parseArgs(argv: string[]): ResetOptions {
backupDir: DEFAULT_BACKUP_DIR,
adminEmail: "admin@capakraken.dev",
adminPassword: "admin123",
adminName: "Planarchy Admin",
adminName: "CapaKraken Admin",
};
for (let index = 0; index < argv.length; index += 1) {
+36 -36
View File
@@ -1,7 +1,7 @@
/**
* Updates PlanarchyExamples.xlsx with missing columns and documentation.
* Adds: Display Name, Email, Skills, Planarchy Notes columns to EID sheet.
* Adds: Start Date, End Date, Status, Planarchy Notes columns to Projects sheet.
* Updates CapaKrakenExamples.xlsx with missing columns and documentation.
* Adds: Display Name, Email, Skills, CapaKraken Notes columns to EID sheet.
* Adds: Start Date, End Date, Status, CapaKraken Notes columns to Projects sheet.
*/
import ExcelJS from "exceljs";
@@ -11,7 +11,7 @@ import { dirname, join } from "path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const EXCEL_PATH = join(__dirname, "../../../samples/PlanarchyExamples.xlsx");
const EXCEL_PATH = join(__dirname, "../../../samples/CapaKrakenExamples.xlsx");
// ─── Helpers ─────────────────────────────────────────────────────────────────
@@ -23,7 +23,7 @@ function toDisplayName(eid) {
}
function toEmail(eid) {
return `${eid}@planarchy.example`;
return `${eid}@capakraken.example`;
}
function computeSkillLabel(chapter, typeOfWork) {
@@ -36,8 +36,8 @@ function computeSkillLabel(chapter, typeOfWork) {
return typeOfWork;
}
function computePlanarchyEid(eid) {
// In Planarchy the EID stays as firstname.lastname (unique key)
function computeCapaKrakenEid(eid) {
// In CapaKraken the EID stays as firstname.lastname (unique key)
return eid;
}
@@ -139,28 +139,28 @@ async function main() {
// Column N: Display Name
// Column O: Email
// Column P: Skills (derived)
// Column Q: Description / Notes for Planarchy
// Column Q: Description / Notes for CapaKraken
const newEidCols = [
{
col: 14, // N
header: "Display Name\n(auto-generated)",
doc: "Full display name derived from EID (firstname.lastname → Firstname Lastname). Used as the person's name in Planarchy.",
doc: "Full display name derived from EID (firstname.lastname → Firstname Lastname). Used as the person's name in CapaKraken.",
},
{
col: 15, // O
header: "Email\n(generated)",
doc: "Generated email: firstname.lastname@planarchy.example. Required unique field in Planarchy. Replace with real email in production.",
doc: "Generated email: firstname.lastname@capakraken.example. Required unique field in CapaKraken. Replace with real email in production.",
},
{
col: 16, // P
header: "Skills\n(derived from Chapter)",
doc: "Skill tags assigned based on Chapter + Type of Work. Format: 'SkillA | SkillB'. Stored as JSON array in Planarchy with proficiency 1-5. Senior (LCR 118) 5, Mid-Senior (LCR 95) 4, Mid 3.",
doc: "Skill tags assigned based on Chapter + Type of Work. Format: 'SkillA | SkillB'. Stored as JSON array in CapaKraken with proficiency 1-5. Senior (LCR >= 118) -> 5, Mid-Senior (LCR >= 95) -> 4, Mid -> 3.",
},
{
col: 17, // Q
header: "Planarchy Notes",
doc: "How data maps to Planarchy:\n EID = unique key (col A)\n Chapter = chapter field\n LCR / UCR multiply by 100 for integer cents (85.00 8500)\n Hours fraction × 8 = daily availability hours\n Chargeability multiply by 100 for % (0.75 75%)\n Employee type, City, Client Unit stored in dynamicFields JSONB",
header: "CapaKraken Notes",
doc: "How data maps to CapaKraken:\n- EID = unique key (col A)\n- Chapter = chapter field\n- LCR / UCR -> multiply by 100 for integer cents (EUR85.00 -> 8500)\n- Hours fraction x 8 = daily availability hours\n- Chargeability -> multiply by 100 for % (0.75 -> 75%)\n- Employee type, City, Client Unit -> stored in dynamicFields JSONB",
},
];
@@ -214,18 +214,18 @@ async function main() {
// Also add doc notes to existing header columns A-M in row 1
const eidExistingDocs = [
"Unique identifier. Used as EID in Planarchy (no EMP-XXX prefix needed). e.g. steve.rogers",
"Team / department. Maps to 'chapter' field in Planarchy.",
"Unique identifier. Used as EID in CapaKraken (no EMP-XXX prefix needed). e.g. steve.rogers",
"Team / department. Maps to 'chapter' field in CapaKraken.",
"Specialization within chapter. Stored in dynamicFields.workType.",
"Assigned client account. Stored in dynamicFields.clientUnit.",
"Unit-specific field. Currently unused — can be stored in dynamicFields.",
"Office city location. Stored in dynamicFields.city.",
"Employment type: Employee or Freelancer. Stored in dynamicFields.employeeType.",
"Loaded Cost Rate (LCR) in EUR/h. Multiply × 100 for Planarchy cents. e.g. 133.77 → 13377",
"Loaded Cost Rate (LCR) in EUR/h. Multiply × 100 for CapaKraken cents. e.g. 133.77 → 13377",
"Unloaded/Utilization Cost Rate (UCR) in EUR/h. Multiply × 100 for cents.",
"FTE fraction (1.0 = 40h/week, 0.8 = 4 days, 0.5 = 20h/week). Combined with col K for availability JSON.",
"Available weekdays. 'all' = Mon-Fri. Specific days listed = only those days active at 8h.",
"Chargeability target as decimal. Multiply × 100 for Planarchy % (0.75 → 75%).",
"Chargeability target as decimal. Multiply × 100 for CapaKraken % (0.75 → 75%).",
"(unused)",
];
for (let c = 1; c <= 13; c++) {
@@ -254,12 +254,12 @@ async function main() {
{
col: 19, // S
header: "Status\n(derived)",
doc: "Planarchy status derived from 'is ordered' + win probability + date:\n• COMPLETED: ordered + 100% + past dates\n• ACTIVE: ordered + 100% + current/future\n• ON_HOLD: ordered but paused\n• DRAFT: not ordered or low win probability",
doc: "CapaKraken status derived from 'is ordered' + win probability + date:\n• COMPLETED: ordered + 100% + past dates\n• ACTIVE: ordered + 100% + current/future\n• ON_HOLD: ordered but paused\n• DRAFT: not ordered or low win probability",
},
{
col: 20, // T
header: "Planarchy Notes",
doc: "How data maps to Planarchy:\n• Col C (short code) → shortCode (unique key)\n• Col B → name\n• BD/CH/UN → OrderType: BD / CHARGEABLE / INTERNAL\n• Internal/External → allocationType: INT / EXT\n• Resource Costs (col I) × 100 = budgetCents in Planarchy\n• Col H (chargability %) → stored in dynamicFields.chargeabilityPercent\n• Col J (person hours) → stored in dynamicFields.personHoursSold\n• Col O (classification) → stored in dynamicFields.classification",
header: "CapaKraken Notes",
doc: "How data maps to CapaKraken:\n• Col C (short code) → shortCode (unique key)\n• Col B → name\n• BD/CH/UN → OrderType: BD / CHARGEABLE / INTERNAL\n• Internal/External → allocationType: INT / EXT\n• Resource Costs (col I) × 100 = budgetCents in CapaKraken\n• Col H (chargability %) → stored in dynamicFields.chargeabilityPercent\n• Col J (person hours) → stored in dynamicFields.personHoursSold\n• Col O (classification) → stored in dynamicFields.classification",
},
];
@@ -286,21 +286,21 @@ async function main() {
const projExistingDocs = [
"Client Unit tag. e.g. [DAI]=Daimler, [PAG]=Porsche AG, [BMW], [JLR]=Jaguar Land Rover. Stored in dynamicFields.clientUnit.",
"Full project name → maps to Planarchy 'name' field.",
"Short internal project code (5-6 chars) → maps to Planarchy 'shortCode' (unique key). e.g. JLFJFL",
"Full project name → maps to CapaKraken 'name' field.",
"Short internal project code (5-6 chars) → maps to CapaKraken 'shortCode' (unique key). e.g. JLFJFL",
"'yes'/'no' — whether the project is formally ordered. Drives status: yes+100% → ACTIVE/COMPLETED.",
"Order type: BD=Business Development, CH=Chargeable, UN=Internal/Unordered. Maps to Planarchy OrderType.",
"Win probability 0-100. Used in Planarchy 'winProbability' field for pipeline forecasting.",
"Allocation type: Internal → INT, External → EXT. Maps to Planarchy 'allocationType' field.",
"Order type: BD=Business Development, CH=Chargeable, UN=Internal/Unordered. Maps to CapaKraken OrderType.",
"Win probability 0-100. Used in CapaKraken 'winProbability' field for pipeline forecasting.",
"Allocation type: Internal → INT, External → EXT. Maps to CapaKraken 'allocationType' field.",
"Chargeability % as decimal. Stored in dynamicFields.chargeabilityPercent.",
"Planned resource cost in EUR. Multiply × 100 for Planarchy budgetCents. e.g. 78799 → 7879900 cents.",
"Planned resource cost in EUR. Multiply × 100 for CapaKraken budgetCents. e.g. 78799 → 7879900 cents.",
"Person hours planned/sold. Stored in dynamicFields.personHoursSold for budget tracking.",
"Team staffing (empty in source). Would list assigned EIDs. Handled by Allocations in Planarchy.",
"Team staffing (empty in source). Would list assigned EIDs. Handled by Allocations in CapaKraken.",
"Day project sold (empty in source). Could be stored as dynamicFields.dateSold.",
"Project start date (empty in source → synthesized in col Q). Maps to Planarchy 'startDate'.",
"Project end date (empty in source → synthesized in col R). Maps to Planarchy 'endDate'.",
"Project start date (empty in source → synthesized in col Q). Maps to CapaKraken 'startDate'.",
"Project end date (empty in source → synthesized in col R). Maps to CapaKraken 'endDate'.",
"Confidentiality: Confidential / Not Confidential. Stored in dynamicFields.classification.",
"Responsible EID / Owner (empty in source). Would map to a PM allocation in Planarchy.",
"Responsible EID / Owner (empty in source). Would map to a PM allocation in CapaKraken.",
];
for (let c = 1; c <= 16; c++) {
const doc = projExistingDocs[c - 1];
@@ -329,15 +329,15 @@ async function main() {
projSheet.getColumn(19).width = 16;
projSheet.getColumn(20).width = 50;
// ─── Add a "Planarchy Data Model" sheet ──────────────────────────────────
// ─── Add a "CapaKraken Data Model" sheet ──────────────────────────────────
let modelSheet = workbook.getWorksheet("Planarchy Data Model");
let modelSheet = workbook.getWorksheet("CapaKraken Data Model");
if (!modelSheet) {
modelSheet = workbook.addWorksheet("Planarchy Data Model");
modelSheet = workbook.addWorksheet("CapaKraken Data Model");
}
const modelData = [
["Planarchy Data Model — Field Reference", "", "", "", ""],
["CapaKraken Data Model — Field Reference", "", "", "", ""],
["", "", "", "", ""],
["RESOURCE FIELDS", "", "", "", ""],
["Field", "Type", "Required", "Example", "Description"],
@@ -393,7 +393,7 @@ async function main() {
// Style title row
const titleCell = modelSheet.getCell(1, 1);
titleCell.font = { bold: true, size: 14, color: { argb: "FF4F46E5" } };
titleCell.value = "Planarchy Data Model — Field Reference";
titleCell.value = "CapaKraken Data Model — Field Reference";
// Style section headers and field headers
const sectionRows = [3, 17, 31];
@@ -421,7 +421,7 @@ async function main() {
console.log(`✅ Excel updated: ${EXCEL_PATH}`);
console.log(" - EID_Informationen: added Display Name, Email, Skills, Notes columns");
console.log(" - Projektinfomartionen: added Start Date, End Date, Status, Notes columns");
console.log(" - Added new sheet: 'Planarchy Data Model' (field reference)");
console.log(" - Added new sheet: 'CapaKraken Data Model' (field reference)");
}
main().catch((err) => {