feat(platform): harden access scoping and delivery baseline

This commit is contained in:
2026-03-30 00:27:31 +02:00
parent 00b936fa1f
commit 819345acfa
109 changed files with 26142 additions and 8081 deletions
@@ -1,7 +1,12 @@
import { AllocationStatus, SystemRole } from "@capakraken/shared";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { allocationRouter } from "../router/allocation.js";
import { emitAllocationCreated, emitAllocationDeleted, emitNotificationCreated } from "../sse/event-bus.js";
import { checkBudgetThresholds } from "../lib/budget-alerts.js";
import { generateAutoSuggestions } from "../lib/auto-staffing.js";
import { invalidateDashboardCache } from "../lib/cache.js";
import { logger } from "../lib/logger.js";
import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
import { createCallerFactory } from "../trpc.js";
vi.mock("../sse/event-bus.js", () => ({
@@ -19,12 +24,29 @@ vi.mock("../lib/cache.js", () => ({
invalidateDashboardCache: vi.fn(),
}));
vi.mock("../lib/auto-staffing.js", () => ({
generateAutoSuggestions: vi.fn(),
}));
vi.mock("../lib/webhook-dispatcher.js", () => ({
dispatchWebhooks: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../lib/logger.js", () => ({
logger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
},
}));
const createCaller = createCallerFactory(allocationRouter);
beforeEach(() => {
vi.clearAllMocks();
});
function createManagerCaller(db: Record<string, unknown>) {
return createCaller({
session: {
@@ -112,6 +134,9 @@ describe("allocation entry resolution router", () => {
},
]),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createManagerCaller(db);
@@ -134,6 +159,97 @@ describe("allocation entry resolution router", () => {
});
});
it("returns the canonical resource availability summary shape", async () => {
const db = {
resource: {
findUnique: vi.fn().mockResolvedValue({
id: "resource_1",
displayName: "Bruce Banner",
eid: "E-001",
fte: 1,
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
saturday: 0,
sunday: 0,
},
countryId: "country_de",
federalState: "BY",
metroCityId: null,
country: { dailyWorkingHours: 8, code: "DE" },
metroCity: null,
}),
},
assignment: {
findMany: vi.fn().mockResolvedValue([
{
id: "assignment_1",
startDate: new Date("2026-04-01T00:00:00.000Z"),
endDate: new Date("2026-04-01T00:00:00.000Z"),
hoursPerDay: 4,
status: "CONFIRMED",
project: { name: "Gelddruckmaschine", shortCode: "GDM" },
},
]),
},
vacation: {
findMany: vi.fn().mockResolvedValue([
{
id: "vac_1",
type: "ANNUAL",
status: "APPROVED",
startDate: new Date("2026-04-02T00:00:00.000Z"),
endDate: new Date("2026-04-02T00:00:00.000Z"),
isHalfDay: true,
halfDayPart: "AFTERNOON",
},
]),
},
};
const caller = createManagerCaller(db);
const result = await caller.getResourceAvailabilitySummary({
resourceId: "resource_1",
startDate: new Date("2026-04-01T00:00:00.000Z"),
endDate: new Date("2026-04-02T00:00:00.000Z"),
hoursPerDay: 8,
});
expect(result).toEqual({
resource: "Bruce Banner",
period: "2026-04-01 to 2026-04-02",
fte: null,
workingDays: 2,
periodAvailableHours: 16,
periodBookedHours: 4,
periodRemainingHours: 12,
maxHoursPerDay: 8,
currentBookedHoursPerDay: 2,
availableHoursPerDay: 6,
isFullyAvailable: false,
existingAllocations: [
{
project: "Gelddruckmaschine (GDM)",
hoursPerDay: 4,
status: "CONFIRMED",
start: "2026-04-01",
end: "2026-04-01",
},
],
vacations: [
{
type: "ANNUAL",
start: "2026-04-02",
end: "2026-04-02",
isHalfDay: true,
},
],
});
});
it("creates an open demand through allocation.create without requiring isPlaceholder", async () => {
const createdDemandRequirement = {
id: "demand_1",
@@ -346,6 +462,217 @@ describe("allocation entry resolution router", () => {
expect(emitNotificationCreated).toHaveBeenCalledTimes(2);
});
it("creates a canonical demand draft with router-owned defaults", async () => {
vi.mocked(emitAllocationCreated).mockClear();
vi.mocked(emitNotificationCreated).mockClear();
const createdDemandRequirement = {
id: "demand_draft_1",
projectId: "project_1",
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-15"),
hoursPerDay: 6,
percentage: 75,
role: "Designer",
roleId: "role_design",
headcount: 2,
status: AllocationStatus.PROPOSED,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
roleEntity: { id: "role_design", name: "Designer", color: "#0099FF" },
};
const db = createDemandWorkflowDb({
demandRequirement: {
create: vi.fn().mockResolvedValue(createdDemandRequirement),
},
}) as Record<string, unknown>;
Object.assign(db, {
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
});
const caller = createManagerCaller(db);
const result = await caller.createDemand({
projectId: "project_1",
role: "Designer",
roleId: "role_design",
headcount: 2,
hoursPerDay: 6,
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-15"),
});
expect(result.id).toBe("demand_draft_1");
expect(db.demandRequirement.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
headcount: 2,
percentage: 75,
status: AllocationStatus.PROPOSED,
metadata: {},
}),
}),
);
expect(emitAllocationCreated).toHaveBeenCalledWith({
id: "demand_draft_1",
projectId: "project_1",
resourceId: null,
});
});
it("logs and swallows background side-effect failures during demand creation", async () => {
vi.mocked(invalidateDashboardCache).mockRejectedValueOnce(new Error("redis unavailable"));
vi.mocked(checkBudgetThresholds).mockRejectedValueOnce(new Error("budget alerts unavailable"));
vi.mocked(generateAutoSuggestions).mockRejectedValueOnce(new Error("auto suggestions unavailable"));
const createdDemandRequirement = {
id: "demand_safe_1",
projectId: "project_1",
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-15"),
hoursPerDay: 6,
percentage: 75,
role: "Designer",
roleId: "role_design",
headcount: 2,
status: AllocationStatus.PROPOSED,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
roleEntity: { id: "role_design", name: "Designer", color: "#0099FF" },
};
const db = createDemandWorkflowDb({
demandRequirement: {
create: vi.fn().mockResolvedValue(createdDemandRequirement),
},
}) as Record<string, unknown>;
Object.assign(db, {
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
});
const caller = createManagerCaller(db);
const result = await caller.createDemand({
projectId: "project_1",
role: "Designer",
roleId: "role_design",
headcount: 2,
hoursPerDay: 6,
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-15"),
});
await Promise.resolve();
await Promise.resolve();
expect(result.id).toBe("demand_safe_1");
expect(vi.mocked(logger.error)).toHaveBeenCalledWith(
expect.objectContaining({ effectName: "invalidateDashboardCache" }),
"Allocation background side effect failed",
);
expect(vi.mocked(logger.error)).toHaveBeenCalledWith(
expect.objectContaining({ effectName: "checkBudgetThresholds", projectId: "project_1" }),
"Allocation background side effect failed",
);
expect(vi.mocked(logger.error)).toHaveBeenCalledWith(
expect.objectContaining({ effectName: "generateAutoSuggestions", demandRequirementId: "demand_safe_1" }),
"Allocation background side effect failed",
);
});
it("logs and swallows background webhook failures during allocation creation", async () => {
vi.mocked(dispatchWebhooks).mockRejectedValueOnce(new Error("webhook unavailable"));
const createdAssignment = {
id: "assignment_safe_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: "Compositor",
roleId: "role_comp",
dailyCostCents: 40000,
status: AllocationStatus.ACTIVE,
metadata: {},
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: "Project One", shortCode: "PRJ" },
roleEntity: { id: "role_comp", name: "Compositor", color: "#111111" },
demandRequirement: null,
};
const db = {
project: {
findUnique: vi.fn().mockResolvedValue({ id: "project_1" }),
},
resource: {
findUnique: vi.fn().mockResolvedValue({
id: "resource_1",
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 caller = createManagerCaller(db);
const result = await caller.create({
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 8,
percentage: 100,
role: "Compositor",
roleId: "role_comp",
status: AllocationStatus.ACTIVE,
metadata: {},
});
await Promise.resolve();
await Promise.resolve();
expect(result.id).toBe("assignment_safe_1");
expect(vi.mocked(logger.error)).toHaveBeenCalledWith(
expect.objectContaining({ effectName: "dispatchWebhooks", event: "allocation.created" }),
"Allocation background side effect failed",
);
});
it("creates an explicit assignment without dual-writing a legacy allocation row", async () => {
vi.mocked(emitAllocationCreated).mockClear();
@@ -442,6 +769,121 @@ describe("allocation entry resolution router", () => {
});
});
it("assigns a resource to demand and returns the hydrated demand view", async () => {
const demandView = {
id: "demand_1",
projectId: "project_1",
startDate: new Date("2026-05-01T00:00:00.000Z"),
endDate: new Date("2026-05-15T00:00:00.000Z"),
hoursPerDay: 6,
percentage: 75,
role: "Designer",
roleId: "role_1",
headcount: 1,
status: AllocationStatus.PROPOSED,
metadata: {},
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
roleEntity: { id: "role_1", name: "Designer", color: "#00AAFF" },
assignments: [],
};
const createdAssignment = {
id: "assignment_1",
demandRequirementId: "demand_1",
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-05-01T00:00:00.000Z"),
endDate: new Date("2026-05-15T00:00:00.000Z"),
hoursPerDay: 6,
percentage: 75,
role: "Designer",
roleId: "role_1",
dailyCostCents: 42000,
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: 7000,
},
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
roleEntity: { id: "role_1", name: "Designer", color: "#00AAFF" },
demandRequirement: demandView,
};
const db = {
project: {
findUnique: vi.fn().mockResolvedValue({ id: "project_1" }),
},
resource: {
findUnique: vi.fn().mockResolvedValue({
id: "resource_1",
lcrCents: 7000,
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
saturday: 0,
sunday: 0,
},
}),
},
demandRequirement: {
findUnique: vi.fn()
.mockResolvedValueOnce({
id: "demand_1",
projectId: "project_1",
startDate: new Date("2026-05-01T00:00:00.000Z"),
endDate: new Date("2026-05-15T00:00:00.000Z"),
hoursPerDay: 6,
role: "Designer",
roleId: "role_1",
headcount: 1,
status: AllocationStatus.PROPOSED,
metadata: {},
})
.mockResolvedValueOnce({
id: "demand_1",
projectId: "project_1",
})
.mockResolvedValueOnce(demandView),
update: vi.fn().mockResolvedValue({
id: "demand_1",
projectId: "project_1",
headcount: 1,
status: AllocationStatus.COMPLETED,
}),
},
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 caller = createManagerCaller(db);
const result = await caller.assignResourceToDemand({
demandRequirementId: "demand_1",
resourceId: "resource_1",
});
expect(result.assignment.id).toBe("assignment_1");
expect(result.demandRequirement.project.shortCode).toBe("PRJ");
expect(result.demandRequirement.roleEntity?.name).toBe("Designer");
expect(db.assignment.create).toHaveBeenCalledTimes(1);
});
it("deletes an explicit demand requirement without routing through allocation.delete", async () => {
vi.mocked(emitAllocationDeleted).mockClear();
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PermissionKey, SystemRole, type PermissionKey as PermissionKeyValue } from "@capakraken/shared";
import { apiRateLimiter } from "../middleware/rate-limit.js";
import {
ASSISTANT_CONFIRMATION_PREFIX,
canExecuteMutationTool,
@@ -9,6 +10,7 @@ import {
getAvailableAssistantTools,
listPendingAssistantApprovals,
peekPendingAssistantApproval,
selectAssistantToolsForRequest,
} from "../router/assistant.js";
import { TOOL_DEFINITIONS } from "../router/assistant-tools.js";
@@ -19,6 +21,19 @@ function getToolNames(
return getAvailableAssistantTools(new Set(permissions), userRole).map((tool) => tool.function.name);
}
function getSelectedToolNames(
permissions: PermissionKeyValue[],
messages: Array<{ role: "user" | "assistant"; content: string }>,
userRole: SystemRole = SystemRole.ADMIN,
pageContext?: string,
) {
return selectAssistantToolsForRequest(
getAvailableAssistantTools(new Set(permissions), userRole),
messages,
pageContext,
).map((tool) => tool.function.name);
}
const TEST_USER_ID = "assistant-test-user";
const TEST_CONVERSATION_ID = "assistant-test-conversation";
@@ -174,11 +189,22 @@ function createApprovalStoreMock() {
};
}
function createMissingApprovalTableError() {
return Object.assign(
new Error("The table `public.assistant_approvals` does not exist in the current database."),
{
code: "P2021",
meta: { table: "public.assistant_approvals" },
},
);
}
describe("assistant router tool gating", () => {
let approvalStore = createApprovalStoreMock();
beforeEach(() => {
approvalStore = createApprovalStoreMock();
apiRateLimiter.reset();
});
it("hides advanced tools unless the dedicated assistant permission is granted", () => {
@@ -195,12 +221,115 @@ describe("assistant router tool gating", () => {
expect(withAdvanced).toContain("get_project_computation_graph");
});
it("keeps user administration tools behind manageUsers", () => {
const withoutManageUsers = getToolNames([]);
const withManageUsers = getToolNames([PermissionKey.MANAGE_USERS]);
it("keeps user self-service tools available to plain authenticated users", () => {
const userNames = getToolNames([], SystemRole.USER);
expect(withoutManageUsers).not.toContain("list_users");
expect(withManageUsers).toContain("list_users");
expect(userNames).toContain("get_current_user");
expect(userNames).toContain("get_dashboard_layout");
expect(userNames).toContain("save_dashboard_layout");
expect(userNames).toContain("get_favorite_project_ids");
expect(userNames).toContain("toggle_favorite_project");
expect(userNames).toContain("get_column_preferences");
expect(userNames).toContain("set_column_preferences");
expect(userNames).toContain("get_mfa_status");
expect(userNames).toContain("list_notifications");
expect(userNames).toContain("get_unread_notification_count");
expect(userNames).toContain("list_tasks");
expect(userNames).toContain("get_task_counts");
expect(userNames).toContain("create_reminder");
expect(userNames).toContain("list_reminders");
expect(userNames).toContain("update_reminder");
expect(userNames).toContain("delete_reminder");
});
it("keeps admin-only user tools hidden from non-admin roles", () => {
const adminNames = getToolNames([], SystemRole.ADMIN);
const managerNames = getToolNames([], SystemRole.MANAGER);
const userNames = getToolNames([], SystemRole.USER);
expect(adminNames).toContain("list_users");
expect(adminNames).toContain("get_active_user_count");
expect(adminNames).toContain("create_user");
expect(adminNames).toContain("set_user_password");
expect(adminNames).toContain("update_user_role");
expect(adminNames).toContain("update_user_name");
expect(adminNames).toContain("link_user_resource");
expect(adminNames).toContain("auto_link_users_by_email");
expect(adminNames).toContain("set_user_permissions");
expect(adminNames).toContain("reset_user_permissions");
expect(adminNames).toContain("get_effective_user_permissions");
expect(adminNames).toContain("disable_user_totp");
expect(managerNames).not.toContain("list_users");
expect(managerNames).not.toContain("create_user");
expect(managerNames).not.toContain("set_user_permissions");
expect(managerNames).not.toContain("disable_user_totp");
expect(userNames).not.toContain("list_users");
expect(userNames).not.toContain("get_active_user_count");
expect(userNames).not.toContain("create_user");
expect(userNames).not.toContain("set_user_password");
expect(userNames).not.toContain("update_user_role");
expect(userNames).not.toContain("update_user_name");
expect(userNames).not.toContain("link_user_resource");
expect(userNames).not.toContain("auto_link_users_by_email");
expect(userNames).not.toContain("set_user_permissions");
expect(userNames).not.toContain("reset_user_permissions");
expect(userNames).not.toContain("get_effective_user_permissions");
expect(userNames).not.toContain("disable_user_totp");
});
it("caps the OpenAI tool payload to 128 definitions even for fully privileged admins", () => {
const allPermissions = Object.values(PermissionKey);
const selectedNames = getSelectedToolNames(
allPermissions,
[{ role: "user", content: "Bitte gib mir einen Überblick über das System." }],
SystemRole.ADMIN,
);
expect(selectedNames.length).toBeLessThanOrEqual(128);
expect(selectedNames).toContain("get_current_user");
expect(selectedNames).toContain("search_resources");
expect(selectedNames).toContain("search_projects");
});
it("prioritizes holiday and resource tools for German holiday questions", () => {
const allPermissions = Object.values(PermissionKey);
const selectedNames = getSelectedToolNames(
allPermissions,
[{ role: "user", content: "Kannst du mir alle Feiertage nennen, die Peter Parker in 2026 zustehen?" }],
SystemRole.ADMIN,
);
expect(selectedNames.length).toBeLessThanOrEqual(128);
expect(selectedNames).toContain("search_resources");
expect(selectedNames).toContain("get_resource");
expect(selectedNames).toContain("get_resource_holidays");
expect(selectedNames).toContain("list_holidays_by_region");
expect(selectedNames).toContain("list_holiday_calendars");
});
it("keeps assignable users and manager notification lifecycle tools behind manager/admin role", () => {
const managerNames = getToolNames([], SystemRole.MANAGER);
const adminNames = getToolNames([], SystemRole.ADMIN);
const userNames = getToolNames([], SystemRole.USER);
expect(managerNames).toContain("list_assignable_users");
expect(managerNames).toContain("create_notification");
expect(managerNames).toContain("create_task_for_user");
expect(managerNames).toContain("assign_task");
expect(managerNames).toContain("send_broadcast");
expect(managerNames).toContain("list_broadcasts");
expect(managerNames).toContain("get_broadcast_detail");
expect(adminNames).toContain("list_assignable_users");
expect(adminNames).toContain("create_task_for_user");
expect(adminNames).toContain("send_broadcast");
expect(userNames).not.toContain("list_assignable_users");
expect(userNames).not.toContain("create_notification");
expect(userNames).not.toContain("create_task_for_user");
expect(userNames).not.toContain("assign_task");
expect(userNames).not.toContain("send_broadcast");
expect(userNames).not.toContain("list_broadcasts");
expect(userNames).not.toContain("get_broadcast_detail");
});
it("continues to hide cost-aware advanced tools when viewCosts is missing", () => {
@@ -273,6 +402,66 @@ describe("assistant router tool gating", () => {
expect(missingAdvancedNames).not.toContain("quick_assign_timeline_resource");
});
it("keeps estimate lifecycle mutations behind manager/admin role and their router permissions", () => {
const managerProjectNames = getToolNames([PermissionKey.MANAGE_PROJECTS], SystemRole.MANAGER);
const managerAllocationNames = getToolNames([PermissionKey.MANAGE_ALLOCATIONS], SystemRole.MANAGER);
const userProjectNames = getToolNames([PermissionKey.MANAGE_PROJECTS], SystemRole.USER);
expect(managerProjectNames).toContain("create_estimate");
expect(managerProjectNames).toContain("clone_estimate");
expect(managerProjectNames).toContain("update_estimate_draft");
expect(managerProjectNames).toContain("submit_estimate_version");
expect(managerProjectNames).toContain("approve_estimate_version");
expect(managerProjectNames).toContain("create_estimate_revision");
expect(managerProjectNames).toContain("create_estimate_export");
expect(managerProjectNames).toContain("generate_estimate_weekly_phasing");
expect(managerProjectNames).toContain("update_estimate_commercial_terms");
expect(managerProjectNames).not.toContain("create_estimate_planning_handoff");
expect(managerAllocationNames).toContain("create_estimate_planning_handoff");
expect(managerAllocationNames).not.toContain("create_estimate");
expect(userProjectNames).not.toContain("create_estimate");
expect(userProjectNames).not.toContain("clone_estimate");
expect(userProjectNames).not.toContain("update_estimate_draft");
expect(userProjectNames).not.toContain("submit_estimate_version");
expect(userProjectNames).not.toContain("approve_estimate_version");
expect(userProjectNames).not.toContain("create_estimate_revision");
expect(userProjectNames).not.toContain("create_estimate_export");
expect(userProjectNames).not.toContain("generate_estimate_weekly_phasing");
expect(userProjectNames).not.toContain("update_estimate_commercial_terms");
expect(userProjectNames).not.toContain("create_estimate_planning_handoff");
});
it("keeps estimate read tools aligned to controller/manager/admin visibility and cost requirements", () => {
const controllerNames = getToolNames([PermissionKey.VIEW_COSTS], SystemRole.CONTROLLER);
const controllerWithoutCosts = getToolNames([], SystemRole.CONTROLLER);
const managerNames = getToolNames([PermissionKey.VIEW_COSTS], SystemRole.MANAGER);
const managerWithoutCosts = getToolNames([], SystemRole.MANAGER);
const userNames = getToolNames([PermissionKey.VIEW_COSTS], SystemRole.USER);
expect(controllerNames).toContain("get_estimate_detail");
expect(controllerNames).toContain("list_estimate_versions");
expect(controllerNames).toContain("get_estimate_version_snapshot");
expect(controllerNames).toContain("get_estimate_weekly_phasing");
expect(controllerNames).toContain("get_estimate_commercial_terms");
expect(controllerWithoutCosts).not.toContain("get_estimate_detail");
expect(controllerWithoutCosts).toContain("list_estimate_versions");
expect(controllerWithoutCosts).not.toContain("get_estimate_version_snapshot");
expect(controllerWithoutCosts).toContain("get_estimate_weekly_phasing");
expect(controllerWithoutCosts).toContain("get_estimate_commercial_terms");
expect(managerNames).toContain("get_estimate_detail");
expect(managerNames).toContain("list_estimate_versions");
expect(managerNames).toContain("get_estimate_version_snapshot");
expect(managerNames).toContain("get_estimate_weekly_phasing");
expect(managerNames).toContain("get_estimate_commercial_terms");
expect(managerWithoutCosts).toContain("list_estimate_versions");
expect(managerWithoutCosts).not.toContain("get_estimate_version_snapshot");
expect(userNames).not.toContain("get_estimate_detail");
expect(userNames).not.toContain("list_estimate_versions");
expect(userNames).not.toContain("get_estimate_version_snapshot");
expect(userNames).not.toContain("get_estimate_weekly_phasing");
expect(userNames).not.toContain("get_estimate_commercial_terms");
});
it("keeps import/dispo parity tools aligned to router roles and permissions", () => {
const managerNames = getToolNames([PermissionKey.IMPORT_DATA], SystemRole.MANAGER);
const controllerNames = getToolNames([], SystemRole.CONTROLLER);
@@ -284,11 +473,54 @@ describe("assistant router tool gating", () => {
expect(controllerNames).toContain("export_projects_csv");
expect(adminNames).toContain("list_dispo_import_batches");
expect(adminNames).toContain("get_dispo_import_batch");
expect(adminNames).toContain("stage_dispo_import_batch");
expect(adminNames).toContain("validate_dispo_import_batch");
expect(adminNames).toContain("cancel_dispo_import_batch");
expect(adminNames).toContain("list_dispo_staged_resources");
expect(adminNames).toContain("list_dispo_staged_projects");
expect(adminNames).toContain("list_dispo_staged_assignments");
expect(adminNames).toContain("list_dispo_staged_vacations");
expect(adminNames).toContain("list_dispo_staged_unresolved_records");
expect(adminNames).toContain("resolve_dispo_staged_record");
expect(adminNames).toContain("commit_dispo_import_batch");
expect(userNames).not.toContain("import_csv_data");
expect(userNames).not.toContain("export_resources_csv");
expect(userNames).not.toContain("export_projects_csv");
expect(userNames).not.toContain("list_dispo_import_batches");
expect(userNames).not.toContain("get_dispo_import_batch");
expect(userNames).not.toContain("stage_dispo_import_batch");
expect(userNames).not.toContain("validate_dispo_import_batch");
expect(userNames).not.toContain("list_dispo_staged_resources");
expect(userNames).not.toContain("commit_dispo_import_batch");
});
it("keeps settings and webhook admin tools hidden while preserving protected parity tools", () => {
const adminNames = getToolNames([], SystemRole.ADMIN);
const userNames = getToolNames([], SystemRole.USER);
expect(adminNames).toContain("get_system_settings");
expect(adminNames).toContain("update_system_settings");
expect(adminNames).toContain("test_ai_connection");
expect(adminNames).toContain("test_smtp_connection");
expect(adminNames).toContain("test_gemini_connection");
expect(adminNames).toContain("update_system_role_config");
expect(adminNames).toContain("list_webhooks");
expect(adminNames).toContain("get_webhook");
expect(adminNames).toContain("create_webhook");
expect(adminNames).toContain("update_webhook");
expect(adminNames).toContain("delete_webhook");
expect(adminNames).toContain("test_webhook");
expect(adminNames).toContain("get_ai_configured");
expect(adminNames).toContain("list_system_role_configs");
expect(userNames).not.toContain("get_system_settings");
expect(userNames).not.toContain("update_system_settings");
expect(userNames).not.toContain("test_ai_connection");
expect(userNames).not.toContain("update_system_role_config");
expect(userNames).not.toContain("list_webhooks");
expect(userNames).not.toContain("create_webhook");
expect(userNames).toContain("get_ai_configured");
expect(userNames).toContain("list_system_role_configs");
});
it("keeps holiday calendar mutation tools admin-only while leaving read tools available", () => {
@@ -506,6 +738,59 @@ describe("assistant router tool gating", () => {
expect(approvalSummaries).not.toContain("Foreign");
});
it("degrades approval reads gracefully when approval storage is missing", async () => {
const missingTableError = createMissingApprovalTableError();
const missingStore = {
assistantApproval: {
findFirst: vi.fn(async () => {
throw missingTableError;
}),
findMany: vi.fn(async () => {
throw missingTableError;
}),
create: vi.fn(async () => {
throw missingTableError;
}),
updateMany: vi.fn(async () => {
throw missingTableError;
}),
},
};
await expect(listPendingAssistantApprovals(missingStore, TEST_USER_ID)).resolves.toEqual([]);
await expect(peekPendingAssistantApproval(missingStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeNull();
await expect(consumePendingAssistantApproval(missingStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeNull();
await expect(clearPendingAssistantApproval(missingStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeUndefined();
});
it("returns an explicit error when approval storage is missing for mutation confirmation", async () => {
const missingTableError = createMissingApprovalTableError();
const missingStore = {
assistantApproval: {
findFirst: vi.fn(async () => {
throw missingTableError;
}),
findMany: vi.fn(async () => {
throw missingTableError;
}),
create: vi.fn(async () => {
throw missingTableError;
}),
updateMany: vi.fn(async () => {
throw missingTableError;
}),
},
};
await expect(createPendingAssistantApproval(
missingStore,
TEST_USER_ID,
TEST_CONVERSATION_ID,
"create_project",
JSON.stringify({ name: "Apollo" }),
)).rejects.toThrow("Assistant approval storage is unavailable");
});
it("does not require confirmation for read-only assistant tools", () => {
expect(canExecuteMutationTool([
{ role: "user", content: "Zeig mir meine Notifications" },
@@ -518,12 +803,31 @@ describe("assistant router tool gating", () => {
);
expect(toolDescriptions.get("create_estimate")).toContain("manageProjects");
expect(toolDescriptions.get("set_entitlement")).toContain("manageVacations");
expect(toolDescriptions.get("create_org_unit")).toContain("manageResources");
expect(toolDescriptions.get("update_org_unit")).toContain("manageResources");
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_estimate_planning_handoff")).toContain("manageAllocations");
expect(toolDescriptions.get("get_estimate_detail")).toContain("Controller/manager/admin");
expect(toolDescriptions.get("list_estimate_versions")).toContain("Controller/manager/admin");
expect(toolDescriptions.get("get_estimate_version_snapshot")).toContain("viewCosts");
expect(toolDescriptions.get("get_estimate_weekly_phasing")).toContain("Controller/manager/admin");
expect(toolDescriptions.get("get_estimate_commercial_terms")).toContain("Controller/manager/admin");
expect(toolDescriptions.get("create_vacation")).toContain("authenticated user");
expect(toolDescriptions.get("approve_vacation")).toContain("Manager or admin role");
expect(toolDescriptions.get("reject_vacation")).toContain("Manager or admin role");
expect(toolDescriptions.get("cancel_vacation")).toContain("Users can cancel their own requests");
expect(toolDescriptions.get("set_entitlement")).toContain("Manager or admin role");
expect(toolDescriptions.get("create_role")).toContain("manageRoles");
expect(toolDescriptions.get("update_role")).toContain("manageRoles");
expect(toolDescriptions.get("delete_role")).toContain("manageRoles");
expect(toolDescriptions.get("create_org_unit")).toContain("Admin role");
expect(toolDescriptions.get("update_org_unit")).toContain("Admin role");
expect(toolDescriptions.get("list_users")).toContain("Admin role");
expect(toolDescriptions.get("list_assignable_users")).toContain("Manager or admin role");
expect(toolDescriptions.get("get_current_user")).toContain("authenticated user's own profile");
expect(toolDescriptions.get("create_notification")).toContain("Manager or admin role");
expect(toolDescriptions.get("create_task_for_user")).toContain("Manager or admin role");
expect(toolDescriptions.get("send_broadcast")).toContain("Manager or admin role");
expect(toolDescriptions.get("get_broadcast_detail")).toContain("Manager or admin role");
expect(toolDescriptions.get("create_client")).toContain("manager or admin role");
expect(toolDescriptions.get("update_client")).toContain("manager or admin role");
expect(toolDescriptions.get("create_holiday_calendar")).toContain("Admin role");
expect(toolDescriptions.get("create_holiday_calendar_entry")).toContain("Admin role");
expect(toolDescriptions.get("query_change_history")).toContain("Controller/manager/admin");
@@ -534,6 +838,17 @@ describe("assistant router tool gating", () => {
expect(toolDescriptions.get("import_csv_data")).toContain("manager/admin");
expect(toolDescriptions.get("list_dispo_import_batches")).toContain("Admin role");
expect(toolDescriptions.get("get_dispo_import_batch")).toContain("Admin role");
expect(toolDescriptions.get("stage_dispo_import_batch")).toContain("Admin role");
expect(toolDescriptions.get("validate_dispo_import_batch")).toContain("Admin role");
expect(toolDescriptions.get("commit_dispo_import_batch")).toContain("Always confirm first");
expect(toolDescriptions.get("get_system_settings")).toContain("Admin role");
expect(toolDescriptions.get("update_system_settings")).toContain("Always confirm first");
expect(toolDescriptions.get("get_ai_configured")).toContain("authenticated user");
expect(toolDescriptions.get("list_system_role_configs")).toContain("authenticated user");
expect(toolDescriptions.get("update_system_role_config")).toContain("Admin role");
expect(toolDescriptions.get("list_webhooks")).toContain("Secrets are masked");
expect(toolDescriptions.get("create_webhook")).toContain("Always confirm first");
expect(toolDescriptions.get("test_webhook")).toContain("Always confirm first");
expect(toolDescriptions.get("list_audit_log_entries")).toContain("Controller/manager/admin");
expect(toolDescriptions.get("get_audit_log_entry")).toContain("Controller/manager/admin");
expect(toolDescriptions.get("get_audit_log_timeline")).toContain("Controller/manager/admin");
@@ -548,4 +863,72 @@ describe("assistant router tool gating", () => {
expect(toolDescriptions.get("batch_quick_assign_timeline_resources")).toContain("manageAllocations");
expect(toolDescriptions.get("batch_shift_timeline_allocations")).toContain("manager/admin");
});
it("aligns assistant tool visibility with router role and permission rules", () => {
const managerWithRolePermission = getToolNames(
[PermissionKey.MANAGE_ROLES],
SystemRole.MANAGER,
);
const managerWithoutRolePermission = getToolNames([], SystemRole.MANAGER);
expect(managerWithRolePermission).toContain("create_role");
expect(managerWithRolePermission).toContain("update_role");
expect(managerWithRolePermission).toContain("delete_role");
expect(managerWithRolePermission).toContain("create_client");
expect(managerWithRolePermission).toContain("update_client");
expect(managerWithRolePermission).not.toContain("create_org_unit");
expect(managerWithRolePermission).not.toContain("update_org_unit");
expect(managerWithoutRolePermission).not.toContain("create_role");
expect(managerWithoutRolePermission).not.toContain("update_role");
expect(managerWithoutRolePermission).not.toContain("delete_role");
expect(managerWithoutRolePermission).toContain("create_client");
expect(managerWithoutRolePermission).toContain("update_client");
const adminWithRolePermission = getToolNames(
[PermissionKey.MANAGE_ROLES],
SystemRole.ADMIN,
);
expect(adminWithRolePermission).toContain("create_org_unit");
expect(adminWithRolePermission).toContain("update_org_unit");
const standardUserTools = getToolNames([], SystemRole.USER);
expect(standardUserTools).toContain("get_vacation_balance");
expect(standardUserTools).toContain("create_vacation");
expect(standardUserTools).toContain("cancel_vacation");
expect(standardUserTools).not.toContain("approve_vacation");
expect(standardUserTools).not.toContain("reject_vacation");
expect(standardUserTools).not.toContain("set_entitlement");
const managerVacationTools = getToolNames([], SystemRole.MANAGER);
expect(managerVacationTools).toContain("approve_vacation");
expect(managerVacationTools).toContain("reject_vacation");
expect(managerVacationTools).toContain("set_entitlement");
});
it("keeps estimate tool parameter enums aligned with the current estimate schema", () => {
const definitionByName = new Map(
TOOL_DEFINITIONS.map((tool) => [tool.function.name, tool.function]),
);
const createEstimateStatus = (
definitionByName.get("create_estimate")?.parameters as {
properties?: Record<string, { enum?: unknown[] }>;
}
)?.properties?.status?.enum;
const updateEstimateStatus = (
definitionByName.get("update_estimate_draft")?.parameters as {
properties?: Record<string, { enum?: unknown[] }>;
}
)?.properties?.status?.enum;
const estimateExportFormats = (
definitionByName.get("create_estimate_export")?.parameters as {
properties?: Record<string, { enum?: unknown[] }>;
}
)?.properties?.format?.enum;
expect(createEstimateStatus).toEqual(["DRAFT", "IN_REVIEW", "APPROVED", "ARCHIVED"]);
expect(updateEstimateStatus).toEqual(["DRAFT", "IN_REVIEW", "APPROVED", "ARCHIVED"]);
expect(estimateExportFormats).toEqual(["XLSX", "CSV", "JSON", "SAP", "MMP"]);
});
});
@@ -126,6 +126,15 @@ describe("assistant advanced tools and scoping", () => {
findUnique: vi
.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({
id: "project_lari",
name: "Gelddruckmaschine",
shortCode: "LARI",
status: "ACTIVE",
responsiblePerson: "Larissa Joos",
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-16T00:00:00.000Z"),
})
.mockResolvedValueOnce({
id: "project_lari",
name: "Gelddruckmaschine",
@@ -228,6 +237,101 @@ describe("assistant advanced tools and scoping", () => {
);
});
it("returns project shift preview details from the canonical timeline router", async () => {
const projectFindUnique = vi.fn().mockImplementation((args: { where?: { id?: string; shortCode?: string }; select?: Record<string, unknown> }) => {
if (args.where?.id === "GDM") {
return Promise.resolve(null);
}
if (args.where?.shortCode === "GDM") {
return Promise.resolve({
id: "project_shift",
name: "Gelddruckmaschine",
shortCode: "GDM",
status: "ACTIVE",
responsiblePerson: "Larissa",
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-16T00:00:00.000Z"),
});
}
if (args.select && "budgetCents" in args.select) {
return Promise.resolve({
id: "project_shift",
budgetCents: 100000,
winProbability: 100,
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-16T00:00:00.000Z"),
});
}
return Promise.resolve({
id: "project_shift",
name: "Gelddruckmaschine",
shortCode: "GDM",
status: "ACTIVE",
responsiblePerson: "Larissa",
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-16T00:00:00.000Z"),
});
});
const ctx = createToolContext(
{
project: {
findUnique: projectFindUnique,
findFirst: vi.fn(),
},
demandRequirement: {
findMany: vi.fn().mockResolvedValue([]),
},
assignment: {
findMany: vi.fn().mockResolvedValue([]),
},
},
[PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS],
);
const result = await executeTool(
"preview_project_shift",
JSON.stringify({
projectIdentifier: "GDM",
newStartDate: "2026-01-19",
newEndDate: "2026-01-30",
}),
ctx,
);
expect(JSON.parse(result.content)).toEqual({
project: {
id: "project_shift",
name: "Gelddruckmaschine",
shortCode: "GDM",
status: "ACTIVE",
responsiblePerson: "Larissa",
startDate: "2026-01-05",
endDate: "2026-01-16",
},
requestedShift: {
newStartDate: "2026-01-19",
newEndDate: "2026-01-30",
},
preview: {
valid: true,
errors: [],
warnings: [],
conflictDetails: [],
costImpact: {
currentTotalCents: 0,
newTotalCents: 0,
deltaCents: 0,
budgetCents: 100000,
budgetUtilizationBefore: 0,
budgetUtilizationAfter: 0,
wouldExceedBudget: false,
},
},
});
});
it("returns timeline entries view with demand, assignment, and holiday overlay context", async () => {
const ctx = createToolContext(
{
@@ -1248,9 +1352,94 @@ describe("assistant advanced tools and scoping", () => {
]));
});
it("scopes assistant notification listing to the current user", async () => {
it("returns a filtered project computation graph through the assistant", async () => {
const projectRecord = {
id: "project_1",
name: "Gelddruckmaschine",
shortCode: "GDM",
budgetCents: 100_000,
winProbability: 75,
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-02-28T00:00:00.000Z"),
status: "ACTIVE",
responsiblePerson: "Larissa Joos",
};
const ctx = createToolContext(
{
project: {
findUnique: vi.fn().mockResolvedValue(projectRecord),
findFirst: vi.fn(),
findUniqueOrThrow: vi.fn().mockResolvedValue(projectRecord),
},
estimate: {
findFirst: vi.fn().mockResolvedValue(null),
},
assignment: {
findMany: vi.fn().mockResolvedValue([
{
status: "CONFIRMED",
dailyCostCents: 4_000,
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-30T00:00:00.000Z"),
hoursPerDay: 4,
},
]),
},
effortRule: {
count: vi.fn().mockResolvedValue(0),
},
experienceMultiplierRule: {
count: vi.fn().mockResolvedValue(0),
},
},
[PermissionKey.VIEW_COSTS, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS],
);
const result = await executeTool(
"get_project_computation_graph",
JSON.stringify({
projectId: "project_1",
domain: "BUDGET",
includeLinks: true,
}),
ctx,
);
const parsed = JSON.parse(result.content) as {
project: { id: string; shortCode: string; name: string };
requestedDomain: string;
totalNodeCount: number;
selectedNodeCount: number;
selectedLinkCount: number;
nodes: Array<{ id: string; domain: string }>;
links: Array<{ source: string; target: string }>;
meta: { projectName: string; projectCode: string };
};
expect(parsed.project).toEqual({
id: "project_1",
shortCode: "GDM",
name: "Gelddruckmaschine",
});
expect(parsed.meta).toEqual({
projectName: "Gelddruckmaschine",
projectCode: "GDM",
});
expect(parsed.requestedDomain).toBe("BUDGET");
expect(parsed.totalNodeCount).toBeGreaterThan(parsed.selectedNodeCount);
expect(parsed.selectedNodeCount).toBeGreaterThan(0);
expect(parsed.selectedLinkCount).toBeGreaterThan(0);
expect(parsed.nodes.every((node) => node.domain === "BUDGET")).toBe(true);
expect(parsed.links.length).toBe(parsed.selectedLinkCount);
});
it("scopes assistant notification listing to the current user through the router path", async () => {
const findMany = vi.fn().mockResolvedValue([]);
const ctx = createToolContext({
user: {
findUnique: vi.fn().mockResolvedValue({ id: "user_1" }),
},
notification: {
findMany,
},
@@ -1268,40 +1457,44 @@ describe("assistant advanced tools and scoping", () => {
);
});
it("rejects marking notifications that do not belong to the current user", async () => {
it("scopes mark_notification_read mutations to the current user through the router path", async () => {
const update = vi.fn();
const ctx = createToolContext({
user: {
findUnique: vi.fn().mockResolvedValue({ id: "user_1" }),
},
notification: {
findUnique: vi.fn().mockResolvedValue({ id: "notif_1", userId: "someone_else" }),
update,
},
});
const result = await executeTool(
await executeTool(
"mark_notification_read",
JSON.stringify({ notificationId: "notif_1" }),
ctx,
);
expect(JSON.parse(result.content)).toEqual({
error: "Access denied: this notification does not belong to you",
expect(update).toHaveBeenCalledWith({
where: { id: "notif_1", userId: "user_1" },
data: expect.objectContaining({
readAt: expect.any(Date),
}),
});
expect(update).not.toHaveBeenCalled();
});
it("requires manageUsers before listing users through the assistant", async () => {
it("requires admin role before listing users through the assistant", async () => {
const findMany = vi.fn();
const ctx = createToolContext({
user: {
findMany,
},
});
}, [], SystemRole.MANAGER);
const result = await executeTool("list_users", JSON.stringify({ limit: 10 }), ctx);
expect(JSON.parse(result.content)).toEqual(
expect.objectContaining({
error: expect.stringContaining(PermissionKey.MANAGE_USERS),
error: expect.stringContaining("Admin role required"),
}),
);
expect(findMany).not.toHaveBeenCalled();
@@ -12,30 +12,50 @@ function createToolContext(
userId: "user_1",
userRole,
permissions: new Set(permissions) as ToolContext["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,
};
}
describe("assistant country tools", () => {
it("lists countries with schedule rules, active state, and metro cities", async () => {
const findMany = vi.fn().mockResolvedValue([
{
id: "country_de",
code: "DE",
name: "Deutschland",
dailyWorkingHours: 8,
scheduleRules: null,
isActive: true,
metroCities: [{ id: "city_muc", name: "Munich" }],
},
{
id: "country_es",
code: "ES",
name: "Spain",
dailyWorkingHours: 8,
scheduleRules: null,
isActive: true,
metroCities: [{ id: "city_mad", name: "Madrid" }],
},
]);
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" }],
},
]),
findMany,
},
});
const result = await executeTool(
"list_countries",
JSON.stringify({ includeInactive: true }),
JSON.stringify({ search: "deu" }),
ctx,
);
@@ -49,6 +69,11 @@ describe("assistant country tools", () => {
}>;
};
expect(findMany).toHaveBeenCalledWith({
where: { isActive: true },
include: { metroCities: { orderBy: { name: "asc" } } },
orderBy: { name: "asc" },
});
expect(parsed.count).toBe(1);
expect(parsed.countries[0]).toMatchObject({
code: "DE",
@@ -6,6 +6,7 @@ vi.mock("@capakraken/application", async (importOriginal) => {
return {
...actual,
getDashboardBudgetForecast: vi.fn().mockResolvedValue([]),
listAssignmentBookings: vi.fn().mockResolvedValue([]),
};
});
@@ -25,6 +26,16 @@ function createToolContext(
userId: "user_1",
userRole,
permissions: new Set(permissions) as ToolContext["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,
};
}
@@ -78,6 +89,7 @@ describe("assistant holiday tools", () => {
findUnique: vi
.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ id: "res_1", eid: "bruce.banner", displayName: "Bruce Banner" })
.mockResolvedValueOnce({ id: "res_1", eid: "bruce.banner", displayName: "Bruce Banner", federalState: "BY", countryId: "country_de", metroCityId: "city_augsburg", country: { code: "DE", name: "Deutschland" }, metroCity: { name: "Augsburg" } }),
findFirst: vi.fn(),
},
@@ -172,10 +184,10 @@ describe("assistant holiday tools", () => {
it("previews resolved holiday calendars for a scope and shows the source calendar", async () => {
const ctx = createToolContext({
country: {
findUnique: vi.fn().mockResolvedValue({ code: "DE" }),
findUnique: vi.fn().mockResolvedValue({ id: "country_de", code: "DE", name: "Deutschland" }),
},
metroCity: {
findUnique: vi.fn().mockResolvedValue({ name: "Augsburg" }),
findUnique: vi.fn().mockResolvedValue({ id: "city_augsburg", name: "Augsburg", countryId: "country_de" }),
},
holidayCalendar: {
findMany: vi.fn().mockResolvedValue([
@@ -229,6 +241,14 @@ describe("assistant holiday tools", () => {
}),
]),
);
expect(ctx.db.country.findUnique).toHaveBeenCalledWith({
where: { id: "country_de" },
select: { id: true, code: true, name: true },
});
expect(ctx.db.metroCity.findUnique).toHaveBeenCalledWith({
where: { id: "city_augsburg" },
select: { id: true, name: true, countryId: true },
});
});
it("creates a holiday calendar through the assistant for admin users", async () => {
@@ -301,36 +321,58 @@ describe("assistant holiday tools", () => {
});
it("calculates chargeability with regional holidays excluded from booked and available hours", async () => {
const resourceRecord = {
id: "res_1",
displayName: "Bruce Banner",
eid: "bruce.banner",
fte: 1,
lcrCents: 5000,
chargeabilityTarget: 80,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8, saturday: 0, sunday: 0 },
countryId: "country_de",
federalState: "BY",
metroCityId: null,
country: { id: "country_de", code: "DE", name: "Deutschland", dailyWorkingHours: 8, scheduleRules: null },
metroCity: null,
managementLevelGroup: null,
};
const db = {
resource: {
findUnique: vi
.fn()
.mockResolvedValueOnce({
id: "res_1",
displayName: "Bruce Banner",
eid: "bruce.banner",
fte: 1,
chargeabilityTarget: 80,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_de",
federalState: "BY",
metroCityId: null,
country: { code: "DE", dailyWorkingHours: 8 },
metroCity: null,
}),
.mockResolvedValueOnce(resourceRecord),
findUniqueOrThrow: vi.fn().mockResolvedValue(resourceRecord),
findFirst: vi.fn(),
},
assignment: {
findMany: vi.fn().mockResolvedValue([
{
id: "assign_1",
hoursPerDay: 8,
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-06T00:00:00.000Z"),
dailyCostCents: 40000,
status: "CONFIRMED",
project: { name: "Gamma", shortCode: "GAM" },
project: {
id: "project_gamma",
name: "Gamma",
shortCode: "GAM",
budgetCents: null,
winProbability: 100,
utilizationCategory: { code: "Chg" },
},
},
]),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
holidayCalendar: {
findMany: vi.fn().mockResolvedValue([]),
},
calculationRule: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const ctx = createToolContext(db);
@@ -356,12 +398,12 @@ describe("assistant holiday tools", () => {
expect(parsed.bookedHours).toBe(8);
expect(parsed.allocations).toEqual([expect.objectContaining({ hours: 8 })]);
expect(parsed.baseWorkingDays).toBe(23);
expect(parsed.baseAvailableHours).toBe(184);
expect(parsed.availableHours).toBe(168);
expect(parsed.workingDays).toBe(21);
expect(parsed.targetHours).toBe(134.4);
expect(parsed.unassignedHours).toBe(160);
expect(parsed.baseWorkingDays).toBe(22);
expect(parsed.baseAvailableHours).toBe(176);
expect(parsed.availableHours).toBe(160);
expect(parsed.workingDays).toBe(20);
expect(parsed.targetHours).toBe(128);
expect(parsed.unassignedHours).toBe(152);
expect(parsed.locationContext.federalState).toBe("BY");
expect(parsed.holidaySummary).toEqual(
expect.objectContaining({
@@ -409,7 +451,6 @@ describe("assistant holiday tools", () => {
}>;
};
expect(getDashboardBudgetForecast).toHaveBeenCalled();
expect(parsed.forecasts).toEqual([
expect.objectContaining({
projectName: "Gelddruckmaschine",
@@ -425,21 +466,23 @@ describe("assistant holiday tools", () => {
});
it("checks resource availability with regional holidays excluded from capacity", async () => {
const resourceRecord = {
id: "res_1",
displayName: "Bruce Banner",
eid: "bruce.banner",
fte: 1,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8, saturday: 0, sunday: 0 },
countryId: "country_de",
federalState: "BY",
metroCityId: null,
country: { code: "DE", dailyWorkingHours: 8 },
metroCity: null,
};
const db = {
resource: {
findUnique: vi
.fn()
.mockResolvedValueOnce({
id: "res_1",
displayName: "Bruce Banner",
fte: 1,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_de",
federalState: "BY",
metroCityId: null,
country: { code: "DE" },
metroCity: null,
}),
.mockResolvedValue(resourceRecord),
findFirst: vi.fn(),
},
assignment: {
@@ -581,13 +624,17 @@ describe("assistant holiday tools", () => {
it("prefers resources without a local holiday in staffing suggestions", async () => {
const db = {
project: {
findFirst: vi.fn().mockResolvedValue({
findUnique: vi.fn().mockResolvedValue({
id: "project_1",
name: "Holiday Project",
shortCode: "HP",
startDate: new Date("2026-01-06T00:00:00.000Z"),
endDate: new Date("2026-01-06T00:00:00.000Z"),
}),
findFirst: vi.fn().mockResolvedValue(null),
},
assignment: {
findMany: vi.fn().mockResolvedValue([]),
},
resource: {
findMany: vi.fn().mockResolvedValue([
@@ -597,15 +644,17 @@ describe("assistant holiday tools", () => {
eid: "BY-1",
fte: 1,
lcrCents: 10000,
chargeabilityTarget: 80,
valueScore: 10,
skills: [],
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_de",
federalState: "BY",
metroCityId: null,
country: { code: "DE" },
country: { code: "DE", name: "Deutschland" },
metroCity: null,
areaRole: { name: "Consultant" },
chapter: "CGI",
assignments: [],
},
{
id: "res_hh",
@@ -613,21 +662,20 @@ describe("assistant holiday tools", () => {
eid: "HH-1",
fte: 1,
lcrCents: 10000,
chargeabilityTarget: 80,
valueScore: 10,
skills: [],
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_de",
federalState: "HH",
metroCityId: null,
country: { code: "DE" },
country: { code: "DE", name: "Deutschland" },
metroCity: null,
areaRole: { name: "Consultant" },
chapter: "CGI",
assignments: [],
},
]),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const ctx = createToolContext(db);
@@ -645,6 +693,16 @@ describe("assistant holiday tools", () => {
expect(parsed.suggestions[0]).toEqual(
expect.objectContaining({ name: "Hamburg", availableHours: 8 }),
);
expect(db.project.findUnique).toHaveBeenCalledWith({
where: { id: "project_1" },
select: expect.objectContaining({
id: true,
shortCode: true,
name: true,
startDate: true,
endDate: true,
}),
});
});
it("finds capacity with local holidays respected", async () => {
@@ -714,6 +772,12 @@ describe("assistant holiday tools", () => {
id: "project_1",
name: "Holiday Project",
shortCode: "HP",
status: "ACTIVE",
responsiblePerson: null,
})
.mockResolvedValueOnce({
id: "project_1",
name: "Holiday Project",
shoringThreshold: 55,
onshoreCountryCode: "DE",
}),
@@ -726,6 +790,7 @@ describe("assistant holiday tools", () => {
startDate: new Date("2026-01-06T00:00:00.000Z"),
endDate: new Date("2026-01-06T00:00:00.000Z"),
resource: {
id: "res_by",
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_de",
federalState: "BY",
@@ -740,6 +805,7 @@ describe("assistant holiday tools", () => {
startDate: new Date("2026-01-06T00:00:00.000Z"),
endDate: new Date("2026-01-06T00:00:00.000Z"),
resource: {
id: "res_in",
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_in",
federalState: null,
@@ -765,4 +831,121 @@ describe("assistant holiday tools", () => {
expect(result.content).toContain("0% onshore (DE), 100% offshore");
expect(result.content).toContain("IN 100% (1 people)");
});
it("routes pending vacation approvals through the vacation router path", async () => {
const db = {
vacation: {
findMany: vi.fn().mockResolvedValue([
{
id: "vac_1",
type: "ANNUAL",
startDate: new Date("2026-07-01T00:00:00.000Z"),
endDate: new Date("2026-07-03T00:00:00.000Z"),
isHalfDay: false,
resource: { displayName: "Bruce Banner", eid: "BB-1", chapter: "CGI" },
requestedBy: { id: "user_2", name: "Manager", email: "manager@example.com" },
},
]),
},
};
const ctx = createToolContext(db, [], SystemRole.MANAGER);
const result = await executeTool(
"get_pending_vacation_approvals",
JSON.stringify({ limit: 10 }),
ctx,
);
expect(db.vacation.findMany).toHaveBeenCalledWith({
where: { status: "PENDING" },
include: {
resource: { select: expect.any(Object) },
requestedBy: { select: { id: true, name: true, email: true } },
},
orderBy: { startDate: "asc" },
});
expect(JSON.parse(result.content)).toEqual([
expect.objectContaining({
id: "vac_1",
resource: "Bruce Banner",
eid: "BB-1",
chapter: "CGI",
}),
]);
});
it("routes team vacation overlap through the vacation router path", async () => {
const db = {
resource: {
findUnique: vi
.fn()
.mockResolvedValue({
id: "res_1",
displayName: "Bruce Banner",
eid: "BB-1",
chapter: "CGI",
lcrCents: 0,
isActive: true,
countryId: null,
federalState: null,
metroCityId: null,
areaRole: null,
country: null,
metroCity: null,
}),
findFirst: vi.fn(),
findMany: vi.fn(),
},
vacation: {
findMany: vi.fn().mockResolvedValue([
{
type: "ANNUAL",
status: "APPROVED",
startDate: new Date("2026-08-10T00:00:00.000Z"),
endDate: new Date("2026-08-12T00:00:00.000Z"),
resource: { displayName: "Clark Kent" },
},
]),
},
};
const ctx = createToolContext(db);
const result = await executeTool(
"get_team_vacation_overlap",
JSON.stringify({
resourceId: "res_1",
startDate: "2026-08-10",
endDate: "2026-08-12",
}),
ctx,
);
expect(db.vacation.findMany).toHaveBeenCalledWith({
where: {
resource: { chapter: "CGI" },
resourceId: { not: "res_1" },
status: { in: ["APPROVED", "PENDING"] },
startDate: { lte: new Date("2026-08-12T00:00:00.000Z") },
endDate: { gte: new Date("2026-08-10T00:00:00.000Z") },
},
include: {
resource: { select: expect.any(Object) },
},
orderBy: { startDate: "asc" },
take: 20,
});
expect(JSON.parse(result.content)).toEqual(
expect.objectContaining({
resource: "Bruce Banner",
chapter: "CGI",
overlapCount: 1,
overlappingVacations: [
expect.objectContaining({
resource: "Clark Kent",
status: "APPROVED",
}),
],
}),
);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,124 @@
import { SystemRole } from "@capakraken/shared";
import { describe, expect, it, vi } from "vitest";
import { auditLogRouter } from "../router/audit-log.js";
import { createCallerFactory } from "../trpc.js";
const createCaller = createCallerFactory(auditLogRouter);
function createControllerCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "controller@example.com", name: "Controller", image: null },
expires: "2099-01-01T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_1",
systemRole: SystemRole.CONTROLLER,
permissionOverrides: null,
},
});
}
describe("audit log router detail endpoints", () => {
it("returns formatted list detail rows with ISO timestamps", async () => {
const db = {
auditLog: {
findMany: vi.fn().mockResolvedValue([
{
id: "audit_1",
entityType: "project",
entityId: "project_1",
entityName: "Apollo",
action: "updated",
userId: "user_1",
source: "ui",
summary: "Changed budget",
createdAt: new Date("2026-03-29T12:00:00.000Z"),
user: {
id: "user_1",
name: "Controller User",
email: "controller@example.com",
},
},
]),
},
};
const caller = createControllerCaller(db);
const result = await caller.listDetail({ limit: 10 });
expect(result).toEqual({
items: [
{
id: "audit_1",
entityType: "project",
entityId: "project_1",
entityName: "Apollo",
action: "updated",
userId: "user_1",
source: "ui",
summary: "Changed budget",
createdAt: "2026-03-29T12:00:00.000Z",
user: {
id: "user_1",
name: "Controller User",
email: "controller@example.com",
},
},
],
nextCursor: null,
});
});
it("returns formatted timeline detail grouped by date", async () => {
const db = {
auditLog: {
findMany: vi.fn().mockResolvedValue([
{
id: "audit_2",
entityType: "resource",
entityId: "resource_1",
entityName: "Peter Parker",
action: "updated",
userId: "user_2",
source: "assistant",
summary: "Updated location",
changes: { city: ["Hamburg", "Munich"] },
createdAt: new Date("2026-03-30T08:00:00.000Z"),
user: {
id: "user_2",
name: "Audit User",
email: "audit@example.com",
},
},
]),
},
};
const caller = createControllerCaller(db);
const result = await caller.getTimelineDetail({ limit: 10 });
expect(result).toEqual({
"2026-03-30": [
{
id: "audit_2",
entityType: "resource",
entityId: "resource_1",
entityName: "Peter Parker",
action: "updated",
userId: "user_2",
source: "assistant",
summary: "Updated location",
createdAt: "2026-03-30T08:00:00.000Z",
changes: { city: ["Hamburg", "Munich"] },
user: {
id: "user_2",
name: "Audit User",
email: "audit@example.com",
},
},
],
});
});
});
@@ -425,4 +425,111 @@ describe("chargeability report router", () => {
expect(month).toBeDefined();
expect(month?.chg).toBeCloseTo(16 / 144, 5);
});
it("returns a filtered detailed report with rounded percentages", async () => {
const db = {
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([]),
},
};
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 caller = createControllerCaller(db);
const result = await caller.getDetail({
startMonth: "2026-03",
endMonth: "2026-03",
resourceQuery: "ali",
resourceLimit: 10,
});
expect(result.filters).toEqual({
startMonth: "2026-03",
endMonth: "2026-03",
orgUnitId: null,
managementLevelGroupId: null,
countryId: null,
includeProposed: false,
resourceQuery: "ali",
});
expect(result.groupTotals).toEqual([
expect.objectContaining({
monthKey: "2026-03",
totalFte: 1,
chargeabilityPct: expect.any(Number),
targetPct: 80,
}),
]);
expect(result.resourceCount).toBe(1);
expect(result.returnedResourceCount).toBe(1);
expect(result.truncated).toBe(false);
expect(result.resources).toEqual([
expect.objectContaining({
displayName: "Alice",
targetPct: 80,
country: "ES",
city: "Barcelona",
managementLevelGroup: "Senior",
managementLevel: "L7",
months: [
expect.objectContaining({
monthKey: "2026-03",
sah: expect.any(Number),
chargeabilityPct: expect.any(Number),
gapPct: expect.any(Number),
}),
],
}),
]);
});
});
@@ -27,6 +27,32 @@ type ResourceGraphMeta = {
};
};
type ResourceGraphDetail = {
resource: { id: string; eid: string; displayName: string };
availableDomains: string[];
requestedDomain: string | null;
totalNodeCount: number;
totalLinkCount: number;
selectedNodeCount: number;
selectedLinkCount: number;
nodes: Array<{ id: string; domain: string }>;
};
type ProjectGraphDetail = {
project: { id: string; shortCode: string; name: string };
availableDomains: string[];
requestedDomain: string | null;
totalNodeCount: number;
selectedNodeCount: number;
selectedLinkCount: number;
nodes: Array<{ id: string; domain: string }>;
links?: Array<{ source: string; target: string }>;
meta: {
projectName: string;
projectCode: string;
};
};
function createControllerCaller(db: Record<string, unknown>) {
return createCaller({
session: {
@@ -99,6 +125,47 @@ function buildResource(overrides: Record<string, unknown> = {}) {
};
}
function createProjectDb(projectFindImpl: ReturnType<typeof vi.fn>) {
return {
project: {
findUniqueOrThrow: projectFindImpl,
},
estimate: {
findFirst: vi.fn().mockResolvedValue(null),
},
assignment: {
findMany: vi.fn().mockResolvedValue([
{
status: "CONFIRMED",
dailyCostCents: 4_000,
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-30T00:00:00.000Z"),
hoursPerDay: 4,
},
]),
},
effortRule: {
count: vi.fn().mockResolvedValue(0),
},
experienceMultiplierRule: {
count: vi.fn().mockResolvedValue(0),
},
};
}
function buildProject(overrides: Record<string, unknown> = {}) {
return {
id: "project_1",
name: "Gelddruckmaschine",
shortCode: "GDM",
budgetCents: 100_000,
winProbability: 75,
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-02-28T00:00:00.000Z"),
...overrides,
};
}
describe("computation graph router", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -192,4 +259,60 @@ describe("computation graph router", () => {
expect.objectContaining({ name: "Heilige Drei Könige", date: "2026-01-06" }),
]));
});
it("returns a filtered resource detail graph with canonical selection metadata", async () => {
const db = createDb(vi.fn().mockResolvedValue(buildResource({
id: "resource_augsburg",
metroCityId: "city_augsburg",
metroCity: { id: "city_augsburg", name: "Augsburg" },
})));
const caller = createControllerCaller(db);
const result = await caller.getResourceDataDetail({
resourceId: "resource_augsburg",
month: "2026-08",
domain: "SAH",
}) as ResourceGraphDetail;
expect(result.resource).toEqual({
id: "resource_augsburg",
eid: "bruce.banner",
displayName: "Bruce Banner",
});
expect(result.availableDomains).toEqual(expect.arrayContaining(["INPUT", "SAH", "ALLOCATION", "CHARGEABILITY"]));
expect(result.requestedDomain).toBe("SAH");
expect(result.totalNodeCount).toBeGreaterThan(result.selectedNodeCount);
expect(result.totalLinkCount).toBeGreaterThan(0);
expect(result.selectedNodeCount).toBeGreaterThan(0);
expect(result.selectedLinkCount).toBe(0);
expect(result.nodes.every((node) => node.domain === "SAH")).toBe(true);
});
it("returns a filtered project detail graph with canonical project identity", async () => {
const db = createProjectDb(vi.fn().mockResolvedValue(buildProject()));
const caller = createControllerCaller(db);
const result = await caller.getProjectDataDetail({
projectId: "project_1",
domain: "BUDGET",
includeLinks: true,
}) as ProjectGraphDetail;
expect(result.project).toEqual({
id: "project_1",
shortCode: "GDM",
name: "Gelddruckmaschine",
});
expect(result.meta).toEqual({
projectName: "Gelddruckmaschine",
projectCode: "GDM",
});
expect(result.availableDomains).toEqual(expect.arrayContaining(["INPUT", "BUDGET"]));
expect(result.requestedDomain).toBe("BUDGET");
expect(result.totalNodeCount).toBeGreaterThan(result.selectedNodeCount);
expect(result.selectedNodeCount).toBeGreaterThan(0);
expect(result.selectedLinkCount).toBeGreaterThan(0);
expect(result.nodes.every((node) => node.domain === "BUDGET")).toBe(true);
expect(result.links?.length).toBe(result.selectedLinkCount);
});
});
@@ -11,6 +11,7 @@ vi.mock("@capakraken/application", async (importOriginal) => {
getDashboardTopValueResources: vi.fn(),
getDashboardChargeabilityOverview: vi.fn(),
getDashboardBudgetForecast: vi.fn(),
getDashboardProjectHealth: vi.fn(),
};
});
@@ -31,6 +32,7 @@ import {
getDashboardTopValueResources,
getDashboardChargeabilityOverview,
getDashboardBudgetForecast,
getDashboardProjectHealth,
} from "@capakraken/application";
import { dashboardRouter } from "../router/dashboard.js";
import { createCallerFactory } from "../trpc.js";
@@ -97,7 +99,7 @@ describe("dashboard router", () => {
vi.mocked(getDashboardOverview).mockResolvedValue(overview);
const caller = createProtectedCaller({});
const caller = createControllerCaller({});
const result = await caller.getOverview();
expect(result).toMatchObject({
@@ -115,6 +117,72 @@ describe("dashboard router", () => {
});
});
describe("getStatisticsDetail", () => {
it("returns assistant-friendly statistics derived from the canonical dashboard overview", async () => {
vi.mocked(getDashboardOverview).mockResolvedValue({
totalResources: 12,
activeResources: 10,
inactiveResources: 2,
totalProjects: 7,
activeProjects: 4,
inactiveProjects: 3,
totalAllocations: 21,
activeAllocations: 18,
cancelledAllocations: 3,
approvedVacations: 6,
totalEstimates: 9,
budgetSummary: {
totalBudgetCents: 1_234_56,
totalCostCents: 654_32,
avgUtilizationPercent: 53,
},
budgetBasis: {
remainingBudgetCents: 58_024,
budgetedProjects: 5,
unbudgetedProjects: 2,
trackedAssignmentCount: 18,
windowStart: null,
windowEnd: null,
},
projectsByStatus: [
{ status: "ACTIVE", count: 4 },
{ status: "DRAFT", count: 2 },
{ status: "DONE", count: 1 },
],
chapterUtilization: [
{ chapter: "CGI", resourceCount: 5, avgChargeabilityTarget: 78 },
{ chapter: "Compositing", resourceCount: 3, avgChargeabilityTarget: 74 },
{ chapter: "Unassigned", resourceCount: 2, avgChargeabilityTarget: 0 },
],
recentActivity: [],
});
const caller = createControllerCaller({});
const result = await caller.getStatisticsDetail();
expect(getDashboardOverview).toHaveBeenCalledTimes(1);
expect(result).toEqual({
activeResources: 10,
totalProjects: 7,
activeProjects: 4,
totalAllocations: 21,
approvedVacations: 6,
totalEstimates: 9,
totalBudget: "1.234,56 EUR",
projectsByStatus: {
ACTIVE: 4,
DRAFT: 2,
DONE: 1,
},
topChapters: [
{ chapter: "CGI", count: 5 },
{ chapter: "Compositing", count: 3 },
{ chapter: "Unassigned", count: 2 },
],
});
});
});
// ─── getPeakTimes ─────────────────────────────────────────────────────────
describe("getPeakTimes", () => {
@@ -126,7 +194,7 @@ describe("dashboard router", () => {
vi.mocked(getDashboardPeakTimes).mockResolvedValue(peakData);
const caller = createProtectedCaller({});
const caller = createControllerCaller({});
const result = await caller.getPeakTimes({
startDate: "2026-03-01T00:00:00.000Z",
endDate: "2026-06-30T00:00:00.000Z",
@@ -148,7 +216,7 @@ describe("dashboard router", () => {
it("passes week granularity to application layer", async () => {
vi.mocked(getDashboardPeakTimes).mockResolvedValue([]);
const caller = createProtectedCaller({});
const caller = createControllerCaller({});
await caller.getPeakTimes({
startDate: "2026-03-01T00:00:00.000Z",
endDate: "2026-03-31T00:00:00.000Z",
@@ -177,7 +245,7 @@ describe("dashboard router", () => {
vi.mocked(getDashboardDemand).mockResolvedValue(demandData);
const caller = createProtectedCaller({});
const caller = createControllerCaller({});
const result = await caller.getDemand({
startDate: "2026-01-01T00:00:00.000Z",
endDate: "2026-12-31T00:00:00.000Z",
@@ -194,7 +262,7 @@ describe("dashboard router", () => {
it("supports grouping by chapter", async () => {
vi.mocked(getDashboardDemand).mockResolvedValue([]);
const caller = createProtectedCaller({});
const caller = createControllerCaller({});
await caller.getDemand({
startDate: "2026-06-01T00:00:00.000Z",
endDate: "2026-06-30T00:00:00.000Z",
@@ -208,6 +276,73 @@ describe("dashboard router", () => {
});
});
describe("getProjectHealthDetail", () => {
it("returns assistant-friendly health detail derived from the canonical dashboard read model", async () => {
vi.mocked(getDashboardProjectHealth).mockResolvedValue([
{
id: "project_critical",
projectName: "Critical Project",
shortCode: "CRIT",
status: "ACTIVE",
clientId: "client_1",
clientName: "Acme",
budgetHealth: 25,
staffingHealth: 40,
timelineHealth: 30,
compositeScore: 35,
},
{
id: "project_healthy",
projectName: "Healthy Project",
shortCode: "HLTH",
status: "ACTIVE",
clientId: "client_1",
clientName: "Acme",
budgetHealth: 90,
staffingHealth: 92,
timelineHealth: 86,
compositeScore: 89,
},
]);
const caller = createControllerCaller({});
const result = await caller.getProjectHealthDetail();
expect(getDashboardProjectHealth).toHaveBeenCalledTimes(1);
expect(result).toEqual({
projects: [
{
projectId: "project_critical",
projectName: "Critical Project",
shortCode: "CRIT",
status: "ACTIVE",
overall: 35,
budget: 25,
staffing: 40,
timeline: 30,
rating: "critical",
},
{
projectId: "project_healthy",
projectName: "Healthy Project",
shortCode: "HLTH",
status: "ACTIVE",
overall: 89,
budget: 90,
staffing: 92,
timeline: 86,
rating: "healthy",
},
],
summary: {
healthy: 1,
atRisk: 0,
critical: 1,
},
});
});
});
// ─── getTopValueResources ─────────────────────────────────────────────────
describe("getTopValueResources", () => {
@@ -219,7 +354,7 @@ describe("dashboard router", () => {
vi.mocked(getDashboardTopValueResources).mockResolvedValue(resources);
const caller = createProtectedCaller({});
const caller = createControllerCaller({});
const result = await caller.getTopValueResources({ limit: 10 });
expect(result).toHaveLength(2);
@@ -232,7 +367,7 @@ describe("dashboard router", () => {
it("respects custom limit", async () => {
vi.mocked(getDashboardTopValueResources).mockResolvedValue([]);
const caller = createProtectedCaller({});
const caller = createControllerCaller({});
await caller.getTopValueResources({ limit: 5 });
expect(getDashboardTopValueResources).toHaveBeenCalledWith(
@@ -334,7 +469,7 @@ describe("dashboard router", () => {
},
]);
const caller = createProtectedCaller({});
const caller = createControllerCaller({});
const result = await caller.getBudgetForecast();
expect(result).toHaveLength(1);
@@ -351,5 +486,177 @@ describe("dashboard router", () => {
});
expect(getDashboardBudgetForecast).toHaveBeenCalledTimes(1);
});
it("returns assistant-friendly budget forecast detail derived from the canonical dashboard read model", async () => {
vi.mocked(getDashboardBudgetForecast).mockResolvedValue([
{
projectId: "project_1",
projectName: "Alpha",
shortCode: "ALPHA",
clientId: "client_1",
clientName: "Client One",
budgetCents: 100_000,
spentCents: 40_000,
remainingCents: 60_000,
burnRate: 10_000,
estimatedExhaustionDate: "2026-06-30",
pctUsed: 40,
activeAssignmentCount: 2,
calendarLocations: [
{
countryCode: "DE",
countryName: "Germany",
federalState: "BY",
metroCityName: "Munich",
activeAssignmentCount: 2,
burnRateCents: 10_000,
},
],
},
]);
const caller = createControllerCaller({});
const result = await caller.getBudgetForecastDetail();
expect(getDashboardBudgetForecast).toHaveBeenCalledTimes(1);
expect(result).toEqual({
forecasts: [
expect.objectContaining({
projectId: "project_1",
projectName: "Alpha",
shortCode: "ALPHA",
budgetCents: 100_000,
spentCents: 40_000,
remainingCents: 60_000,
projectedCents: 100_000,
burnRateCents: 10_000,
utilization: "40%",
burnStatus: "on_track",
calendarLocations: [
expect.objectContaining({
countryCode: "DE",
federalState: "BY",
metroCityName: "Munich",
}),
],
}),
],
});
});
});
describe("getDetail", () => {
it("returns the canonical assistant dashboard detail payload", async () => {
vi.mocked(getDashboardOverview).mockResolvedValue({
budgetBasis: {
windowStart: "2026-01-01T00:00:00.000Z",
windowEnd: "2026-06-30T00:00:00.000Z",
},
chapterUtilization: [
{
chapter: "Delivery",
resourceCount: 4,
avgChargeabilityTarget: 78,
},
],
});
vi.mocked(getDashboardPeakTimes).mockResolvedValue([
{
period: "2026-03",
totalHours: 320.4,
capacityHours: 400.2,
utilizationPct: 80,
},
]);
vi.mocked(getDashboardTopValueResources).mockResolvedValue([
{
id: "res_1",
eid: "pparker",
displayName: "Peter Parker",
chapter: "Delivery",
valueScore: 91,
lcrCents: 9_500,
},
]);
vi.mocked(getDashboardDemand).mockResolvedValue([
{
id: "project_1",
name: "Gelddruckmaschine",
shortCode: "GDM",
allocatedHours: 120,
requiredFTEs: 4,
resourceCount: 2,
derivation: {
calendarLocations: [
{
countryCode: "DE",
federalState: "BY",
metroCityName: "Augsburg",
resourceCount: 2,
allocatedHours: 120,
},
],
},
},
]);
const caller = createControllerCaller({});
const result = await caller.getDetail({ section: "all" });
expect(result).toEqual({
peakTimes: [
{
month: "2026-03",
totalHours: 320.4,
totalHoursPerDay: 320.4,
capacityHours: 400.2,
utilizationPct: 80,
},
],
topResources: [
{
name: "Peter Parker",
eid: "pparker",
chapter: "Delivery",
lcr: "95,00 EUR",
valueScore: 91,
},
],
demandPipeline: [
{
project: "Gelddruckmaschine (GDM)",
needed: 2,
requiredFTEs: 4,
allocatedResources: 2,
allocatedHours: 120,
calendarLocations: [
{
countryCode: "DE",
federalState: "BY",
metroCityName: "Augsburg",
resourceCount: 2,
allocatedHours: 120,
},
],
},
],
chargeabilityByChapter: [
{
chapter: "Delivery",
headcount: 4,
avgTarget: "78%",
},
],
});
expect(getDashboardPeakTimes).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
startDate: new Date("2026-01-01T00:00:00.000Z"),
endDate: new Date("2026-06-30T00:00:00.000Z"),
granularity: "month",
groupBy: "project",
}),
);
});
});
});
@@ -368,6 +368,54 @@ describe("entitlement.getBalance", () => {
});
});
describe("entitlement.getBalanceDetail", () => {
it("returns assistant-friendly balance detail from the canonical balance workflow", async () => {
const entitlement = sampleEntitlement({ carryoverDays: 0, usedDays: 1, pendingDays: 0.5 });
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
},
resource: {
findUnique: vi.fn().mockImplementation(async ({ select }: { select?: Record<string, unknown> } = {}) => ({
...(select?.userId ? { userId: "user_1" } : {}),
...(select?.federalState ? { federalState: "BY" } : {}),
...(select?.country ? { country: { code: "DE" } } : {}),
...(select?.metroCity ? { metroCity: null } : {}),
...(select?.displayName ? { displayName: "Alice Example" } : {}),
...(select?.eid ? { eid: "EMP-001" } : {}),
})),
},
vacationEntitlement: {
findUnique: mockEntitlementFindUniqueByYear({ 2026: entitlement }),
update: vi.fn().mockResolvedValue(entitlement),
},
vacation: {
findMany: vi.fn().mockImplementation(async ({ where }: { where?: { type?: string } } = {}) => {
if (where?.type === "SICK") {
return [{ startDate: new Date("2026-02-01"), endDate: new Date("2026-02-01"), isHalfDay: false }];
}
return [];
}),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getBalanceDetail({ resourceId: "res_1", year: 2026 });
expect(result).toEqual({
resource: "Alice Example",
eid: "EMP-001",
year: 2026,
entitlement: 30,
carryOver: 0,
taken: 1,
pending: 0.5,
remaining: 28.5,
sickDays: 1,
});
});
});
// ─── get ─────────────────────────────────────────────────────────────────────
describe("entitlement.get", () => {
@@ -624,3 +672,67 @@ describe("entitlement.getYearSummary", () => {
);
});
});
describe("entitlement.getYearSummaryDetail", () => {
it("returns assistant-friendly year summary detail from the canonical summary workflow", async () => {
const resources = [
{ id: "res_1", displayName: "Alice Example", eid: "EMP-001", chapter: "Delivery" },
{ id: "res_2", displayName: "Bob Example", eid: "EMP-002", chapter: "CGI" },
];
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
},
resource: {
findMany: vi.fn().mockResolvedValue(resources),
findUnique: vi.fn().mockResolvedValue({
federalState: "BY",
country: { code: "DE" },
metroCity: null,
}),
},
vacationEntitlement: {
findUnique: vi.fn().mockImplementation(async ({ where }: { where: { resourceId_year: { resourceId: string; year: number } } }) => {
if (where.resourceId_year.year !== 2026) {
return null;
}
return sampleEntitlement({
id: `ent_${where.resourceId_year.resourceId}`,
resourceId: where.resourceId_year.resourceId,
year: 2026,
entitledDays: 28,
carryoverDays: 0,
usedDays: 0,
pendingDays: 0,
});
}),
create: vi.fn(),
update: vi.fn().mockImplementation(async (args?: { data?: Record<string, unknown>; where?: { id?: string } }) => ({
...sampleEntitlement({ entitledDays: 28, carryoverDays: 0, usedDays: 0, pendingDays: 0 }),
id: args?.where?.id ?? "ent_updated",
...(args?.data ?? {}),
})),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createManagerCaller(db);
const result = await caller.getYearSummaryDetail({ year: 2026, resourceName: "alice" });
expect(result).toEqual([
{
resource: "Alice Example",
eid: "EMP-001",
chapter: "Delivery",
year: 2026,
entitled: 28,
carryover: 0,
used: 0,
pending: 0,
remaining: 28,
},
]);
});
});
@@ -1168,6 +1168,65 @@ describe("estimate router", () => {
expect.objectContaining({ code: "PRECONDITION_FAILED" }),
);
});
it("throws PRECONDITION_FAILED for demand-line project windows without working days", async () => {
const approvedEstimate = {
...baseEstimate,
projectId: "project_1",
status: EstimateStatus.APPROVED,
versions: [
{
...baseVersion,
id: "ver_approved",
status: EstimateVersionStatus.APPROVED,
lockedAt: new Date("2026-03-13"),
demandLines: [
{
id: "line_1",
name: "Staffing Gap",
hours: 16,
fte: 1,
resourceId: null,
},
],
},
],
};
const findUnique = vi.fn().mockResolvedValue(approvedEstimate);
const projectFindUnique = vi.fn().mockResolvedValue({
id: "project_1",
shortCode: "PRJ1",
name: "Weekend Project",
status: "ACTIVE",
startDate: new Date("2026-03-15"),
endDate: new Date("2026-03-15"),
orderType: "CHARGEABLE",
allocationType: "INT",
winProbability: 100,
budgetCents: 100_000_00,
responsiblePerson: "Test",
});
const db = {
estimate: { findUnique },
project: { findUnique: projectFindUnique },
demandRequirement: { findMany: vi.fn().mockResolvedValue([]) },
assignment: { findMany: vi.fn().mockResolvedValue([]) },
resource: { findMany: vi.fn().mockResolvedValue([]) },
auditLog: { create: vi.fn() },
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
};
const caller = createManagerCaller(db);
await expect(
caller.createPlanningHandoff({ estimateId: "est_1" }),
).rejects.toThrow(
expect.objectContaining({
code: "PRECONDITION_FAILED",
message: 'Project window has no working days for demand line "Staffing Gap"',
}),
);
});
});
// ─── RBAC ──────────────────────────────────────────────────────────────────
@@ -4,7 +4,9 @@ import {
cancelPendingEvents,
eventBus,
flushPendingEvents,
permissionAudience,
type SseEvent,
userAudience,
} from "../sse/event-bus.js";
// Mock Redis so the module loads without a real connection.
@@ -153,4 +155,80 @@ describe("event-bus debounce", () => {
// The timestamp should be from the first event (not later)
expect(received[0]!.timestamp).toBe(before);
});
it("delivers scoped events only to matching audiences", () => {
const managerReceived: SseEvent[] = [];
const userReceived: SseEvent[] = [];
const unsubscribeManager = eventBus.subscribe((event) => {
managerReceived.push(event);
}, {
audiences: [permissionAudience("manageAllocations")],
includeUnscoped: false,
});
const unsubscribeUser = eventBus.subscribe((event) => {
userReceived.push(event);
}, {
audiences: [userAudience("user_1")],
includeUnscoped: false,
});
eventBus.emit(
SSE_EVENT_TYPES.ALLOCATION_CREATED,
{ id: "a1" },
[permissionAudience("manageAllocations")],
);
eventBus.emit(
SSE_EVENT_TYPES.NOTIFICATION_CREATED,
{ notificationId: "n1" },
[userAudience("user_1")],
);
vi.advanceTimersByTime(50);
expect(managerReceived).toHaveLength(1);
expect(managerReceived[0]!.type).toBe(SSE_EVENT_TYPES.ALLOCATION_CREATED);
expect(userReceived).toHaveLength(1);
expect(userReceived[0]!.type).toBe(SSE_EVENT_TYPES.NOTIFICATION_CREATED);
unsubscribeManager();
unsubscribeUser();
});
it("does not batch events from different audiences together", () => {
const firstUserReceived: SseEvent[] = [];
const secondUserReceived: SseEvent[] = [];
const unsubscribeFirst = eventBus.subscribe((event) => {
firstUserReceived.push(event);
}, {
audiences: [userAudience("user_1")],
includeUnscoped: false,
});
const unsubscribeSecond = eventBus.subscribe((event) => {
secondUserReceived.push(event);
}, {
audiences: [userAudience("user_2")],
includeUnscoped: false,
});
eventBus.emit(
SSE_EVENT_TYPES.NOTIFICATION_CREATED,
{ notificationId: "n1" },
[userAudience("user_1")],
);
eventBus.emit(
SSE_EVENT_TYPES.NOTIFICATION_CREATED,
{ notificationId: "n2" },
[userAudience("user_2")],
);
vi.advanceTimersByTime(50);
expect(firstUserReceived).toHaveLength(1);
expect(firstUserReceived[0]!.payload).toEqual({ notificationId: "n1" });
expect(secondUserReceived).toHaveLength(1);
expect(secondUserReceived[0]!.payload).toEqual({ notificationId: "n2" });
unsubscribeFirst();
unsubscribeSecond();
});
});
@@ -40,6 +40,114 @@ function createAdminCaller(db: Record<string, unknown>) {
}
describe("holiday calendar router", () => {
it("lists holiday calendars with assistant-facing detail formatting", async () => {
const db = {
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 caller = createProtectedCaller(db);
const result = await caller.listCalendarsDetail({
countryCode: "DE",
scopeType: "STATE",
includeInactive: true,
});
expect(result).toEqual({
count: 1,
calendars: [
expect.objectContaining({
id: "cal_by",
name: "Bayern Feiertage",
scopeType: "STATE",
stateCode: "BY",
entryCount: 2,
country: { id: "country_de", code: "DE", name: "Deutschland" },
entries: [
expect.objectContaining({
id: "entry_1",
date: "2026-01-06",
name: "Heilige Drei Koenige",
isRecurringAnnual: true,
source: "state",
}),
],
}),
],
});
});
it("resolves a holiday calendar by identifier with assistant-facing detail formatting", async () => {
const db = {
holidayCalendar: {
findUnique: vi.fn().mockResolvedValue(null),
findFirst: vi
.fn()
.mockResolvedValueOnce({
id: "cal_augsburg",
name: "Augsburg lokal",
scopeType: "CITY",
stateCode: null,
isActive: true,
priority: 5,
country: { id: "country_de", code: "DE", name: "Deutschland" },
metroCity: { id: "city_augsburg", name: "Augsburg" },
entries: [
{
id: "entry_1",
date: new Date("2026-08-08T00:00:00.000Z"),
name: "Friedensfest lokal",
isRecurringAnnual: true,
source: "manual",
},
],
}),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getCalendarByIdentifierDetail({ identifier: "Augsburg lokal" });
expect(result).toEqual(
expect.objectContaining({
id: "cal_augsburg",
name: "Augsburg lokal",
scopeType: "CITY",
entryCount: 1,
metroCity: { id: "city_augsburg", name: "Augsburg" },
entries: [
expect.objectContaining({
id: "entry_1",
date: "2026-08-08",
name: "Friedensfest lokal",
}),
],
}),
);
});
it("merges built-in and scoped custom holidays in preview", async () => {
const db = {
country: {
@@ -106,6 +214,164 @@ describe("holiday calendar router", () => {
);
});
it("formats preview results for assistant consumption", async () => {
const db = {
country: {
findUnique: vi.fn().mockResolvedValue({ id: "country_de", code: "DE", name: "Deutschland" }),
},
metroCity: {
findUnique: vi.fn().mockResolvedValue({ id: "city_augsburg", name: "Augsburg", countryId: "country_de" }),
},
holidayCalendar: {
findMany: vi.fn().mockResolvedValue([
{
id: "cal_city",
name: "Augsburg lokal",
scopeType: "CITY",
priority: 10,
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 caller = createProtectedCaller(db);
const result = await caller.previewResolvedHolidaysDetail({
countryId: "country_de",
metroCityId: "city_augsburg",
year: 2026,
});
expect(result.locationContext).toEqual({
countryId: "country_de",
countryCode: "DE",
stateCode: null,
metroCityId: "city_augsburg",
metroCity: "Augsburg",
year: 2026,
});
expect(result.summary.byScope).toEqual(
expect.arrayContaining([expect.objectContaining({ scope: "CITY" })]),
);
expect(result.holidays).toEqual(
expect.arrayContaining([
expect.objectContaining({
date: "2026-08-08",
name: "Friedensfest lokal",
scope: "CITY",
calendarName: "Augsburg lokal",
sourceType: "CUSTOM",
}),
]),
);
});
it("formats resolved holidays by region for assistant consumption", async () => {
const db = {
holidayCalendar: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.resolveHolidaysDetail({
countryCode: "DE",
stateCode: "BY",
periodStart: new Date("2026-01-01T00:00:00.000Z"),
periodEnd: new Date("2026-12-31T00:00:00.000Z"),
});
expect(result.locationContext).toEqual({
countryId: null,
countryCode: "DE",
federalState: "BY",
metroCityId: null,
metroCity: null,
});
expect(result.count).toBeGreaterThan(0);
expect(result.summary.byScope).toEqual(
expect.arrayContaining([expect.objectContaining({ scope: "STATE" })]),
);
expect(result.holidays).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: "Heilige Drei Könige", date: "2026-01-06", scope: "STATE" }),
]),
);
});
it("formats resolved holidays for a resource including local city holidays", async () => {
const db = {
resource: {
findUnique: vi.fn().mockResolvedValue({
id: "res_1",
eid: "bruce.banner",
displayName: "Bruce Banner",
federalState: "BY",
countryId: "country_de",
metroCityId: "city_augsburg",
country: { code: "DE", name: "Deutschland" },
metroCity: { 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: "Augsburger Friedensfest",
isRecurringAnnual: true,
source: "manual",
},
],
},
]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.resolveResourceHolidaysDetail({
resourceId: "res_1",
periodStart: new Date("2026-01-01T00:00:00.000Z"),
periodEnd: new Date("2026-12-31T00:00:00.000Z"),
});
expect(result.resource).toEqual(
expect.objectContaining({
eid: "bruce.banner",
federalState: "BY",
metroCity: "Augsburg",
}),
);
expect(result.summary.byScope).toEqual(
expect.arrayContaining([expect.objectContaining({ scope: "CITY" })]),
);
expect(result.holidays).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "Augsburger Friedensfest",
date: "2026-08-08",
scope: "CITY",
}),
]),
);
});
it("rejects duplicate calendar scopes on create", async () => {
const db = {
country: {
@@ -0,0 +1,207 @@
import { BlueprintTarget, SystemRole } from "@capakraken/shared";
import { describe, expect, it, vi } from "vitest";
import { blueprintRouter } from "../router/blueprint.js";
import { clientRouter } from "../router/client.js";
import { countryRouter } from "../router/country.js";
import { orgUnitRouter } from "../router/org-unit.js";
import { roleRouter } from "../router/role.js";
import { createCallerFactory } from "../trpc.js";
function createProtectedContext(db: Record<string, unknown>) {
return {
session: {
user: { email: "user@example.com", name: "User", image: null },
expires: "2099-01-01T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_1",
systemRole: SystemRole.USER,
permissionOverrides: null,
},
};
}
describe("identifier resolvers", () => {
it("resolves blueprints via a minimal read model", async () => {
const findUnique = vi.fn().mockResolvedValue({
id: "bp_1",
name: "Consulting Blueprint",
target: BlueprintTarget.PROJECT,
isActive: true,
});
const caller = createCallerFactory(blueprintRouter)(createProtectedContext({
blueprint: {
findUnique,
findFirst: vi.fn(),
},
}));
const result = await caller.resolveByIdentifier({ identifier: "bp_1" });
expect(result).toEqual({
id: "bp_1",
name: "Consulting Blueprint",
target: BlueprintTarget.PROJECT,
isActive: true,
});
expect(findUnique).toHaveBeenCalledWith(expect.objectContaining({
where: { id: "bp_1" },
select: expect.objectContaining({
id: true,
name: true,
target: true,
isActive: true,
}),
}));
});
it("resolves clients by code via a minimal read model", async () => {
const findUnique = vi.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({
id: "client_1",
name: "Acme",
code: "ACME",
parentId: null,
isActive: true,
});
const caller = createCallerFactory(clientRouter)(createProtectedContext({
client: {
findUnique,
findFirst: vi.fn(),
},
}));
const result = await caller.resolveByIdentifier({ identifier: "ACME" });
expect(result).toEqual({
id: "client_1",
name: "Acme",
code: "ACME",
parentId: null,
isActive: true,
});
expect(findUnique).toHaveBeenNthCalledWith(2, expect.objectContaining({
where: { code: "ACME" },
select: expect.objectContaining({
id: true,
name: true,
code: true,
parentId: true,
isActive: true,
}),
}));
});
it("resolves countries by code via a minimal read model", async () => {
const findUnique = vi.fn().mockResolvedValue(null);
const findFirst = vi.fn().mockResolvedValue({
id: "country_de",
code: "DE",
name: "Germany",
isActive: true,
dailyWorkingHours: 8,
});
const caller = createCallerFactory(countryRouter)(createProtectedContext({
country: {
findUnique,
findFirst,
},
}));
const result = await caller.resolveByIdentifier({ identifier: "de" });
expect(result).toEqual({
id: "country_de",
code: "DE",
name: "Germany",
isActive: true,
dailyWorkingHours: 8,
});
expect(findFirst).toHaveBeenNthCalledWith(1, expect.objectContaining({
where: { code: { equals: "DE", mode: "insensitive" } },
select: expect.objectContaining({
id: true,
code: true,
name: true,
isActive: true,
dailyWorkingHours: true,
}),
}));
});
it("resolves org units by short name via a minimal read model", async () => {
const findUnique = vi.fn().mockResolvedValue(null);
const findFirst = vi.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({
id: "ou_1",
name: "Delivery",
shortName: "DEL",
level: 5,
isActive: true,
});
const caller = createCallerFactory(orgUnitRouter)(createProtectedContext({
orgUnit: {
findUnique,
findFirst,
},
}));
const result = await caller.resolveByIdentifier({ identifier: "DEL" });
expect(result).toEqual({
id: "ou_1",
name: "Delivery",
shortName: "DEL",
level: 5,
isActive: true,
});
expect(findFirst).toHaveBeenNthCalledWith(2, expect.objectContaining({
where: { shortName: { equals: "DEL", mode: "insensitive" } },
select: expect.objectContaining({
id: true,
name: true,
shortName: true,
level: true,
isActive: true,
}),
}));
});
it("resolves roles without loading planning counts", async () => {
const findUnique = vi.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({
id: "role_1",
name: "Designer",
color: "#123456",
isActive: true,
});
const caller = createCallerFactory(roleRouter)(createProtectedContext({
role: {
findUnique,
findFirst: vi.fn(),
},
}));
const result = await caller.resolveByIdentifier({ identifier: "Designer" });
expect(result).toEqual({
id: "role_1",
name: "Designer",
color: "#123456",
isActive: true,
});
expect(findUnique).toHaveBeenNthCalledWith(2, expect.objectContaining({
where: { name: "Designer" },
select: expect.objectContaining({
id: true,
name: true,
color: true,
isActive: true,
}),
}));
});
});
@@ -0,0 +1,198 @@
import { SystemRole } from "@capakraken/shared";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { insightsRouter } from "../router/insights.js";
import { createCallerFactory } from "../trpc.js";
const createCaller = createCallerFactory(insightsRouter);
function createControllerCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "controller@example.com", name: "Controller", image: null },
expires: "2099-01-01T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_controller",
systemRole: SystemRole.CONTROLLER,
permissionOverrides: null,
},
roleDefaults: null,
});
}
describe("insights router", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("derives the summary from the same canonical anomaly snapshot", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-29T12:00:00.000Z"));
try {
const db = {
project: {
findMany: vi.fn().mockResolvedValue([
{
id: "project_1",
name: "Apollo",
budgetCents: 100_000,
startDate: new Date("2026-03-01T00:00:00.000Z"),
endDate: new Date("2026-03-31T00:00:00.000Z"),
demandRequirements: [
{
headcount: 3,
startDate: new Date("2026-03-20T00:00:00.000Z"),
endDate: new Date("2026-04-05T00:00:00.000Z"),
_count: { assignments: 1 },
},
],
assignments: [
{
resourceId: "res_1",
startDate: new Date("2026-03-01T00:00:00.000Z"),
endDate: new Date("2026-04-05T00:00:00.000Z"),
hoursPerDay: 12,
dailyCostCents: 10_000,
status: "ACTIVE",
},
],
},
]),
},
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "res_1",
displayName: "Peter Parker",
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
},
},
]),
},
assignment: {
findMany: vi.fn().mockResolvedValue([
{
resourceId: "res_1",
hoursPerDay: 12,
},
]),
},
};
const caller = createControllerCaller(db);
const anomalies = await caller.detectAnomalies();
const summary = await caller.getInsightsSummary();
expect(anomalies).toEqual([
expect.objectContaining({ type: "budget", severity: "critical", entityName: "Apollo" }),
expect.objectContaining({ type: "staffing", severity: "critical", entityName: "Apollo" }),
expect.objectContaining({ type: "utilization", severity: "critical", entityName: "Peter Parker" }),
expect.objectContaining({ type: "timeline", severity: "warning", entityName: "Apollo" }),
]);
expect(summary).toEqual({
total: anomalies.length,
criticalCount: anomalies.filter((anomaly) => anomaly.severity === "critical").length,
budget: anomalies.filter((anomaly) => anomaly.type === "budget").length,
staffing: anomalies.filter((anomaly) => anomaly.type === "staffing").length,
timeline: anomalies.filter((anomaly) => anomaly.type === "timeline").length,
utilization: anomalies.filter((anomaly) => anomaly.type === "utilization").length,
});
expect(db.project.findMany).toHaveBeenCalledTimes(2);
expect(db.resource.findMany).toHaveBeenCalledWith({
where: { isActive: true },
select: {
id: true,
displayName: true,
availability: true,
},
});
} finally {
vi.useRealTimers();
}
});
it("returns assistant-friendly anomaly detail from the canonical snapshot", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-15T00:00:00.000Z"));
try {
const db = {
project: {
findMany: vi.fn().mockResolvedValue([
{
id: "project_1",
name: "Apollo",
budgetCents: 100_000,
startDate: new Date("2026-03-01T00:00:00.000Z"),
endDate: new Date("2026-03-31T00:00:00.000Z"),
demandRequirements: [
{
headcount: 3,
startDate: new Date("2026-03-10T00:00:00.000Z"),
endDate: new Date("2026-03-20T00:00:00.000Z"),
_count: { assignments: 1 },
},
],
assignments: [
{
resourceId: "res_1",
startDate: new Date("2026-03-01T00:00:00.000Z"),
endDate: new Date("2026-04-05T00:00:00.000Z"),
hoursPerDay: 12,
dailyCostCents: 10_000,
status: "ACTIVE",
},
],
},
]),
},
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "res_1",
displayName: "Peter Parker",
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
},
},
]),
},
assignment: {
findMany: vi.fn().mockResolvedValue([
{
resourceId: "res_1",
hoursPerDay: 12,
},
]),
},
};
const caller = createControllerCaller(db);
const result = await caller.getAnomalyDetail();
expect(result).toEqual({
count: 4,
anomalies: [
expect.objectContaining({ type: "budget", severity: "critical", entityName: "Apollo" }),
expect.objectContaining({ type: "staffing", severity: "critical", entityName: "Apollo" }),
expect.objectContaining({ type: "utilization", severity: "critical", entityName: "Peter Parker" }),
expect.objectContaining({ type: "timeline", severity: "warning", entityName: "Apollo" }),
],
});
expect(db.project.findMany).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
});
@@ -1,6 +1,9 @@
import { OrderType, AllocationType, ProjectStatus, SystemRole } from "@capakraken/shared";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { invalidateDashboardCache } from "../lib/cache.js";
import { logger } from "../lib/logger.js";
import { projectRouter } from "../router/project.js";
import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
import { createCallerFactory } from "../trpc.js";
vi.mock("@capakraken/application", async (importOriginal) => {
@@ -26,6 +29,19 @@ vi.mock("../lib/cache.js", () => ({
invalidateDashboardCache: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../lib/webhook-dispatcher.js", () => ({
dispatchWebhooks: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../lib/logger.js", () => ({
logger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
},
}));
vi.mock("../ai-client.js", () => ({
isDalleConfigured: vi.fn().mockReturnValue(false),
createDalleClient: vi.fn(),
@@ -155,6 +171,47 @@ describe("project router", () => {
expect(db.auditLog.create).toHaveBeenCalled();
});
it("logs and swallows background cache and webhook failures during create", async () => {
vi.mocked(invalidateDashboardCache).mockRejectedValueOnce(new Error("redis unavailable"));
vi.mocked(dispatchWebhooks).mockRejectedValueOnce(new Error("webhook unavailable"));
const created = { ...sampleProject, id: "project_safe_create" };
const db = {
project: {
findUnique: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue(created),
},
auditLog: { create: vi.fn().mockResolvedValue({}) },
webhook: { findMany: vi.fn().mockResolvedValue([]) },
};
const caller = createManagerCaller(db);
const result = await caller.create({
shortCode: "SAFE-001",
name: "Safe Project",
responsiblePerson: "Alice",
orderType: OrderType.CHARGEABLE,
allocationType: AllocationType.INT,
winProbability: 80,
budgetCents: 500_000_00,
startDate: new Date("2026-01-01"),
endDate: new Date("2026-06-30"),
});
await Promise.resolve();
await Promise.resolve();
expect(result.id).toBe("project_safe_create");
expect(vi.mocked(logger.error)).toHaveBeenCalledWith(
expect.objectContaining({ effectName: "invalidateDashboardCache" }),
"Project background side effect failed",
);
expect(vi.mocked(logger.error)).toHaveBeenCalledWith(
expect.objectContaining({ effectName: "dispatchWebhooks", event: "project.created" }),
"Project background side effect failed",
);
});
it("throws CONFLICT when shortCode already exists", async () => {
const db = {
project: {
@@ -208,7 +265,7 @@ describe("project router", () => {
// ─── getById ──────────────────────────────────────────────────────────────
describe("getById", () => {
it("returns the correct project with allocations and demands", async () => {
it("returns the correct project with allocations and demands for controller-level access", async () => {
const db = {
project: {
findUnique: vi.fn().mockResolvedValue({ ...sampleProject, blueprint: null }),
@@ -218,7 +275,7 @@ describe("project router", () => {
assignment: { findMany: vi.fn().mockResolvedValue([]) },
};
const caller = createProtectedCaller(db);
const caller = createControllerCaller(db);
const result = await caller.getById({ id: "project_1" });
expect(result.id).toBe("project_1");
@@ -236,11 +293,22 @@ describe("project router", () => {
assignment: { findMany: vi.fn().mockResolvedValue([]) },
};
const caller = createProtectedCaller(db);
const caller = createControllerCaller(db);
await expect(caller.getById({ id: "missing" })).rejects.toThrow(
expect.objectContaining({ code: "NOT_FOUND" }),
);
});
it("blocks USER role from loading full project planning context", async () => {
const db = {
project: { findUnique: vi.fn() },
};
const caller = createProtectedCaller(db);
await expect(caller.getById({ id: "project_1" })).rejects.toThrow(
expect.objectContaining({ code: "FORBIDDEN" }),
);
});
});
describe("getShoringRatio", () => {
@@ -292,7 +360,7 @@ describe("project router", () => {
},
};
const caller = createProtectedCaller(db);
const caller = createControllerCaller(db);
const result = await caller.getShoringRatio({ projectId: "project_1" });
expect(result.totalHours).toBe(24);
@@ -373,6 +441,38 @@ describe("project router", () => {
}),
);
});
it("logs and swallows background failures during status changes", async () => {
vi.mocked(invalidateDashboardCache).mockRejectedValueOnce(new Error("redis unavailable"));
vi.mocked(dispatchWebhooks).mockRejectedValueOnce(new Error("webhook unavailable"));
const updated = { ...sampleProject, status: ProjectStatus.COMPLETED };
const db = {
project: {
update: vi.fn().mockResolvedValue(updated),
},
webhook: { findMany: vi.fn().mockResolvedValue([]) },
};
const caller = createManagerCaller(db);
const result = await caller.updateStatus({
id: "project_1",
status: ProjectStatus.COMPLETED,
});
await Promise.resolve();
await Promise.resolve();
expect(result.status).toBe(ProjectStatus.COMPLETED);
expect(vi.mocked(logger.error)).toHaveBeenCalledWith(
expect.objectContaining({ effectName: "invalidateDashboardCache" }),
"Project background side effect failed",
);
expect(vi.mocked(logger.error)).toHaveBeenCalledWith(
expect.objectContaining({ effectName: "dispatchWebhooks", event: "project.status_changed" }),
"Project background side effect failed",
);
});
});
// ─── batchUpdateStatus ────────────────────────────────────────────────────
@@ -547,4 +647,212 @@ describe("project router", () => {
);
});
});
describe("assistant-facing detail routes", () => {
it("returns lightweight project search summaries from the canonical router", async () => {
const db = {
project: {
findMany: vi.fn().mockResolvedValue([
{
id: "project_1",
shortCode: "GDM",
name: "Gelddruckmaschine",
status: ProjectStatus.ACTIVE,
startDate: new Date("2026-01-01T00:00:00.000Z"),
endDate: new Date("2026-03-31T00:00:00.000Z"),
client: { name: "Acme Mobility" },
},
]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.searchSummaries({ search: "Gelddruckmaschine", limit: 10 });
expect(result).toEqual([
{
id: "project_1",
code: "GDM",
name: "Gelddruckmaschine",
status: "ACTIVE",
start: "2026-01-01",
end: "2026-03-31",
client: "Acme Mobility",
},
]);
});
it("returns formatted project search summaries from the canonical router", async () => {
const db = {
project: {
findMany: vi.fn().mockResolvedValue([
{
id: "project_1",
shortCode: "GDM",
name: "Gelddruckmaschine",
status: ProjectStatus.ACTIVE,
budgetCents: 500000,
winProbability: 100,
startDate: new Date("2026-01-01T00:00:00.000Z"),
endDate: new Date("2026-03-31T00:00:00.000Z"),
client: { name: "Acme Mobility" },
_count: { assignments: 3, estimates: 1 },
},
]),
},
};
const caller = createControllerCaller(db);
const result = await caller.searchSummariesDetail({ search: "Gelddruckmaschine", limit: 10 });
expect(result).toEqual([
{
id: "project_1",
code: "GDM",
name: "Gelddruckmaschine",
status: "ACTIVE",
budget: "5.000,00 EUR",
winProbability: "100%",
start: "2026-01-01",
end: "2026-03-31",
client: "Acme Mobility",
assignmentCount: 3,
estimateCount: 1,
},
]);
});
it("blocks USER role from detailed project search summaries", async () => {
const db = {
project: {
findMany: vi.fn(),
},
};
const caller = createProtectedCaller(db);
await expect(
caller.searchSummariesDetail({ search: "Gelddruckmaschine", limit: 10 }),
).rejects.toThrow(expect.objectContaining({ code: "FORBIDDEN" }));
});
it("returns lightweight project identifier reads from the canonical router", async () => {
const db = {
project: {
findUnique: vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce({
id: "project_1",
shortCode: "GDM",
name: "Gelddruckmaschine",
status: ProjectStatus.ACTIVE,
startDate: new Date("2026-01-01T00:00:00.000Z"),
endDate: new Date("2026-03-31T00:00:00.000Z"),
}),
findFirst: vi.fn(),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getByIdentifier({ identifier: "GDM" });
expect(result).toEqual({
id: "project_1",
shortCode: "GDM",
name: "Gelddruckmaschine",
status: ProjectStatus.ACTIVE,
startDate: new Date("2026-01-01T00:00:00.000Z"),
endDate: new Date("2026-03-31T00:00:00.000Z"),
});
});
it("returns formatted project details from the canonical router", async () => {
const db = {
project: {
findUnique: vi.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({
id: "project_1",
shortCode: "GDM",
name: "Gelddruckmaschine",
status: ProjectStatus.ACTIVE,
startDate: new Date("2026-01-01T00:00:00.000Z"),
endDate: new Date("2026-03-31T00:00:00.000Z"),
})
.mockResolvedValueOnce({
id: "project_1",
shortCode: "GDM",
name: "Gelddruckmaschine",
status: ProjectStatus.ACTIVE,
orderType: OrderType.CHARGEABLE,
allocationType: AllocationType.INT,
budgetCents: 500000,
winProbability: 100,
startDate: new Date("2026-01-01T00:00:00.000Z"),
endDate: new Date("2026-03-31T00:00:00.000Z"),
responsiblePerson: "Bruce Banner",
client: { name: "Acme Mobility" },
utilizationCategory: { code: "BILLABLE", name: "Billable" },
_count: { assignments: 3, estimates: 1 },
}),
findFirst: vi.fn(),
},
assignment: {
findMany: vi.fn().mockResolvedValue([
{
resource: { displayName: "Bruce Banner", eid: "EMP-001" },
role: "Lead",
status: "ACTIVE",
hoursPerDay: 8,
startDate: new Date("2026-02-01T00:00:00.000Z"),
endDate: new Date("2026-02-28T00:00:00.000Z"),
},
]),
},
};
const caller = createControllerCaller(db);
const result = await caller.getByIdentifierDetail({ identifier: "GDM" });
expect(result).toEqual({
id: "project_1",
code: "GDM",
name: "Gelddruckmaschine",
status: "ACTIVE",
orderType: "CHARGEABLE",
allocationType: "INT",
budget: "5.000,00 EUR",
budgetCents: 500000,
winProbability: "100%",
start: "2026-01-01",
end: "2026-03-31",
responsible: "Bruce Banner",
client: "Acme Mobility",
category: "Billable",
assignmentCount: 3,
estimateCount: 1,
topAllocations: [
{
resource: "Bruce Banner",
eid: "EMP-001",
role: "Lead",
status: "ACTIVE",
hoursPerDay: 8,
start: "2026-02-01",
end: "2026-02-28",
},
],
});
});
it("blocks USER role from detailed project identifier reads", async () => {
const db = {
project: {
findUnique: vi.fn(),
},
};
const caller = createProtectedCaller(db);
await expect(
caller.getByIdentifierDetail({ identifier: "GDM" }),
).rejects.toThrow(expect.objectContaining({ code: "FORBIDDEN" }));
});
});
});
@@ -1,15 +1,23 @@
import { describe, expect, it, vi } from "vitest";
import type { inferProcedureInput } from "@trpc/server";
import type { AppRouter } from "../router/index.js";
import { SystemRole } from "@capakraken/shared";
import { rateCardRouter } from "../router/rate-card.js";
import { createCallerFactory } from "../trpc.js";
// Minimal mock helpers
function mockCtx(overrides: Record<string, unknown> = {}) {
return {
ctx: {
session: { user: { id: "user_1", systemRole: "MANAGER" } },
db: overrides,
const createCaller = createCallerFactory(rateCardRouter);
function createControllerCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "controller@example.com", name: "Controller", image: null },
expires: "2099-01-01T00:00:00.000Z",
},
};
db: db as never,
dbUser: {
id: "user_1",
systemRole: SystemRole.CONTROLLER,
permissionOverrides: null,
},
});
}
describe("rateCard router", () => {
@@ -60,6 +68,52 @@ describe("rateCard router", () => {
});
describe("resolveRate", () => {
it("resolves a resource-based rate through the canonical router query", async () => {
const db = {
resource: {
findUnique: vi.fn().mockResolvedValue({
id: "res_1",
displayName: "Bruce Banner",
chapter: "Delivery",
areaRole: { name: "Pipeline TD" },
}),
},
role: {
findFirst: vi.fn().mockResolvedValue({ id: "role_1" }),
},
rateCard: {
findMany: vi.fn().mockResolvedValue([
{
id: "rc_2026",
name: "Standard 2026",
client: null,
lines: [
{
id: "line_1",
chapter: "Delivery",
seniority: "Senior",
costRateCents: 12_000,
billRateCents: 18_000,
role: { id: "role_1", name: "Pipeline TD" },
},
],
},
]),
},
};
const caller = createControllerCaller(db);
const result = await caller.resolveBestRate({ resourceId: "res_1" });
expect(result).toEqual({
rateCard: "Standard 2026",
resource: "Bruce Banner",
rate: "120,00 EUR",
rateCents: 12000,
matchedBy: "role: Pipeline TD",
});
});
it("returns the most specific matching line", () => {
const lines = [
{ id: "rcl_1", roleId: null, chapter: "Digital Content Production", costRateCents: 7000, billRateCents: 12000 },
@@ -115,4 +115,80 @@ describe("report router", () => {
expect(result.csv).toContain("Name,Country Code,Holiday Dates,Holiday Hours Deduction,Absence Hours Deduction,SAH,Target Hours,Unassigned Hours");
expect(result.csv).toContain("Alice,DE,1,8,4,156,124.8,156");
});
it("rejects invalid resource_month period months instead of silently normalizing them", async () => {
const caller = createControllerCaller({});
await expect(caller.getReportData({
entity: "resource_month",
columns: ["displayName"],
filters: [],
periodMonth: "2026-13",
limit: 10,
offset: 0,
})).rejects.toMatchObject({
code: "BAD_REQUEST",
message: expect.stringContaining("Invalid"),
});
});
it("rejects unknown columns instead of silently dropping them", async () => {
const caller = createControllerCaller({
resource: {
findMany: vi.fn(),
count: vi.fn(),
},
});
await expect(caller.getReportData({
entity: "resource",
columns: ["displayName", "unknownColumn"],
filters: [],
limit: 10,
offset: 0,
})).rejects.toMatchObject({
code: "BAD_REQUEST",
message: expect.stringContaining("unknownColumn"),
});
});
it("rejects unsupported relation filters instead of silently ignoring them", async () => {
const caller = createControllerCaller({
assignment: {
findMany: vi.fn(),
count: vi.fn(),
},
});
await expect(caller.getReportData({
entity: "assignment",
columns: ["id", "resource.displayName"],
filters: [{ field: "resource.displayName", op: "contains", value: "Alice" }],
limit: 10,
offset: 0,
})).rejects.toMatchObject({
code: "BAD_REQUEST",
message: expect.stringContaining("resource.displayName"),
});
});
it("rejects invalid numeric filter values instead of silently dropping them", async () => {
const caller = createControllerCaller({
resource: {
findMany: vi.fn(),
count: vi.fn(),
},
});
await expect(caller.getReportData({
entity: "resource",
columns: ["displayName"],
filters: [{ field: "lcrCents", op: "gte", value: "not-a-number" }],
limit: 10,
offset: 0,
})).rejects.toMatchObject({
code: "BAD_REQUEST",
message: expect.stringContaining("lcrCents"),
});
});
});
@@ -112,10 +112,10 @@ describe("resource router CRUD", () => {
vi.clearAllMocks();
});
// ─── list ─────────────────────────────────────────────────────────────────
// ─── listStaff ────────────────────────────────────────────────────────────
describe("list", () => {
it("returns paginated results with total count", async () => {
describe("listStaff", () => {
it("returns paginated results with total count for staff callers", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([sampleResource]),
@@ -123,15 +123,15 @@ describe("resource router CRUD", () => {
},
};
const caller = createProtectedCaller(db);
const result = await caller.list({ limit: 50 });
const caller = createManagerCaller(db);
const result = await caller.listStaff({ limit: 50 });
expect(result.resources).toHaveLength(1);
expect(result.resources[0]?.displayName).toBe("Alice");
expect(db.resource.findMany).toHaveBeenCalled();
});
it("applies search filter", async () => {
it("applies search filter for staff callers", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([]),
@@ -139,8 +139,8 @@ describe("resource router CRUD", () => {
},
};
const caller = createProtectedCaller(db);
await caller.list({ search: "Alice", limit: 50 });
const caller = createManagerCaller(db);
await caller.listStaff({ search: "Alice", limit: 50 });
expect(db.resource.findMany).toHaveBeenCalled();
});
@@ -152,7 +152,8 @@ describe("resource router CRUD", () => {
it("returns correct resource", async () => {
const db = {
resource: {
findUnique: vi.fn().mockResolvedValue(sampleResource),
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
findUnique: vi.fn().mockResolvedValue({ ...sampleResource, userId: "user_1" }),
findMany: vi.fn().mockResolvedValue([]),
},
systemSettings: {
@@ -170,6 +171,7 @@ describe("resource router CRUD", () => {
it("throws NOT_FOUND when resource does not exist", async () => {
const db = {
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
findUnique: vi.fn().mockResolvedValue(null),
findMany: vi.fn().mockResolvedValue([]),
},
@@ -188,6 +190,7 @@ describe("resource router CRUD", () => {
const ownedResource = { ...sampleResource, userId: "user_1" };
const db = {
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
findUnique: vi.fn().mockResolvedValue(ownedResource),
findMany: vi.fn().mockResolvedValue([]),
},
@@ -201,6 +204,21 @@ describe("resource router CRUD", () => {
expect(result.isOwnedByCurrentUser).toBe(true);
});
it("rejects foreign resources for regular users", async () => {
const db = {
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_own" }),
findUnique: vi.fn().mockResolvedValue(sampleResource),
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createProtectedCaller(db);
await expect(caller.getById({ id: "res_1" })).rejects.toThrow(
expect.objectContaining({ code: "FORBIDDEN" }),
);
});
});
// ─── create ───────────────────────────────────────────────────────────────
@@ -349,6 +367,7 @@ describe("resource router CRUD", () => {
it("returns expected shape with key fields", async () => {
const db = {
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
findUnique: vi.fn().mockResolvedValue({
id: "res_1",
displayName: "Alice",
@@ -387,7 +406,10 @@ describe("resource router CRUD", () => {
it("throws NOT_FOUND for missing resource", async () => {
const db = {
resource: { findUnique: vi.fn().mockResolvedValue(null) },
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
findUnique: vi.fn().mockResolvedValue(null),
},
systemSettings: { findUnique: vi.fn().mockResolvedValue(null) },
};
@@ -396,6 +418,37 @@ describe("resource router CRUD", () => {
expect.objectContaining({ code: "NOT_FOUND" }),
);
});
it("rejects foreign hover-card access for regular users", async () => {
const db = {
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_own" }),
findUnique: vi.fn().mockResolvedValue({
id: "res_1",
displayName: "Alice",
eid: "E-001",
email: "alice@example.com",
chapter: "CGI",
lcrCents: 5000,
ucrCents: 9000,
currency: "EUR",
chargeabilityTarget: 80,
skills: [],
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
isActive: true,
areaRole: null,
country: null,
managementLevel: null,
resourceType: null,
}),
},
};
const caller = createProtectedCaller(db);
await expect(caller.getHoverCard({ id: "res_1" })).rejects.toThrow(
expect.objectContaining({ code: "FORBIDDEN" }),
);
});
});
// ─── importSkillMatrix ────────────────────────────────────────────────────
File diff suppressed because it is too large Load Diff
@@ -1,3 +1,4 @@
import { listAssignmentBookings } from "@capakraken/application";
import { SystemRole } from "@capakraken/shared";
import { describe, expect, it, vi } from "vitest";
import { staffingRouter } from "../router/staffing.js";
@@ -245,6 +246,303 @@ describe("staffing.getSuggestions", () => {
});
});
describe("staffing.getProjectStaffingSuggestions", () => {
it("returns canonical project-scoped staffing suggestions with defaults and role filter", async () => {
vi.mocked(listAssignmentBookings).mockResolvedValueOnce([]);
const db = {
project: {
findUnique: vi.fn().mockResolvedValue({
id: "project_1",
shortCode: "GDM",
name: "Gelddruckmaschine",
startDate: new Date("2026-01-06T00:00:00.000Z"),
endDate: new Date("2026-01-06T00:00:00.000Z"),
}),
},
resource: {
findMany: vi.fn().mockResolvedValue([
sampleResource({
id: "res_by",
displayName: "Bavaria",
eid: "BY-1",
areaRole: { name: "Consultant" },
country: { code: "DE", name: "Germany" },
}),
sampleResource({
id: "res_hh",
displayName: "Hamburg",
eid: "HH-1",
federalState: "HH",
areaRole: { name: "Artist" },
country: { code: "DE", name: "Germany" },
}),
]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getProjectStaffingSuggestions({
projectId: "project_1",
roleName: "artist",
limit: 5,
});
expect(result).toEqual({
project: "Gelddruckmaschine (GDM)",
period: "2026-01-06 to 2026-01-06",
suggestions: [
{
id: "res_hh",
name: "Hamburg",
eid: "HH-1",
role: "Artist",
chapter: "VFX",
fte: expect.any(Number),
lcr: "75,00 EUR",
workingDays: expect.any(Number),
availableHours: expect.any(Number),
bookedHours: 0,
availableHoursPerDay: expect.any(Number),
utilization: 0,
},
],
});
expect(db.project.findUnique).toHaveBeenCalledWith({
where: { id: "project_1" },
select: {
id: true,
shortCode: true,
name: true,
startDate: true,
endDate: true,
},
});
});
});
describe("staffing.getBestProjectResourceDetail", () => {
it("returns canonical project resource ranking with holiday-aware capacity details", async () => {
const assignmentFindMany = vi
.fn()
.mockResolvedValueOnce([
{
resourceId: "res_carol",
hoursPerDay: 2,
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-16T00:00:00.000Z"),
status: "PROPOSED",
resource: {
id: "res_carol",
eid: "carol.danvers",
displayName: "Carol Danvers",
chapter: "Delivery",
lcrCents: 7664,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_de",
federalState: "HH",
metroCityId: "city_hamburg",
country: { code: "DE", name: "Deutschland" },
metroCity: { name: "Hamburg" },
areaRole: { name: "Artist" },
},
},
{
resourceId: "res_steve",
hoursPerDay: 4,
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-16T00:00:00.000Z"),
status: "CONFIRMED",
resource: {
id: "res_steve",
eid: "steve.rogers",
displayName: "Steve Rogers",
chapter: "Delivery",
lcrCents: 13377,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_de",
federalState: "BY",
metroCityId: "city_augsburg",
country: { code: "DE", name: "Deutschland" },
metroCity: { name: "Augsburg" },
areaRole: { name: "Artist" },
},
},
])
.mockResolvedValueOnce([
{
resourceId: "res_carol",
projectId: "project_lari",
hoursPerDay: 2,
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-16T00:00:00.000Z"),
status: "PROPOSED",
project: { name: "Gelddruckmaschine", shortCode: "LARI" },
},
{
resourceId: "res_steve",
projectId: "project_lari",
hoursPerDay: 4,
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-16T00:00:00.000Z"),
status: "CONFIRMED",
project: { name: "Gelddruckmaschine", shortCode: "LARI" },
},
]);
const db = {
project: {
findUnique: vi.fn().mockResolvedValue({
id: "project_lari",
name: "Gelddruckmaschine",
shortCode: "LARI",
status: "ACTIVE",
responsiblePerson: "Larissa Joos",
}),
},
assignment: {
findMany: assignmentFindMany,
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getBestProjectResourceDetail({
projectId: "project_lari",
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-16T00:00:00.000Z"),
minHoursPerDay: 3,
rankingMode: "lowest_lcr",
});
expect(result.project).toEqual({
id: "project_lari",
name: "Gelddruckmaschine",
shortCode: "LARI",
status: "ACTIVE",
responsiblePerson: "Larissa Joos",
});
expect(result.period).toEqual({
startDate: "2026-01-05",
endDate: "2026-01-16",
minHoursPerDay: 3,
rankingMode: "lowest_lcr",
});
expect(result.filters).toEqual({
chapter: null,
roleName: null,
});
expect(result.candidateCount).toBe(2);
expect(result.bestMatch).toEqual(
expect.objectContaining({
name: "Carol Danvers",
remainingHoursPerDay: 6,
lcrCents: 7664,
federalState: "HH",
metroCity: "Hamburg",
baseAvailableHours: 80,
holidaySummary: expect.objectContaining({ count: 0 }),
}),
);
expect(result.candidates).toEqual([
expect.objectContaining({
name: "Carol Danvers",
remainingHoursPerDay: 6,
workingDays: 10,
baseAvailableHours: 80,
holidaySummary: expect.objectContaining({ count: 0, hoursDeduction: 0 }),
capacityBreakdown: expect.objectContaining({ holidayHoursDeduction: 0 }),
}),
expect.objectContaining({
name: "Steve Rogers",
remainingHoursPerDay: 4,
workingDays: 9,
baseAvailableHours: 80,
holidaySummary: expect.objectContaining({ count: 1, hoursDeduction: 8 }),
capacityBreakdown: expect.objectContaining({ holidayHoursDeduction: 8 }),
}),
]);
});
});
describe("staffing.searchCapacity", () => {
it("returns holiday-aware capacity across multiple resources", async () => {
vi.mocked(listAssignmentBookings).mockResolvedValueOnce([]);
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([
sampleResource({
id: "res_by",
displayName: "Bavaria",
eid: "BY-1",
chapter: "CGI",
areaRole: { name: "Consultant" },
federalState: "BY",
}),
sampleResource({
id: "res_hh",
displayName: "Hamburg",
eid: "HH-1",
chapter: "CGI",
areaRole: { name: "Consultant" },
federalState: "HH",
}),
]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.searchCapacity({
startDate: new Date("2026-01-06"),
endDate: new Date("2026-01-06"),
minHoursPerDay: 1,
});
expect(result.results).toHaveLength(1);
expect(result.results[0]).toEqual(
expect.objectContaining({
name: "Hamburg",
availableHours: 8,
availableHoursPerDay: 8,
}),
);
});
it("applies role and chapter filters in the resource query", async () => {
vi.mocked(listAssignmentBookings).mockResolvedValueOnce([]);
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createProtectedCaller(db);
await caller.searchCapacity({
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-02"),
minHoursPerDay: 4,
roleName: "Consult",
chapter: "CG",
limit: 5,
});
expect(db.resource.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
isActive: true,
areaRole: { name: { contains: "Consult", mode: "insensitive" } },
chapter: { contains: "CG", mode: "insensitive" },
}),
take: 100,
}),
);
});
});
// ─── analyzeUtilization ──────────────────────────────────────────────────────
describe("staffing.analyzeUtilization", () => {
@@ -0,0 +1,638 @@
import { SystemRole } from "@capakraken/shared";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@capakraken/application", async (importOriginal) => {
const actual = await importOriginal<typeof import("@capakraken/application")>();
return {
...actual,
listAssignmentBookings: vi.fn(),
};
});
vi.mock("../lib/anonymization.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/anonymization.js")>();
return {
...actual,
getAnonymizationDirectory: vi.fn().mockResolvedValue(null),
};
});
import { listAssignmentBookings } from "@capakraken/application";
import { timelineRouter } from "../router/timeline.js";
import { createCallerFactory } from "../trpc.js";
const createCaller = createCallerFactory(timelineRouter);
function createAdminCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "admin@example.com", name: "Admin", image: null },
expires: "2026-03-29T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_admin",
systemRole: SystemRole.ADMIN,
permissionOverrides: null,
},
roleDefaults: null,
});
}
function createProtectedCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "user@example.com", name: "User", image: null },
expires: "2026-03-29T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_1",
systemRole: SystemRole.USER,
permissionOverrides: null,
},
roleDefaults: null,
});
}
describe("timeline router detail views", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns a self-service timeline view scoped to the caller's linked resource", async () => {
const demandFindMany = vi.fn();
const assignmentFindMany = vi.fn().mockResolvedValue([
{
id: "asg_self",
projectId: "project_1",
resourceId: "res_self",
role: "Artist",
hoursPerDay: 6,
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-09T00:00:00.000Z"),
status: "CONFIRMED",
metadata: null,
resource: {
id: "res_self",
displayName: "Alice",
eid: "EMP-SELF",
chapter: "Delivery",
lcrCents: 10000,
},
project: {
id: "project_1",
name: "Gelddruckmaschine",
shortCode: "GDM",
clientId: "client_1",
budgetCents: 100000,
winProbability: 100,
status: "ACTIVE",
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-16T00:00:00.000Z"),
staffingReqs: null,
responsiblePerson: "Larissa",
color: "#fff",
orderType: "CHARGEABLE",
},
roleEntity: null,
},
]);
const caller = createProtectedCaller({
demandRequirement: {
findMany: demandFindMany,
},
assignment: {
findMany: assignmentFindMany,
},
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_self" }),
},
project: {
findMany: vi.fn(),
},
});
const result = await caller.getMyEntriesView({
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-09T00:00:00.000Z"),
resourceIds: ["res_other"],
chapters: ["Finance"],
eids: ["EMP-OTHER"],
countryCodes: ["US"],
});
expect(result.assignments).toHaveLength(1);
expect(result.assignments[0]?.resourceId).toBe("res_self");
expect(demandFindMany).not.toHaveBeenCalled();
expect(assignmentFindMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({
resourceId: { in: ["res_self"] },
}),
}));
});
it("returns self-service holiday overlays for the caller's linked resource", async () => {
const demandFindMany = vi.fn();
const assignmentFindMany = vi.fn().mockResolvedValue([]);
const resourceFindMany = vi.fn().mockResolvedValue([
{
id: "res_self",
countryId: "country_de",
federalState: "BY",
metroCityId: "city_munich",
country: { code: "DE" },
metroCity: { name: "Muenchen" },
},
]);
const caller = createProtectedCaller({
demandRequirement: {
findMany: demandFindMany,
},
assignment: {
findMany: assignmentFindMany,
},
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_self" }),
findMany: resourceFindMany,
},
project: {
findMany: vi.fn(),
},
});
const result = await caller.getMyHolidayOverlays({
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-09T00:00:00.000Z"),
resourceIds: ["res_other"],
chapters: ["Finance"],
eids: ["EMP-OTHER"],
countryCodes: ["US"],
});
expect(result).toEqual([
expect.objectContaining({
resourceId: "res_self",
note: "Heilige Drei Könige",
scope: "STATE",
}),
]);
expect(demandFindMany).not.toHaveBeenCalled();
expect(assignmentFindMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({
resourceId: { in: ["res_self"] },
}),
}));
expect(resourceFindMany).toHaveBeenCalledWith(expect.objectContaining({
where: { id: { in: ["res_self"] } },
}));
});
it("returns empty self-service timeline data when the caller has no linked resource", async () => {
const demandFindMany = vi.fn();
const assignmentFindMany = vi.fn();
const caller = createProtectedCaller({
demandRequirement: {
findMany: demandFindMany,
},
assignment: {
findMany: assignmentFindMany,
},
resource: {
findFirst: vi.fn().mockResolvedValue(null),
},
project: {
findMany: vi.fn(),
},
});
const result = await caller.getMyEntriesView({
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-09T00:00:00.000Z"),
});
expect(result.allocations).toEqual([]);
expect(result.demands).toEqual([]);
expect(result.assignments).toEqual([]);
expect(demandFindMany).not.toHaveBeenCalled();
expect(assignmentFindMany).not.toHaveBeenCalled();
});
it("returns a detailed timeline entries view with holiday overlays and summary", async () => {
const caller = createAdminCaller({
demandRequirement: {
findMany: vi.fn().mockResolvedValue([
{
id: "dem_1",
projectId: "project_1",
resourceId: null,
role: "Artist",
hoursPerDay: 6,
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-09T00:00:00.000Z"),
status: "OPEN",
metadata: null,
project: {
id: "project_1",
name: "Gelddruckmaschine",
shortCode: "GDM",
clientId: "client_1",
budgetCents: 100000,
winProbability: 100,
status: "ACTIVE",
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-16T00:00:00.000Z"),
staffingReqs: null,
responsiblePerson: "Larissa",
color: "#fff",
orderType: "CHARGEABLE",
},
roleEntity: null,
},
]),
},
assignment: {
findMany: vi.fn().mockResolvedValue([
{
id: "asg_by",
projectId: "project_1",
resourceId: "res_by",
role: "Artist",
hoursPerDay: 6,
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-09T00:00:00.000Z"),
status: "CONFIRMED",
metadata: null,
resource: {
id: "res_by",
displayName: "Alice",
eid: "EMP-BY",
chapter: "Delivery",
lcrCents: 10000,
},
project: {
id: "project_1",
name: "Gelddruckmaschine",
shortCode: "GDM",
clientId: "client_1",
budgetCents: 100000,
winProbability: 100,
status: "ACTIVE",
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-16T00:00:00.000Z"),
staffingReqs: null,
responsiblePerson: "Larissa",
color: "#fff",
orderType: "CHARGEABLE",
},
roleEntity: null,
},
{
id: "asg_hh",
projectId: "project_1",
resourceId: "res_hh",
role: "Artist",
hoursPerDay: 6,
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-09T00:00:00.000Z"),
status: "CONFIRMED",
metadata: null,
resource: {
id: "res_hh",
displayName: "Bob",
eid: "EMP-HH",
chapter: "Delivery",
lcrCents: 10000,
},
project: {
id: "project_1",
name: "Gelddruckmaschine",
shortCode: "GDM",
clientId: "client_1",
budgetCents: 100000,
winProbability: 100,
status: "ACTIVE",
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-16T00:00:00.000Z"),
staffingReqs: null,
responsiblePerson: "Larissa",
color: "#fff",
orderType: "CHARGEABLE",
},
roleEntity: null,
},
]),
},
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "res_by",
countryId: "country_de",
federalState: "BY",
metroCityId: "city_munich",
country: { code: "DE" },
metroCity: { name: "Muenchen" },
},
{
id: "res_hh",
countryId: "country_de",
federalState: "HH",
metroCityId: "city_hamburg",
country: { code: "DE" },
metroCity: { name: "Hamburg" },
},
]),
},
project: {
findMany: vi.fn(),
},
});
const result = await caller.getEntriesDetail({
startDate: "2026-01-05",
endDate: "2026-01-09",
projectIds: ["project_1"],
});
expect(result.period).toEqual({
startDate: "2026-01-05",
endDate: "2026-01-09",
});
expect(result.summary).toEqual(
expect.objectContaining({
demandCount: 1,
assignmentCount: 2,
overlayCount: 1,
resourceCount: 2,
}),
);
expect(result.demands).toHaveLength(1);
expect(result.assignments).toHaveLength(2);
expect(result.holidayOverlays).toEqual([
expect.objectContaining({
resourceId: "res_by",
startDate: "2026-01-06",
note: "Heilige Drei Könige",
scope: "STATE",
}),
]);
});
it("returns detailed project timeline context with overlap summaries", async () => {
vi.mocked(listAssignmentBookings).mockResolvedValue([
{
id: "asg_project",
projectId: "project_ctx",
resourceId: "res_1",
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-16T00:00:00.000Z"),
hoursPerDay: 6,
dailyCostCents: 0,
status: "CONFIRMED",
project: { id: "project_ctx", name: "Gelddruckmaschine", shortCode: "GDM", status: "ACTIVE", orderType: "CHARGEABLE", dynamicFields: null },
resource: { id: "res_1", displayName: "Alice", chapter: "Delivery" },
},
{
id: "asg_other",
projectId: "project_other",
resourceId: "res_1",
startDate: new Date("2026-01-08T00:00:00.000Z"),
endDate: new Date("2026-01-10T00:00:00.000Z"),
hoursPerDay: 4,
dailyCostCents: 0,
status: "CONFIRMED",
project: { id: "project_other", name: "Other Project", shortCode: "OTH", status: "ACTIVE", orderType: "CHARGEABLE", dynamicFields: null },
resource: { id: "res_1", displayName: "Alice", chapter: "Delivery" },
},
]);
const project = {
id: "project_ctx",
name: "Gelddruckmaschine",
shortCode: "GDM",
orderType: "CHARGEABLE",
budgetCents: 100000,
winProbability: 100,
status: "ACTIVE",
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-16T00:00:00.000Z"),
staffingReqs: null,
};
const caller = createAdminCaller({
project: {
findUnique: vi.fn().mockResolvedValue(project),
},
demandRequirement: {
findMany: vi.fn().mockResolvedValue([
{
id: "dem_ctx",
projectId: "project_ctx",
resourceId: null,
role: "Artist",
hoursPerDay: 6,
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-16T00:00:00.000Z"),
status: "OPEN",
metadata: null,
project,
roleEntity: null,
},
]),
},
assignment: {
findMany: vi.fn().mockResolvedValue([
{
id: "asg_project",
projectId: "project_ctx",
resourceId: "res_1",
role: "Artist",
hoursPerDay: 6,
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-16T00:00:00.000Z"),
status: "CONFIRMED",
metadata: null,
resource: {
id: "res_1",
displayName: "Alice",
eid: "EMP-1",
chapter: "Delivery",
lcrCents: 10000,
},
project,
roleEntity: null,
},
]),
},
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "res_1",
countryId: "country_de",
federalState: "BY",
metroCityId: "city_munich",
country: { code: "DE" },
metroCity: { name: "Muenchen" },
},
]),
},
});
const result = await caller.getProjectContextDetail({
projectId: "project_ctx",
});
expect(result.project).toEqual(
expect.objectContaining({
id: "project_ctx",
shortCode: "GDM",
}),
);
expect(result.summary).toEqual(
expect.objectContaining({
demandCount: 1,
assignmentCount: 1,
conflictedAssignmentCount: 1,
overlayCount: 1,
}),
);
expect(result.assignmentConflicts).toEqual([
expect.objectContaining({
assignmentId: "asg_project",
crossProjectOverlapCount: 1,
overlaps: expect.arrayContaining([
expect.objectContaining({
projectShortCode: "OTH",
sameProject: false,
}),
]),
}),
]);
expect(result.holidayOverlays).toEqual([
expect.objectContaining({
startDate: "2026-01-06",
}),
]);
});
it("returns detailed project shift preview metadata and validation", async () => {
const projectFindUnique = vi.fn().mockImplementation((args: { select?: Record<string, unknown> }) => {
if (args.select && "budgetCents" in args.select) {
return Promise.resolve({
id: "project_shift",
budgetCents: 100000,
winProbability: 100,
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-16T00:00:00.000Z"),
});
}
return Promise.resolve({
id: "project_shift",
name: "Gelddruckmaschine",
shortCode: "GDM",
status: "ACTIVE",
responsiblePerson: "Larissa",
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-16T00:00:00.000Z"),
});
});
const caller = createAdminCaller({
project: {
findUnique: projectFindUnique,
},
demandRequirement: {
findMany: vi.fn().mockResolvedValue([]),
},
assignment: {
findMany: vi.fn().mockResolvedValue([]),
},
});
const result = await caller.getShiftPreviewDetail({
projectId: "project_shift",
newStartDate: new Date("2026-01-19T00:00:00.000Z"),
newEndDate: new Date("2026-01-30T00:00:00.000Z"),
});
expect(result.project).toEqual({
id: "project_shift",
name: "Gelddruckmaschine",
shortCode: "GDM",
status: "ACTIVE",
responsiblePerson: "Larissa",
startDate: "2026-01-05",
endDate: "2026-01-16",
});
expect(result.requestedShift).toEqual({
newStartDate: "2026-01-19",
newEndDate: "2026-01-30",
});
expect(result.preview).toEqual({
valid: true,
errors: [],
warnings: [],
conflictDetails: [],
costImpact: {
currentTotalCents: 0,
newTotalCents: 0,
deltaCents: 0,
budgetCents: 100000,
budgetUtilizationBefore: 0,
budgetUtilizationAfter: 0,
wouldExceedBudget: false,
},
});
});
it("blocks USER role from broad timeline detail reads", async () => {
const db = {
demandRequirement: {
findMany: vi.fn(),
},
assignment: {
findMany: vi.fn(),
},
resource: {
findMany: vi.fn(),
},
project: {
findMany: vi.fn(),
},
};
const caller = createProtectedCaller(db);
await expect(
caller.getEntriesDetail({
startDate: "2026-01-05",
endDate: "2026-01-09",
}),
).rejects.toThrow(expect.objectContaining({ code: "FORBIDDEN" }));
});
it("blocks USER role from project timeline context reads", async () => {
const db = {
project: {
findUnique: vi.fn(),
},
demandRequirement: {
findMany: vi.fn(),
},
assignment: {
findMany: vi.fn(),
},
resource: {
findMany: vi.fn(),
},
};
const caller = createProtectedCaller(db);
await expect(
caller.getProjectContextDetail({ projectId: "project_ctx" }),
).rejects.toThrow(expect.objectContaining({ code: "FORBIDDEN" }));
});
});
@@ -1,6 +1,9 @@
import { SystemRole } from "@capakraken/shared";
import { VacationStatus, VacationType } from "@capakraken/db";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { createNotification } from "../lib/create-notification.js";
import { logger } from "../lib/logger.js";
import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
import { vacationRouter } from "../router/vacation.js";
import { createCallerFactory } from "../trpc.js";
@@ -33,6 +36,15 @@ vi.mock("../lib/audit.js", () => ({
createAuditEntry: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../lib/logger.js", () => ({
logger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
},
}));
const createCaller = createCallerFactory(vacationRouter);
function createProtectedCaller(db: Record<string, unknown>) {
@@ -163,6 +175,9 @@ describe("vacation router", () => {
describe("list", () => {
it("returns vacations with default filters", async () => {
const db = {
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
},
vacation: {
findMany: vi.fn().mockResolvedValue([sampleVacation]),
},
@@ -183,6 +198,9 @@ describe("vacation router", () => {
it("applies resourceId filter", async () => {
const db = {
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
@@ -198,8 +216,48 @@ describe("vacation router", () => {
);
});
it("scopes regular users to their own resource when no filter is provided", async () => {
const db = {
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_own" }),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createProtectedCaller(db);
await caller.list({});
expect(db.vacation.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ resourceId: "res_own" }),
}),
);
});
it("forbids regular users from listing another resource's vacations", async () => {
const db = {
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_own" }),
},
vacation: {
findMany: vi.fn(),
},
};
const caller = createProtectedCaller(db);
await expect(caller.list({ resourceId: "res_other" })).rejects.toThrow(
"You can only view vacation data for your own resource",
);
expect(db.vacation.findMany).not.toHaveBeenCalled();
});
it("applies status and type filters", async () => {
const db = {
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
@@ -229,11 +287,110 @@ describe("vacation router", () => {
});
});
describe("background side effects", () => {
it("logs and swallows async notification failures during approval", async () => {
vi.mocked(createNotification).mockRejectedValueOnce(new Error("notification down"));
const db = createVacationDb({
user: {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1", systemRole: "MANAGER" }),
},
resource: {
findUnique: vi.fn().mockImplementation(async (args?: { select?: Record<string, unknown> }) => {
const select = args?.select ?? {};
return {
...(select.displayName ? { displayName: "Alice" } : {}),
...(select.user
? { user: { id: "user_1", email: "user@example.com", name: "User" } }
: {}),
};
}),
},
vacation: {
findUnique: vi.fn().mockResolvedValue({
...sampleVacation,
status: VacationStatus.PENDING,
}),
update: vi.fn().mockResolvedValue({
...sampleVacation,
status: VacationStatus.APPROVED,
}),
},
});
const caller = createManagerCaller(db);
const result = await caller.approve({ id: "vac_1" });
await Promise.resolve();
await Promise.resolve();
expect(result.status).toBe(VacationStatus.APPROVED);
expect(vi.mocked(logger.error)).toHaveBeenCalledWith(
expect.objectContaining({
effectName: "notifyVacationStatus",
vacationId: "vac_1",
resourceId: "res_1",
newStatus: VacationStatus.APPROVED,
}),
"Vacation background side effect failed",
);
});
it("logs and swallows webhook failures during approval", async () => {
vi.mocked(dispatchWebhooks).mockRejectedValueOnce(new Error("webhook down"));
const db = createVacationDb({
user: {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1", systemRole: "MANAGER" }),
},
resource: {
findUnique: vi.fn().mockImplementation(async (args?: { select?: Record<string, unknown> }) => {
const select = args?.select ?? {};
return {
...(select.displayName ? { displayName: "Alice" } : {}),
...(select.user
? { user: { id: "user_1", email: "user@example.com", name: "User" } }
: {}),
};
}),
},
vacation: {
findUnique: vi.fn().mockResolvedValue({
...sampleVacation,
status: VacationStatus.PENDING,
}),
update: vi.fn().mockResolvedValue({
...sampleVacation,
status: VacationStatus.APPROVED,
}),
},
});
const caller = createManagerCaller(db);
const result = await caller.approve({ id: "vac_1" });
await Promise.resolve();
await Promise.resolve();
expect(result.status).toBe(VacationStatus.APPROVED);
expect(vi.mocked(logger.error)).toHaveBeenCalledWith(
expect.objectContaining({ effectName: "dispatchWebhooks", event: "vacation.approved" }),
"Vacation background side effect failed",
);
});
});
describe("getById", () => {
it("returns vacation by id", async () => {
const db = {
resource: {
findFirst: vi.fn(),
},
vacation: {
findUnique: vi.fn().mockResolvedValue(sampleVacation),
findUnique: vi.fn().mockResolvedValue({
...sampleVacation,
resource: { ...sampleVacation.resource, userId: "user_1" },
}),
},
};
@@ -248,6 +405,23 @@ describe("vacation router", () => {
);
});
it("forbids regular users from reading another user's vacation", async () => {
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue({
...sampleVacation,
requestedById: "someone_else",
resource: { ...sampleVacation.resource, userId: "someone_else" },
}),
},
};
const caller = createProtectedCaller(db);
await expect(caller.getById({ id: "vac_1" })).rejects.toThrow(
"You can only view your own vacation data",
);
});
it("throws NOT_FOUND for missing vacation", async () => {
const db = {
vacation: {
@@ -890,6 +1064,9 @@ describe("vacation router", () => {
describe("getForResource", () => {
it("returns approved vacations in date range", async () => {
const db = {
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
},
vacation: {
findMany: vi.fn().mockResolvedValue([
{
@@ -920,6 +1097,27 @@ describe("vacation router", () => {
}),
);
});
it("forbids regular users from reading another resource's approved vacations", async () => {
const db = {
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_own" }),
},
vacation: {
findMany: vi.fn(),
},
};
const caller = createProtectedCaller(db);
await expect(
caller.getForResource({
resourceId: "res_other",
startDate: new Date("2026-01-01"),
endDate: new Date("2026-12-31"),
}),
).rejects.toThrow("You can only view vacation data for your own resource");
expect(db.vacation.findMany).not.toHaveBeenCalled();
});
});
describe("getPendingApprovals", () => {
@@ -952,6 +1150,7 @@ describe("vacation router", () => {
it("returns overlapping vacations for the same chapter", async () => {
const db = {
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
findUnique: vi.fn().mockResolvedValue({ chapter: "Animation" }),
},
vacation: {
@@ -987,6 +1186,7 @@ describe("vacation router", () => {
it("returns empty array when resource has no chapter", async () => {
const db = {
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
findUnique: vi.fn().mockResolvedValue({ chapter: null }),
},
};
@@ -1000,6 +1200,76 @@ describe("vacation router", () => {
expect(result).toEqual([]);
});
it("forbids regular users from reading another resource's team overlap", async () => {
const db = {
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_own" }),
findUnique: vi.fn(),
},
vacation: {
findMany: vi.fn(),
},
};
const caller = createProtectedCaller(db);
await expect(
caller.getTeamOverlap({
resourceId: "res_other",
startDate: new Date("2026-06-01"),
endDate: new Date("2026-06-05"),
}),
).rejects.toThrow("You can only view vacation data for your own resource");
expect(db.resource.findUnique).not.toHaveBeenCalled();
expect(db.vacation.findMany).not.toHaveBeenCalled();
});
});
describe("getTeamOverlapDetail", () => {
it("returns assistant-friendly overlap detail from the canonical overlap query", async () => {
const db = {
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
findUnique: vi
.fn()
.mockResolvedValueOnce({ displayName: "Bruce Banner", chapter: "CGI" }),
},
vacation: {
findMany: vi.fn().mockResolvedValue([
{
...sampleVacation,
id: "vac_other",
resourceId: "res_2",
status: VacationStatus.APPROVED,
resource: { id: "res_2", displayName: "Clark Kent", eid: "E-002" },
},
]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getTeamOverlapDetail({
resourceId: "res_1",
startDate: new Date("2026-08-10T00:00:00.000Z"),
endDate: new Date("2026-08-12T00:00:00.000Z"),
});
expect(result).toEqual({
resource: "Bruce Banner",
chapter: "CGI",
period: "2026-08-10 to 2026-08-12",
overlapCount: 1,
overlappingVacations: [
{
resource: "Clark Kent",
type: VacationType.ANNUAL,
status: VacationStatus.APPROVED,
start: "2026-06-01",
end: "2026-06-05",
},
],
});
});
});
describe("batchCreatePublicHolidays", () => {
+1 -1
View File
@@ -1,3 +1,3 @@
export const ROLE_BRIEF_SELECT = { id: true, name: true, color: true } as const;
export const PROJECT_BRIEF_SELECT = { id: true, name: true, shortCode: true, status: true, endDate: true } as const;
export const RESOURCE_BRIEF_SELECT = { id: true, displayName: true, eid: true, lcrCents: true } as const;
export const RESOURCE_BRIEF_SELECT = { id: true, displayName: true, eid: true, lcrCents: true, chapter: true } as const;
+6 -1
View File
@@ -92,6 +92,11 @@ export function generateSummary(
export async function createAuditEntry(params: CreateAuditEntryParams): Promise<void> {
try {
const { db, entityType, entityId, entityName, action, userId, before, after, source, metadata } = params;
const auditLog = (db as Partial<PrismaClient>).auditLog;
if (!auditLog || typeof auditLog.create !== "function") {
return;
}
// Compute diff if both snapshots are available
const diff = before && after ? computeDiff(before, after) : undefined;
@@ -111,7 +116,7 @@ export async function createAuditEntry(params: CreateAuditEntryParams): Promise<
if (diff) changes.diff = diff;
if (metadata) changes.metadata = metadata;
await db.auditLog.create({
await auditLog.create({
data: {
entityType,
entityId,
+13 -2
View File
@@ -15,12 +15,17 @@ interface RateLimitResult {
resetAt: Date;
}
export interface RateLimiter {
(key: string): RateLimitResult;
reset(): void;
}
/**
* Creates a sliding-window rate limiter.
* @param windowMs - Time window in milliseconds
* @param maxRequests - Maximum requests allowed within the window
*/
export function createRateLimiter(windowMs: number, maxRequests: number) {
export function createRateLimiter(windowMs: number, maxRequests: number): RateLimiter {
const store = new Map<string, RateLimitEntry>();
// Periodically clean up expired entries to prevent memory leaks
@@ -38,7 +43,7 @@ export function createRateLimiter(windowMs: number, maxRequests: number) {
cleanupInterval.unref();
}
return function check(key: string): RateLimitResult {
const check = function check(key: string): RateLimitResult {
const now = Date.now();
const existing = store.get(key);
@@ -61,7 +66,13 @@ export function createRateLimiter(windowMs: number, maxRequests: number) {
remaining: Math.max(0, maxRequests - existing.count),
resetAt: new Date(existing.resetAt),
};
} as RateLimiter;
check.reset = () => {
store.clear();
};
return check;
}
/** General API rate limiter: 100 requests per 15 minutes per key */
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+409 -89
View File
@@ -5,7 +5,7 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { AssistantApprovalStatus, type PrismaClient } from "@capakraken/db";
import { AssistantApprovalStatus, Prisma, type PrismaClient } from "@capakraken/db";
import { PermissionKey, resolvePermissions, type PermissionOverrides, SystemRole } from "@capakraken/shared";
import { createTRPCRouter, protectedProcedure } from "../trpc.js";
import { createAiClient, isAiConfigured, loggedAiCall, parseAiError } from "../ai-client.js";
@@ -19,11 +19,92 @@ import { logger } from "../lib/logger.js";
const MAX_TOOL_ITERATIONS = 8;
const PENDING_APPROVAL_TTL_MS = 15 * 60 * 1000;
export const ASSISTANT_CONFIRMATION_PREFIX = "CONFIRMATION_REQUIRED:";
const ASSISTANT_APPROVALS_TABLE_NAME = "public.assistant_approvals";
const MAX_OPENAI_TOOL_DEFINITIONS = 128;
const ALWAYS_INCLUDED_TOOL_NAMES = new Set([
"get_current_user",
"search_resources",
"get_resource",
"search_projects",
"get_project",
"list_allocations",
"get_statistics",
"navigate_to_page",
]);
const MUTATION_INTENT_KEYWORDS = [
"create", "add", "new", "update", "change", "edit", "delete", "remove", "cancel", "approve", "reject",
"anlegen", "erstellen", "neu", "aendern", "ändern", "bearbeiten", "loeschen", "löschen", "entfernen",
"stornieren", "genehmigen", "ablehnen", "setzen",
];
const TOOL_SELECTION_HINTS = [
{
keywords: ["holiday", "holidays", "feiertag", "feiertage", "vacation", "vacations", "urlaub", "ferien", "abwesen"],
nameFragments: ["holiday", "vacation", "entitlement"],
exactTools: ["list_holidays_by_region", "get_resource_holidays", "list_holiday_calendars", "get_holiday_calendar", "preview_resolved_holiday_calendar"],
},
{
keywords: ["resource", "resources", "ressource", "ressourcen", "employee", "mitarbeiter", "person", "people", "team", "chapter", "skill", "skills"],
nameFragments: ["resource", "skill", "role", "user", "staffing", "capacity"],
exactTools: ["search_resources", "get_resource", "search_by_skill", "check_resource_availability", "get_staffing_suggestions", "find_capacity"],
},
{
keywords: ["capacity", "availability", "available", "kapazitaet", "kapazität", "verfuegbar", "verfügbar", "auslastung", "chargeability", "sah", "lcr"],
nameFragments: ["capacity", "availability", "chargeability", "staffing", "rate", "budget"],
exactTools: ["check_resource_availability", "get_staffing_suggestions", "find_capacity", "get_chargeability", "find_best_project_resource", "resolve_rate"],
},
{
keywords: ["project", "projects", "projekt", "projekte", "allocation", "allocations", "allokation", "allokationen", "assignment", "assignments", "demand", "demands", "timeline"],
nameFragments: ["project", "allocation", "demand", "timeline", "assignment", "blueprint"],
exactTools: ["search_projects", "get_project", "list_allocations", "list_demands", "get_timeline_entries_view", "get_project_timeline_context"],
},
{
keywords: ["dashboard", "widget", "widgets", "peak", "forecast", "insight", "insights", "anomaly", "anomalies", "report", "reports", "analyse", "analysis", "bericht"],
nameFragments: ["dashboard", "statistics", "report", "insight", "anomal", "health", "forecast", "skill"],
exactTools: ["get_statistics", "get_dashboard_detail", "detect_anomalies", "get_skill_gaps", "get_project_health", "get_budget_forecast", "get_insights_summary", "run_report"],
},
{
keywords: ["estimate", "estimates", "angebot", "angebote", "budget", "budgets", "cost", "costs", "kosten", "rate", "rates", "preis", "preise"],
nameFragments: ["estimate", "budget", "rate", "cost"],
exactTools: ["get_budget_status", "list_rate_cards", "resolve_rate", "lookup_rate", "search_estimates", "get_estimate_detail"],
},
{
keywords: ["notification", "notifications", "benachrichtigung", "benachrichtigungen", "task", "tasks", "aufgabe", "aufgaben", "reminder", "reminders", "broadcast"],
nameFragments: ["notification", "task", "reminder", "broadcast"],
exactTools: ["list_notifications", "get_unread_notification_count", "list_tasks", "get_task_counts", "list_reminders", "get_broadcast_detail"],
},
{
keywords: ["country", "countries", "land", "laender", "länder", "city", "cities", "stadt", "staedte", "städte", "region", "regions", "state", "bundesland"],
nameFragments: ["country", "metro_city", "holiday_calendar"],
exactTools: ["list_countries", "get_country", "list_holidays_by_region", "list_holiday_calendars"],
},
{
keywords: ["user", "users", "permission", "permissions", "rolle", "rollen", "admin", "system", "webhook", "import", "audit", "history", "rechte"],
nameFragments: ["user", "permission", "role", "system", "webhook", "import", "audit", "history", "org_unit", "country"],
exactTools: ["list_users", "get_effective_user_permissions", "list_audit_log_entries", "query_change_history", "get_system_settings", "list_webhooks"],
},
];
const TOOL_SELECTION_STOP_WORDS = new Set([
"the", "and", "for", "with", "from", "that", "this", "what", "when", "where", "who", "how",
"und", "der", "die", "das", "ein", "eine", "einer", "einem", "einen", "mit", "von", "fuer", "für",
"auf", "ist", "sind", "im", "in", "am", "an", "zu", "zum", "zur", "mir", "bitte", "can", "you",
"mir", "alle", "all", "den", "dem", "des",
]);
type ChatMessage = { role: "user" | "assistant"; content: string };
type AssistantApprovalStore = Pick<PrismaClient, "assistantApproval">;
class AssistantApprovalStorageUnavailableError extends Error {
constructor() {
super("Assistant approval storage is unavailable.");
this.name = "AssistantApprovalStorageUnavailableError";
}
}
export interface PendingAssistantApproval {
id: string;
userId: string;
@@ -83,29 +164,32 @@ Datenmodell:
- Projekte: ShortCode, Budget (Cent), Win-Probability, Status (DRAFT/ACTIVE/ON_HOLD/COMPLETED/CANCELLED)
- Allokationen (Assignments): resourceId + projectId, hoursPerDay, dailyCostCents, Zeitraum, Status (PROPOSED/CONFIRMED/ACTIVE/COMPLETED/CANCELLED)
- Chargeability = gebuchte/verfügbare Stunden × 100%
- Urlaub: Typen VACATION/SICK/PARENTAL/SPECIAL/PUBLIC_HOLIDAY, Status PENDING/APPROVED/REJECTED/CANCELLED
- Urlaub: Typen ANNUAL/SICK/OTHER/PUBLIC_HOLIDAY, Status PENDING/APPROVED/REJECTED/CANCELLED. PUBLIC_HOLIDAY wird nicht manuell beantragt, sondern über Feiertagskalender verwaltet.
- Feiertage: können je nach Land, Bundesland und Stadt unterschiedlich sein; nutze Feiertags-Tools statt zu raten
`;
/** Map tool names to the permission required to use them */
const TOOL_PERMISSION_MAP: Record<string, string> = {
list_users: PermissionKey.MANAGE_USERS,
// Resource management
update_resource: "manageResources",
create_resource: "manageResources",
deactivate_resource: "manageResources",
create_role: "manageResources",
update_role: "manageResources",
delete_role: "manageResources",
create_org_unit: "manageResources",
update_org_unit: "manageResources",
create_role: PermissionKey.MANAGE_ROLES,
update_role: PermissionKey.MANAGE_ROLES,
delete_role: PermissionKey.MANAGE_ROLES,
// Project management
update_project: "manageProjects",
create_project: "manageProjects",
delete_project: "manageProjects",
create_client: "manageProjects",
update_client: "manageProjects",
create_estimate: "manageProjects",
clone_estimate: "manageProjects",
update_estimate_draft: "manageProjects",
submit_estimate_version: "manageProjects",
approve_estimate_version: "manageProjects",
create_estimate_revision: "manageProjects",
create_estimate_export: "manageProjects",
generate_estimate_weekly_phasing: "manageProjects",
update_estimate_commercial_terms: "manageProjects",
generate_project_cover: "manageProjects",
remove_project_cover: "manageProjects",
import_csv_data: PermissionKey.IMPORT_DATA,
@@ -120,15 +204,9 @@ const TOOL_PERMISSION_MAP: Record<string, string> = {
batch_shift_timeline_allocations: "manageAllocations",
create_demand: "manageAllocations",
fill_demand: "manageAllocations",
create_estimate_planning_handoff: "manageAllocations",
// Vacation management
create_vacation: "manageVacations",
approve_vacation: "manageVacations",
reject_vacation: "manageVacations",
cancel_vacation: "manageVacations",
set_entitlement: "manageVacations",
// Task management
create_task_for_user: "manageProjects",
send_broadcast: "manageProjects",
execute_task_action: "manageAllocations",
};
@@ -142,6 +220,7 @@ const COST_TOOLS = new Set([
"resolve_rate",
"list_rate_cards",
"get_estimate_detail",
"get_estimate_version_snapshot",
"find_best_project_resource",
]);
@@ -158,22 +237,90 @@ const CONTROLLER_ONLY_TOOLS = new Set([
"get_chargeability_report",
"get_resource_computation_graph",
"get_project_computation_graph",
"get_estimate_detail",
"list_estimate_versions",
"get_estimate_version_snapshot",
"get_estimate_weekly_phasing",
"get_estimate_commercial_terms",
]);
/** Tools that follow managerProcedure access rules in the main API. */
const MANAGER_ONLY_TOOLS = new Set([
"import_csv_data",
"list_assignable_users",
"create_notification",
"update_timeline_allocation_inline",
"apply_timeline_project_shift",
"quick_assign_timeline_resource",
"batch_quick_assign_timeline_resources",
"batch_shift_timeline_allocations",
"create_estimate",
"clone_estimate",
"update_estimate_draft",
"submit_estimate_version",
"approve_estimate_version",
"create_estimate_revision",
"create_estimate_export",
"create_estimate_planning_handoff",
"generate_estimate_weekly_phasing",
"update_estimate_commercial_terms",
"create_task_for_user",
"assign_task",
"send_broadcast",
"list_broadcasts",
"get_broadcast_detail",
"approve_vacation",
"reject_vacation",
"get_pending_vacation_approvals",
"get_entitlement_summary",
"set_entitlement",
"create_role",
"update_role",
"delete_role",
"create_client",
"update_client",
]);
/** Tools that are intentionally limited to ADMIN because the backing routers are admin-only today. */
const ADMIN_ONLY_TOOLS = new Set([
"list_users",
"get_active_user_count",
"create_user",
"set_user_password",
"update_user_role",
"update_user_name",
"link_user_resource",
"auto_link_users_by_email",
"set_user_permissions",
"reset_user_permissions",
"get_effective_user_permissions",
"disable_user_totp",
"list_dispo_import_batches",
"get_dispo_import_batch",
"stage_dispo_import_batch",
"validate_dispo_import_batch",
"cancel_dispo_import_batch",
"list_dispo_staged_resources",
"list_dispo_staged_projects",
"list_dispo_staged_assignments",
"list_dispo_staged_vacations",
"list_dispo_staged_unresolved_records",
"resolve_dispo_staged_record",
"commit_dispo_import_batch",
"get_system_settings",
"update_system_settings",
"test_ai_connection",
"test_smtp_connection",
"test_gemini_connection",
"update_system_role_config",
"list_webhooks",
"get_webhook",
"create_webhook",
"update_webhook",
"delete_webhook",
"test_webhook",
"create_org_unit",
"update_org_unit",
"create_country",
"update_country",
"create_metro_city",
@@ -220,6 +367,96 @@ export function getAvailableAssistantTools(permissions: Set<PermissionKey>, user
});
}
function normalizeAssistantText(input: string): string {
return input
.toLowerCase()
.normalize("NFD")
.replace(/\p{Diacritic}/gu, " ")
.replace(/[^a-z0-9_]+/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function tokenizeAssistantIntent(input: string): string[] {
return normalizeAssistantText(input)
.split(" ")
.map((token) => token.trim())
.filter((token) => token.length >= 3 && !TOOL_SELECTION_STOP_WORDS.has(token));
}
export function selectAssistantToolsForRequest(
availableTools: typeof TOOL_DEFINITIONS,
messages: ChatMessage[],
pageContext?: string,
) {
if (availableTools.length <= MAX_OPENAI_TOOL_DEFINITIONS) {
return availableTools;
}
const recentUserText = messages
.filter((message) => message.role === "user")
.slice(-4)
.map((message) => message.content)
.join(" ");
const intentText = [recentUserText, pageContext ?? ""].filter(Boolean).join(" ");
const normalizedIntent = normalizeAssistantText(intentText);
const intentTokens = tokenizeAssistantIntent(intentText);
const mutationIntent = MUTATION_INTENT_KEYWORDS.some((keyword) => normalizedIntent.includes(normalizeAssistantText(keyword)));
const selectedHintTools = new Set<string>();
for (const hint of TOOL_SELECTION_HINTS) {
const matchedKeyword = hint.keywords.some((keyword) => normalizedIntent.includes(normalizeAssistantText(keyword)));
if (!matchedKeyword) continue;
for (const toolName of hint.exactTools) {
selectedHintTools.add(toolName);
}
}
const scoredTools = availableTools
.map((tool, index) => {
const name = tool.function.name;
const normalizedName = normalizeAssistantText(name.replace(/_/g, " "));
const normalizedDescription = normalizeAssistantText(tool.function.description);
let score = 0;
if (ALWAYS_INCLUDED_TOOL_NAMES.has(name)) score += 1000;
if (selectedHintTools.has(name)) score += 400;
for (const hint of TOOL_SELECTION_HINTS) {
const matchedKeyword = hint.keywords.some((keyword) => normalizedIntent.includes(normalizeAssistantText(keyword)));
if (!matchedKeyword) continue;
if (hint.exactTools.includes(name)) score += 160;
if (hint.nameFragments.some((fragment) => name.includes(fragment))) score += 120;
if (hint.nameFragments.some((fragment) => normalizedDescription.includes(normalizeAssistantText(fragment)))) score += 40;
}
for (const token of intentTokens) {
if (normalizedName.includes(token)) score += 45;
if (normalizedDescription.includes(token)) score += 10;
}
if (name.startsWith("search_")) score += 18;
if (name.startsWith("get_")) score += 12;
if (name.startsWith("list_")) score += 10;
if (MUTATION_TOOLS.has(name)) {
score += mutationIntent ? 40 : -30;
} else {
score += 8;
}
return { tool, index, score };
})
.sort((left, right) => {
if (right.score !== left.score) return right.score - left.score;
return left.index - right.index;
});
return scoredTools
.slice(0, MAX_OPENAI_TOOL_DEFINITIONS)
.map((entry) => entry.tool);
}
function mergeInsights(existing: AssistantInsight[], next: AssistantInsight): AssistantInsight[] {
const duplicateIndex = existing.findIndex((item) => item.kind === next.kind && item.title === next.title && item.subtitle === next.subtitle);
if (duplicateIndex >= 0) {
@@ -307,31 +544,87 @@ function toApprovalPayload(
};
}
function isAssistantApprovalTableMissingError(error: unknown): boolean {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code !== "P2021") return false;
const table = typeof error.meta?.table === "string" ? error.meta.table : "";
return table.includes("assistant_approvals") || error.message.includes("assistant_approvals");
}
if (typeof error !== "object" || error === null || !("code" in error)) {
return false;
}
const candidate = error as {
code?: unknown;
message?: unknown;
meta?: {
table?: unknown;
};
};
const code = typeof candidate.code === "string" ? candidate.code : "";
if (code !== "P2021") return false;
const message = typeof candidate.message === "string"
? candidate.message
: "";
const metaTable = typeof candidate.meta?.table === "string"
? candidate.meta.table
: "";
return metaTable.includes("assistant_approvals") || message.includes("assistant_approvals");
}
function logAssistantApprovalStorageUnavailable(error: unknown) {
logger.warn(
{
err: error,
table: ASSISTANT_APPROVALS_TABLE_NAME,
},
"Assistant approval storage is unavailable",
);
}
async function withAssistantApprovalFallback<T>(
operation: () => Promise<T>,
fallback: () => T,
): Promise<T> {
try {
return await operation();
} catch (error) {
if (!isAssistantApprovalTableMissingError(error)) throw error;
logAssistantApprovalStorageUnavailable(error);
return fallback();
}
}
export async function listPendingAssistantApprovals(
db: AssistantApprovalStore,
userId: string,
): Promise<PendingAssistantApproval[]> {
await db.assistantApproval.updateMany({
where: {
userId,
status: AssistantApprovalStatus.PENDING,
expiresAt: { lte: new Date() },
},
data: {
status: AssistantApprovalStatus.EXPIRED,
},
});
return withAssistantApprovalFallback(async () => {
await db.assistantApproval.updateMany({
where: {
userId,
status: AssistantApprovalStatus.PENDING,
expiresAt: { lte: new Date() },
},
data: {
status: AssistantApprovalStatus.EXPIRED,
},
});
const approvals = await db.assistantApproval.findMany({
where: {
userId,
status: AssistantApprovalStatus.PENDING,
expiresAt: { gt: new Date() },
},
orderBy: { createdAt: "desc" },
});
const approvals = await db.assistantApproval.findMany({
where: {
userId,
status: AssistantApprovalStatus.PENDING,
expiresAt: { gt: new Date() },
},
orderBy: { createdAt: "desc" },
});
return approvals.map(mapPendingApproval);
return approvals.map(mapPendingApproval);
}, () => []);
}
export async function clearPendingAssistantApproval(
@@ -339,17 +632,19 @@ export async function clearPendingAssistantApproval(
userId: string,
conversationId: string,
): Promise<void> {
await db.assistantApproval.updateMany({
where: {
userId,
conversationId,
status: AssistantApprovalStatus.PENDING,
},
data: {
status: AssistantApprovalStatus.CANCELLED,
cancelledAt: new Date(),
},
});
await withAssistantApprovalFallback(async () => {
await db.assistantApproval.updateMany({
where: {
userId,
conversationId,
status: AssistantApprovalStatus.PENDING,
},
data: {
status: AssistantApprovalStatus.CANCELLED,
cancelledAt: new Date(),
},
});
}, () => undefined);
}
export async function peekPendingAssistantApproval(
@@ -357,28 +652,30 @@ export async function peekPendingAssistantApproval(
userId: string,
conversationId: string,
): Promise<PendingAssistantApproval | null> {
await db.assistantApproval.updateMany({
where: {
userId,
conversationId,
status: AssistantApprovalStatus.PENDING,
expiresAt: { lte: new Date() },
},
data: {
status: AssistantApprovalStatus.EXPIRED,
},
});
return withAssistantApprovalFallback(async () => {
await db.assistantApproval.updateMany({
where: {
userId,
conversationId,
status: AssistantApprovalStatus.PENDING,
expiresAt: { lte: new Date() },
},
data: {
status: AssistantApprovalStatus.EXPIRED,
},
});
const pending = await db.assistantApproval.findFirst({
where: {
userId,
conversationId,
status: AssistantApprovalStatus.PENDING,
},
orderBy: { createdAt: "desc" },
});
if (!pending) return null;
return mapPendingApproval(pending);
const pending = await db.assistantApproval.findFirst({
where: {
userId,
conversationId,
status: AssistantApprovalStatus.PENDING,
},
orderBy: { createdAt: "desc" },
});
if (!pending) return null;
return mapPendingApproval(pending);
}, () => null);
}
export async function consumePendingAssistantApproval(
@@ -426,19 +723,25 @@ export async function createPendingAssistantApproval(
const now = new Date();
const expiresAt = new Date(now.getTime() + (options?.ttlMs ?? PENDING_APPROVAL_TTL_MS));
const summary = options?.summary ?? buildApprovalSummary(toolName, toolArguments);
await clearPendingAssistantApproval(db, userId, conversationId);
const pendingApproval = await db.assistantApproval.create({
data: {
userId,
conversationId,
toolName,
toolArguments,
summary,
createdAt: now,
expiresAt,
},
});
return mapPendingApproval(pendingApproval);
try {
await clearPendingAssistantApproval(db, userId, conversationId);
const pendingApproval = await db.assistantApproval.create({
data: {
userId,
conversationId,
toolName,
toolArguments,
summary,
createdAt: now,
expiresAt,
},
});
return mapPendingApproval(pendingApproval);
} catch (error) {
if (!isAssistantApprovalTableMissingError(error)) throw error;
logAssistantApprovalStorageUnavailable(error);
throw new AssistantApprovalStorageUnavailableError();
}
}
function isAffirmativeConfirmationReply(content: string): boolean {
@@ -669,7 +972,11 @@ export const assistantRouter = createTRPCRouter({
}
// 4. Filter tools based on granular permissions
const availableTools = getAvailableAssistantTools(permissions, userRole);
const availableTools = selectAssistantToolsForRequest(
getAvailableAssistantTools(permissions, userRole),
input.messages,
input.pageContext,
);
// 5. Function calling loop
const toolCtx: ToolContext = {
@@ -799,13 +1106,26 @@ export const assistantRouter = createTRPCRouter({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
for (const toolCall of msg.tool_calls as Array<{ id: string; function: { name: string; arguments: string } }>) {
if (MUTATION_TOOLS.has(toolCall.function.name)) {
const approval = await createPendingAssistantApproval(
ctx.db,
userId,
conversationId,
toolCall.function.name,
toolCall.function.arguments,
);
let approval: PendingAssistantApproval;
try {
approval = await createPendingAssistantApproval(
ctx.db,
userId,
conversationId,
toolCall.function.name,
toolCall.function.arguments,
);
} catch (error) {
if (!(error instanceof AssistantApprovalStorageUnavailableError)) {
throw error;
}
return {
content: "Schreibende Assistant-Aktionen sind gerade nicht verfuegbar, weil der Bestaetigungsspeicher in der Datenbank fehlt. Bitte die CapaKraken-DB-Migration anwenden und dann erneut versuchen.",
role: "assistant" as const,
...(collectedInsights.length > 0 ? { insights: collectedInsights } : {}),
...(collectedActions.length > 0 ? { actions: collectedActions } : {}),
};
}
void createAuditEntry({
db: ctx.db,
+328 -99
View File
@@ -1,6 +1,235 @@
import { z } from "zod";
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
type AuditUser = { id: string; name: string | null; email: string | null } | null | undefined;
type AuditEntryShape = {
id: string;
entityType: string;
entityId: string;
entityName?: string | null;
action: string;
userId?: string | null;
source?: string | null;
summary?: string | null;
createdAt: Date;
user?: AuditUser;
};
type AuditDetailEntryShape = AuditEntryShape & {
changes?: unknown;
};
function formatAuditListEntry(entry: AuditEntryShape) {
return {
id: entry.id,
entityType: entry.entityType,
entityId: entry.entityId,
entityName: entry.entityName ?? null,
action: entry.action,
userId: entry.userId ?? null,
source: entry.source ?? null,
summary: entry.summary ?? null,
createdAt: entry.createdAt.toISOString(),
user: entry.user
? {
id: entry.user.id,
name: entry.user.name,
email: entry.user.email,
}
: null,
};
}
function formatAuditDetailEntry(entry: AuditDetailEntryShape) {
return {
...formatAuditListEntry(entry),
changes: entry.changes ?? null,
};
}
type AuditListInput = {
entityType?: string;
entityId?: string;
userId?: string;
action?: string;
source?: string;
startDate?: Date;
endDate?: Date;
search?: string;
limit: number;
cursor?: string;
};
type AuditTimelineInput = {
startDate?: Date;
endDate?: Date;
limit: number;
};
function toAuditListInput(input: {
entityType?: string | undefined;
entityId?: string | undefined;
userId?: string | undefined;
action?: string | undefined;
source?: string | undefined;
startDate?: Date | undefined;
endDate?: Date | undefined;
search?: string | undefined;
limit: number;
cursor?: string | undefined;
}): AuditListInput {
return {
limit: input.limit,
...(input.entityType !== undefined ? { entityType: input.entityType } : {}),
...(input.entityId !== undefined ? { entityId: input.entityId } : {}),
...(input.userId !== undefined ? { userId: input.userId } : {}),
...(input.action !== undefined ? { action: input.action } : {}),
...(input.source !== undefined ? { source: input.source } : {}),
...(input.startDate !== undefined ? { startDate: input.startDate } : {}),
...(input.endDate !== undefined ? { endDate: input.endDate } : {}),
...(input.search !== undefined ? { search: input.search } : {}),
...(input.cursor !== undefined ? { cursor: input.cursor } : {}),
};
}
function toAuditTimelineInput(input: {
startDate?: Date | undefined;
endDate?: Date | undefined;
limit: number;
}): AuditTimelineInput {
return {
limit: input.limit,
...(input.startDate !== undefined ? { startDate: input.startDate } : {}),
...(input.endDate !== undefined ? { endDate: input.endDate } : {}),
};
}
function buildAuditListWhere(input: Omit<AuditListInput, "limit" | "cursor">) {
const { entityType, entityId, userId, action, source, startDate, endDate, search } = input;
const where: Record<string, unknown> = {};
if (entityType) where.entityType = entityType;
if (entityId) where.entityId = entityId;
if (userId) where.userId = userId;
if (action) where.action = action;
if (source) where.source = source;
if (startDate || endDate) {
const createdAt: Record<string, Date> = {};
if (startDate) createdAt.gte = startDate;
if (endDate) createdAt.lte = endDate;
where.createdAt = createdAt;
}
if (search) {
where.OR = [
{ entityName: { contains: search, mode: "insensitive" } },
{ summary: { contains: search, mode: "insensitive" } },
{ entityType: { contains: search, mode: "insensitive" } },
];
}
if (!startDate && !endDate && !entityId) {
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
where.createdAt = { ...(where.createdAt as Record<string, Date> ?? {}), gte: thirtyDaysAgo };
}
return where;
}
async function listAuditEntries(
db: { auditLog: { findMany: Function } },
input: AuditListInput,
) {
const items = await db.auditLog.findMany({
where: buildAuditListWhere(input),
select: {
id: true,
entityType: true,
entityId: true,
entityName: true,
action: true,
userId: true,
source: true,
summary: true,
createdAt: true,
user: { select: { id: true, name: true, email: true } },
},
orderBy: { createdAt: "desc" },
take: input.limit + 1,
...(input.cursor ? { cursor: { id: input.cursor }, skip: 1 } : {}),
});
let nextCursor: string | undefined;
if (items.length > input.limit) {
const next = items.pop();
nextCursor = next?.id;
}
return { items, nextCursor };
}
async function getAuditEntryById(
db: { auditLog: { findUniqueOrThrow: Function } },
id: string,
) {
return db.auditLog.findUniqueOrThrow({
where: { id },
include: { user: { select: { id: true, name: true, email: true } } },
});
}
async function getAuditEntriesByEntity(
db: { auditLog: { findMany: Function } },
input: { entityType: string; entityId: string; limit: number },
) {
return db.auditLog.findMany({
where: {
entityType: input.entityType,
entityId: input.entityId,
},
include: {
user: { select: { id: true, name: true, email: true } },
},
orderBy: { createdAt: "desc" },
take: input.limit,
});
}
async function getAuditTimeline(
db: { auditLog: { findMany: Function } },
input: AuditTimelineInput,
) {
const where: Record<string, unknown> = {};
if (input.startDate || input.endDate) {
const createdAt: Record<string, Date> = {};
if (input.startDate) createdAt.gte = input.startDate;
if (input.endDate) createdAt.lte = input.endDate;
where.createdAt = createdAt;
}
const entries = await db.auditLog.findMany({
where,
include: {
user: { select: { id: true, name: true, email: true } },
},
orderBy: { createdAt: "desc" },
take: input.limit,
});
const grouped: Record<string, typeof entries> = {};
for (const entry of entries) {
const dateKey = entry.createdAt.toISOString().slice(0, 10);
if (!grouped[dateKey]) grouped[dateKey] = [];
grouped[dateKey].push(entry);
}
return grouped;
}
// ─── Router ───────────────────────────────────────────────────────────────────
export const auditLogRouter = createTRPCRouter({
@@ -24,65 +253,52 @@ export const auditLogRouter = createTRPCRouter({
}),
)
.query(async ({ ctx, input }) => {
const { entityType, entityId, userId, action, source, startDate, endDate, search, limit, cursor } = input;
return listAuditEntries(ctx.db, toAuditListInput({
entityType: input.entityType,
entityId: input.entityId,
userId: input.userId,
action: input.action,
source: input.source,
startDate: input.startDate,
endDate: input.endDate,
search: input.search,
limit: input.limit,
cursor: input.cursor,
}));
}),
const where: Record<string, unknown> = {};
if (entityType) where.entityType = entityType;
if (entityId) where.entityId = entityId;
if (userId) where.userId = userId;
if (action) where.action = action;
if (source) where.source = source;
if (startDate || endDate) {
const createdAt: Record<string, Date> = {};
if (startDate) createdAt.gte = startDate;
if (endDate) createdAt.lte = endDate;
where.createdAt = createdAt;
}
if (search) {
where.OR = [
{ entityName: { contains: search, mode: "insensitive" } },
{ summary: { contains: search, mode: "insensitive" } },
{ entityType: { contains: search, mode: "insensitive" } },
];
}
// Default to last 30 days if no date filter to avoid full table scan
if (!startDate && !endDate && !entityId) {
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
where.createdAt = { ...(where.createdAt as Record<string, Date> ?? {}), gte: thirtyDaysAgo };
}
const items = await ctx.db.auditLog.findMany({
where,
select: {
id: true,
entityType: true,
entityId: true,
entityName: true,
action: true,
userId: true,
source: true,
summary: true,
createdAt: true,
user: { select: { id: true, name: true, email: true } },
// Exclude 'changes' from list query — fetch on demand when expanding
},
orderBy: { createdAt: "desc" },
take: limit + 1,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
});
let nextCursor: string | undefined;
if (items.length > limit) {
const next = items.pop();
nextCursor = next?.id;
}
return { items, nextCursor };
listDetail: controllerProcedure
.input(
z.object({
entityType: z.string().optional(),
entityId: z.string().optional(),
userId: z.string().optional(),
action: z.string().optional(),
source: z.string().optional(),
startDate: z.date().optional(),
endDate: z.date().optional(),
search: z.string().optional(),
limit: z.number().min(1).max(100).default(50),
cursor: z.string().optional(),
}),
)
.query(async ({ ctx, input }) => {
const result = await listAuditEntries(ctx.db, toAuditListInput({
entityType: input.entityType,
entityId: input.entityId,
userId: input.userId,
action: input.action,
source: input.source,
startDate: input.startDate,
endDate: input.endDate,
search: input.search,
limit: input.limit,
cursor: input.cursor,
}));
return {
items: result.items.map(formatAuditListEntry),
nextCursor: result.nextCursor ?? null,
};
}),
/**
@@ -91,10 +307,14 @@ export const auditLogRouter = createTRPCRouter({
getById: controllerProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.db.auditLog.findUniqueOrThrow({
where: { id: input.id },
include: { user: { select: { id: true, name: true, email: true } } },
});
return getAuditEntryById(ctx.db, input.id);
}),
getByIdDetail: controllerProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const entry = await getAuditEntryById(ctx.db, input.id);
return formatAuditDetailEntry(entry);
}),
/**
@@ -109,17 +329,26 @@ export const auditLogRouter = createTRPCRouter({
}),
)
.query(async ({ ctx, input }) => {
return ctx.db.auditLog.findMany({
where: {
entityType: input.entityType,
entityId: input.entityId,
},
include: {
user: { select: { id: true, name: true, email: true } },
},
orderBy: { createdAt: "desc" },
take: input.limit,
});
return getAuditEntriesByEntity(ctx.db, input);
}),
getByEntityDetail: controllerProcedure
.input(
z.object({
entityType: z.string(),
entityId: z.string(),
limit: z.number().min(1).max(200).default(50),
}),
)
.query(async ({ ctx, input }) => {
const entries = await getAuditEntriesByEntity(ctx.db, input);
return {
entityType: input.entityType,
entityId: input.entityId,
entityName: entries[0]?.entityName ?? null,
itemCount: entries.length,
items: entries.map(formatAuditDetailEntry),
};
}),
/**
@@ -134,33 +363,33 @@ export const auditLogRouter = createTRPCRouter({
}),
)
.query(async ({ ctx, input }) => {
const where: Record<string, unknown> = {};
return getAuditTimeline(ctx.db, toAuditTimelineInput({
startDate: input.startDate,
endDate: input.endDate,
limit: input.limit,
}));
}),
if (input.startDate || input.endDate) {
const createdAt: Record<string, Date> = {};
if (input.startDate) createdAt.gte = input.startDate;
if (input.endDate) createdAt.lte = input.endDate;
where.createdAt = createdAt;
}
const entries = await ctx.db.auditLog.findMany({
where,
include: {
user: { select: { id: true, name: true, email: true } },
},
orderBy: { createdAt: "desc" },
take: input.limit,
});
// Group by date string (YYYY-MM-DD)
const grouped: Record<string, typeof entries> = {};
for (const entry of entries) {
const dateKey = entry.createdAt.toISOString().slice(0, 10);
if (!grouped[dateKey]) grouped[dateKey] = [];
grouped[dateKey].push(entry);
}
return grouped;
getTimelineDetail: controllerProcedure
.input(
z.object({
startDate: z.date().optional(),
endDate: z.date().optional(),
limit: z.number().min(1).max(500).default(200),
}),
)
.query(async ({ ctx, input }) => {
const timeline = await getAuditTimeline(ctx.db, toAuditTimelineInput({
startDate: input.startDate,
endDate: input.endDate,
limit: input.limit,
}));
return Object.fromEntries(
Object.entries(timeline).map(([dateKey, entries]) => [
dateKey,
entries.map(formatAuditDetailEntry),
]),
);
}),
/**
+76
View File
@@ -6,6 +6,18 @@ import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js
import { createAuditEntry } from "../lib/audit.js";
export const blueprintRouter = createTRPCRouter({
listSummaries: protectedProcedure
.query(async ({ ctx }) => {
return ctx.db.blueprint.findMany({
select: {
id: true,
name: true,
_count: { select: { projects: true } },
},
orderBy: { name: "asc" },
});
}),
list: protectedProcedure
.input(
z.object({
@@ -33,6 +45,70 @@ export const blueprintRouter = createTRPCRouter({
return blueprint;
}),
resolveByIdentifier: protectedProcedure
.input(z.object({ identifier: z.string().trim().min(1) }))
.query(async ({ ctx, input }) => {
const identifier = input.identifier.trim();
const select = {
id: true,
name: true,
target: true,
isActive: true,
} as const;
let blueprint = await ctx.db.blueprint.findUnique({
where: { id: identifier },
select,
});
if (!blueprint) {
blueprint = await ctx.db.blueprint.findFirst({
where: { name: { equals: identifier, mode: "insensitive" } },
select,
});
}
if (!blueprint) {
blueprint = await ctx.db.blueprint.findFirst({
where: { name: { contains: identifier, mode: "insensitive" } },
select,
});
}
if (!blueprint) {
throw new TRPCError({ code: "NOT_FOUND", message: `Blueprint not found: ${identifier}` });
}
return blueprint;
}),
getByIdentifier: protectedProcedure
.input(z.object({ identifier: z.string().trim().min(1) }))
.query(async ({ ctx, input }) => {
const identifier = input.identifier.trim();
let blueprint = await ctx.db.blueprint.findUnique({
where: { id: identifier },
});
if (!blueprint) {
blueprint = await ctx.db.blueprint.findFirst({
where: { name: { equals: identifier, mode: "insensitive" } },
});
}
if (!blueprint) {
blueprint = await ctx.db.blueprint.findFirst({
where: { name: { contains: identifier, mode: "insensitive" } },
});
}
if (!blueprint) {
throw new TRPCError({ code: "NOT_FOUND", message: `Blueprint not found: ${identifier}` });
}
return blueprint;
}),
create: adminProcedure
.input(CreateBlueprintSchema)
.mutation(async ({ ctx, input }) => {
+284 -204
View File
@@ -7,10 +7,12 @@ import {
getMonthKeys,
type AssignmentSlice,
} from "@capakraken/engine";
import type { PrismaClient } from "@capakraken/db";
import type { WeekdayAvailability } from "@capakraken/shared";
import { PermissionKey } from "@capakraken/shared";
import { isChargeabilityActualBooking, listAssignmentBookings } from "@capakraken/application";
import { z } from "zod";
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
import { createTRPCRouter, controllerProcedure, requirePermission } from "../trpc.js";
import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymization.js";
import {
calculateEffectiveAvailableHours,
@@ -18,221 +20,299 @@ import {
loadResourceDailyAvailabilityContexts,
} from "../lib/resource-capacity.js";
export const chargeabilityReportRouter = createTRPCRouter({
getReport: controllerProcedure
.input(
z.object({
startMonth: z.string().regex(/^\d{4}-\d{2}$/), // "2026-01"
endMonth: z.string().regex(/^\d{4}-\d{2}$/),
orgUnitId: z.string().optional(),
managementLevelGroupId: z.string().optional(),
countryId: z.string().optional(),
includeProposed: z.boolean().default(false),
}),
)
.query(async ({ ctx, input }) => {
const { startMonth, endMonth, includeProposed } = input;
function round1(value: number): number {
return Math.round(value * 10) / 10;
}
// Parse month range
const [startYear, startMo] = startMonth.split("-").map(Number) as [number, number];
const [endYear, endMo] = endMonth.split("-").map(Number) as [number, number];
const rangeStart = getMonthRange(startYear, startMo).start;
const rangeEnd = getMonthRange(endYear, endMo).end;
const monthKeys = getMonthKeys(rangeStart, rangeEnd);
const reportInputSchema = z.object({
startMonth: z.string().regex(/^\d{4}-\d{2}$/),
endMonth: z.string().regex(/^\d{4}-\d{2}$/),
orgUnitId: z.string().optional(),
managementLevelGroupId: z.string().optional(),
countryId: z.string().optional(),
includeProposed: z.boolean().default(false),
});
// Fetch resources with filters
const resourceWhere = {
isActive: true,
chgResponsibility: true,
departed: false,
rolledOff: false,
...(input.orgUnitId ? { orgUnitId: input.orgUnitId } : {}),
...(input.managementLevelGroupId ? { managementLevelGroupId: input.managementLevelGroupId } : {}),
...(input.countryId ? { countryId: input.countryId } : {}),
};
const detailedReportInputSchema = reportInputSchema.extend({
resourceQuery: z.string().optional(),
resourceLimit: z.number().int().min(1).max(100).optional(),
});
const resources = await ctx.db.resource.findMany({
where: resourceWhere,
select: {
id: true,
eid: true,
displayName: true,
fte: true,
availability: true,
countryId: true,
federalState: true,
metroCityId: true,
chargeabilityTarget: true,
country: { select: { id: true, code: true, dailyWorkingHours: true, scheduleRules: true } },
orgUnit: { select: { id: true, name: true } },
managementLevelGroup: { select: { id: true, name: true, targetPercentage: true } },
managementLevel: { select: { id: true, name: true } },
metroCity: { select: { id: true, name: true } },
},
orderBy: { displayName: "asc" },
type ChargeabilityReportDbClient = Pick<
PrismaClient,
"assignment" | "resource" | "project" | "vacation" | "holidayCalendar" | "systemSettings"
>;
async function queryChargeabilityReport(
db: ChargeabilityReportDbClient,
input: z.infer<typeof reportInputSchema>,
) {
const { startMonth, endMonth, includeProposed } = input;
const [startYear, startMo] = startMonth.split("-").map(Number) as [number, number];
const [endYear, endMo] = endMonth.split("-").map(Number) as [number, number];
const rangeStart = getMonthRange(startYear, startMo).start;
const rangeEnd = getMonthRange(endYear, endMo).end;
const monthKeys = getMonthKeys(rangeStart, rangeEnd);
const resourceWhere = {
isActive: true,
chgResponsibility: true,
departed: false,
rolledOff: false,
...(input.orgUnitId ? { orgUnitId: input.orgUnitId } : {}),
...(input.managementLevelGroupId ? { managementLevelGroupId: input.managementLevelGroupId } : {}),
...(input.countryId ? { countryId: input.countryId } : {}),
};
const resources = await db.resource.findMany({
where: resourceWhere,
select: {
id: true,
eid: true,
displayName: true,
fte: true,
availability: true,
countryId: true,
federalState: true,
metroCityId: true,
chargeabilityTarget: true,
country: { select: { id: true, code: true, dailyWorkingHours: true, scheduleRules: true } },
orgUnit: { select: { id: true, name: true } },
managementLevelGroup: { select: { id: true, name: true, targetPercentage: true } },
managementLevel: { select: { id: true, name: true } },
metroCity: { select: { id: true, name: true } },
},
orderBy: { displayName: "asc" },
});
if (resources.length === 0) {
return {
monthKeys,
resources: [],
groupTotals: monthKeys.map((key) => ({
monthKey: key,
totalFte: 0,
chg: 0,
target: 0,
gap: 0,
})),
};
}
const resourceIds = resources.map((resource) => resource.id);
const allBookings = await listAssignmentBookings(db, {
startDate: rangeStart,
endDate: rangeEnd,
resourceIds,
});
const availabilityContexts = await loadResourceDailyAvailabilityContexts(
db,
resources.map((resource) => ({
id: resource.id,
availability: resource.availability as unknown as WeekdayAvailability,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
})),
rangeStart,
rangeEnd,
);
const projectIds = [...new Set(allBookings.map((booking) => booking.projectId))];
const projectUtilCats = projectIds.length > 0
? await db.project.findMany({
where: { id: { in: projectIds } },
select: { id: true, utilizationCategory: { select: { code: true } } },
})
: [];
const projectUtilCatMap = new Map(
projectUtilCats.map((project) => [project.id, project.utilizationCategory?.code ?? null]),
);
const assignments = allBookings
.filter((booking) => booking.resourceId !== null)
.filter((booking) => isChargeabilityActualBooking(booking, includeProposed))
.map((booking) => ({
resourceId: booking.resourceId!,
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
project: {
status: booking.project.status,
utilizationCategory: { code: projectUtilCatMap.get(booking.projectId) ?? null },
},
}));
const resourceRows = await Promise.all(resources.map(async (resource) => {
const resourceAssignments = assignments.filter((assignment) => assignment.resourceId === resource.id);
const targetPct = resource.managementLevelGroup?.targetPercentage
?? (resource.chargeabilityTarget / 100);
const availability = resource.availability as unknown as WeekdayAvailability;
const context = availabilityContexts.get(resource.id);
const months = await Promise.all(monthKeys.map(async (key) => {
const [year, month] = key.split("-").map(Number) as [number, number];
const { start: monthStart, end: monthEnd } = getMonthRange(year, month);
const availableHours = calculateEffectiveAvailableHours({
availability,
periodStart: monthStart,
periodEnd: monthEnd,
context,
});
const slices: AssignmentSlice[] = resourceAssignments.flatMap((assignment) => {
const totalChargeableHours = calculateEffectiveBookedHours({
availability,
startDate: assignment.startDate,
endDate: assignment.endDate,
hoursPerDay: assignment.hoursPerDay,
periodStart: monthStart,
periodEnd: monthEnd,
context,
});
if (totalChargeableHours <= 0) {
return [];
}
if (resources.length === 0) {
return {
monthKeys,
resources: [],
groupTotals: monthKeys.map((key) => ({
monthKey: key,
totalFte: 0,
chg: 0,
target: 0,
gap: 0,
})),
};
}
// Fetch all bookings (assignments + legacy allocations) in the date range
const resourceIds = resources.map((r) => r.id);
const allBookings = await listAssignmentBookings(ctx.db, {
startDate: rangeStart,
endDate: rangeEnd,
resourceIds,
});
const availabilityContexts = await loadResourceDailyAvailabilityContexts(
ctx.db,
resources.map((resource) => ({
id: resource.id,
availability: resource.availability as unknown as WeekdayAvailability,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
})),
rangeStart,
rangeEnd,
);
// Enrich with utilization category — fetch project util categories in bulk
const projectIds = [...new Set(allBookings.map((b) => b.projectId))];
const projectUtilCats = projectIds.length > 0
? await ctx.db.project.findMany({
where: { id: { in: projectIds } },
select: { id: true, utilizationCategory: { select: { code: true } } },
})
: [];
const projectUtilCatMap = new Map(
projectUtilCats.map((p) => [p.id, p.utilizationCategory?.code ?? null]),
);
// Normalize bookings to a common shape
const assignments = allBookings
.filter((booking) => booking.resourceId !== null)
.filter((booking) => isChargeabilityActualBooking(booking, includeProposed))
.map((b) => ({
resourceId: b.resourceId!,
startDate: b.startDate,
endDate: b.endDate,
hoursPerDay: b.hoursPerDay,
project: {
status: b.project.status,
utilizationCategory: { code: projectUtilCatMap.get(b.projectId) ?? null },
},
}));
// Build per-resource, per-month forecasts
const resourceRows = await Promise.all(resources.map(async (resource) => {
const resourceAssignments = assignments.filter((a) => a.resourceId === resource.id);
// Prefer mgmt level group target; fall back to legacy chargeabilityTarget (0-100 → 0-1)
const targetPct = resource.managementLevelGroup?.targetPercentage
?? (resource.chargeabilityTarget / 100);
const availability = resource.availability as unknown as WeekdayAvailability;
const context = availabilityContexts.get(resource.id);
const months = await Promise.all(monthKeys.map(async (key) => {
const [y, m] = key.split("-").map(Number) as [number, number];
const { start: monthStart, end: monthEnd } = getMonthRange(y, m);
const availableHours = calculateEffectiveAvailableHours({
availability,
periodStart: monthStart,
periodEnd: monthEnd,
context,
});
const slices: AssignmentSlice[] = resourceAssignments.flatMap((a) => {
const totalChargeableHours = calculateEffectiveBookedHours({
availability,
startDate: a.startDate,
endDate: a.endDate,
hoursPerDay: a.hoursPerDay,
periodStart: monthStart,
periodEnd: monthEnd,
context,
});
if (totalChargeableHours <= 0) {
return [];
}
return {
hoursPerDay: a.hoursPerDay,
workingDays: 0,
categoryCode: a.project.utilizationCategory?.code ?? "Chg",
totalChargeableHours,
};
});
const forecast = deriveResourceForecast({
fte: resource.fte,
targetPercentage: targetPct,
assignments: slices,
sah: availableHours,
});
return {
monthKey: key,
sah: availableHours,
...forecast,
};
}));
const categoryCode = assignment.project.utilizationCategory?.code;
return {
id: resource.id,
eid: resource.eid,
displayName: resource.displayName,
fte: resource.fte,
country: resource.country?.code ?? null,
city: resource.metroCity?.name ?? null,
orgUnit: resource.orgUnit?.name ?? null,
mgmtGroup: resource.managementLevelGroup?.name ?? null,
mgmtLevel: resource.managementLevel?.name ?? null,
targetPct,
months,
};
}));
// Compute group totals per month
const groupTotals = monthKeys.map((key, monthIdx) => {
const groupInputs = resourceRows.map((r) => ({
fte: r.fte,
chargeability: r.months[monthIdx]!.chg,
}));
const targetInputs = resourceRows.map((r) => ({
fte: r.fte,
targetPercentage: r.targetPct,
}));
const chg = calculateGroupChargeability(groupInputs);
const target = calculateGroupTarget(targetInputs);
return {
monthKey: key,
totalFte: sumFte(resourceRows),
chg,
target,
gap: chg - target,
hoursPerDay: assignment.hoursPerDay,
workingDays: 0,
categoryCode: typeof categoryCode === "string" && categoryCode.length > 0 ? categoryCode : "Chg",
totalChargeableHours,
};
});
const directory = await getAnonymizationDirectory(ctx.db);
const forecast = deriveResourceForecast({
fte: resource.fte,
targetPercentage: targetPct,
assignments: slices,
sah: availableHours,
});
return {
monthKeys,
resources: anonymizeResources(resourceRows, directory),
groupTotals,
monthKey: key,
sah: availableHours,
...forecast,
};
}));
return {
id: resource.id,
eid: resource.eid,
displayName: resource.displayName,
fte: resource.fte,
country: resource.country?.code ?? null,
city: resource.metroCity?.name ?? null,
orgUnit: resource.orgUnit?.name ?? null,
mgmtGroup: resource.managementLevelGroup?.name ?? null,
mgmtLevel: resource.managementLevel?.name ?? null,
targetPct,
months,
};
}));
const groupTotals = monthKeys.map((key, monthIdx) => {
const groupInputs = resourceRows.map((resource) => ({
fte: resource.fte,
chargeability: resource.months[monthIdx]!.chg,
}));
const targetInputs = resourceRows.map((resource) => ({
fte: resource.fte,
targetPercentage: resource.targetPct,
}));
const chg = calculateGroupChargeability(groupInputs);
const target = calculateGroupTarget(targetInputs);
return {
monthKey: key,
totalFte: sumFte(resourceRows),
chg,
target,
gap: chg - target,
};
});
const directory = await getAnonymizationDirectory(db);
return {
monthKeys,
resources: anonymizeResources(resourceRows, directory),
groupTotals,
};
}
function buildChargeabilityReportDetail(
report: Awaited<ReturnType<typeof queryChargeabilityReport>>,
input: z.infer<typeof detailedReportInputSchema>,
) {
const resourceQuery = input.resourceQuery?.trim().toLowerCase();
const matchingResources = resourceQuery
? report.resources.filter((resource) => (
resource.displayName.toLowerCase().includes(resourceQuery)
|| resource.eid.toLowerCase().includes(resourceQuery)
))
: report.resources;
const resourceLimit = Math.min(Math.max(input.resourceLimit ?? 25, 1), 100);
const resources = matchingResources.slice(0, resourceLimit).map((resource) => ({
id: resource.id,
eid: resource.eid,
displayName: resource.displayName,
fte: round1(resource.fte),
country: resource.country,
city: resource.city,
orgUnit: resource.orgUnit,
managementLevelGroup: resource.mgmtGroup,
managementLevel: resource.mgmtLevel,
targetPct: round1(resource.targetPct * 100),
months: resource.months.map((month) => ({
monthKey: month.monthKey,
sah: round1(month.sah),
chargeabilityPct: round1(month.chg * 100),
targetPct: round1(resource.targetPct * 100),
gapPct: round1((month.chg - resource.targetPct) * 100),
})),
}));
return {
filters: {
startMonth: input.startMonth,
endMonth: input.endMonth,
orgUnitId: input.orgUnitId ?? null,
managementLevelGroupId: input.managementLevelGroupId ?? null,
countryId: input.countryId ?? null,
includeProposed: input.includeProposed ?? false,
resourceQuery: input.resourceQuery ?? null,
},
monthKeys: report.monthKeys,
groupTotals: report.groupTotals.map((group) => ({
monthKey: group.monthKey,
totalFte: round1(group.totalFte),
chargeabilityPct: round1(group.chg * 100),
targetPct: round1(group.target * 100),
gapPct: round1(group.gap * 100),
})),
resourceCount: matchingResources.length,
returnedResourceCount: resources.length,
truncated: resources.length < matchingResources.length,
resources,
};
}
export const chargeabilityReportRouter = createTRPCRouter({
getReport: controllerProcedure
.input(reportInputSchema)
.query(async ({ ctx, input }) => queryChargeabilityReport(ctx.db, input)),
getDetail: controllerProcedure
.input(detailedReportInputSchema)
.query(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.VIEW_COSTS);
const report = await queryChargeabilityReport(ctx.db, input);
return buildChargeabilityReportDetail(report, input);
}),
});
+98 -1
View File
@@ -44,7 +44,12 @@ export const clientRouter = createTRPCRouter({
...(input?.parentId !== undefined ? { parentId: input.parentId } : {}),
...(input?.isActive !== undefined ? { isActive: input.isActive } : {}),
...(input?.search
? { name: { contains: input.search, mode: "insensitive" as const } }
? {
OR: [
{ name: { contains: input.search, mode: "insensitive" as const } },
{ code: { contains: input.search, mode: "insensitive" as const } },
],
}
: {}),
},
include: { _count: { select: { children: true, projects: true } } },
@@ -81,6 +86,98 @@ export const clientRouter = createTRPCRouter({
return client;
}),
resolveByIdentifier: protectedProcedure
.input(z.object({ identifier: z.string().trim().min(1) }))
.query(async ({ ctx, input }) => {
const identifier = input.identifier.trim();
const select = {
id: true,
name: true,
code: true,
parentId: true,
isActive: true,
} as const;
let client = await ctx.db.client.findUnique({
where: { id: identifier },
select,
});
if (!client) {
client = await ctx.db.client.findUnique({
where: { code: identifier },
select,
});
}
if (!client) {
client = await ctx.db.client.findFirst({
where: { name: { equals: identifier, mode: "insensitive" } },
select,
});
}
if (!client) {
client = await ctx.db.client.findFirst({
where: {
OR: [
{ name: { contains: identifier, mode: "insensitive" } },
{ code: { contains: identifier, mode: "insensitive" } },
],
},
select,
});
}
if (!client) {
throw new TRPCError({ code: "NOT_FOUND", message: `Client not found: ${identifier}` });
}
return client;
}),
getByIdentifier: protectedProcedure
.input(z.object({ identifier: z.string().trim().min(1) }))
.query(async ({ ctx, input }) => {
const identifier = input.identifier.trim();
let client = await ctx.db.client.findUnique({
where: { id: identifier },
include: { _count: { select: { projects: true, children: true } } },
});
if (!client) {
client = await ctx.db.client.findUnique({
where: { code: identifier },
include: { _count: { select: { projects: true, children: true } } },
});
}
if (!client) {
client = await ctx.db.client.findFirst({
where: { name: { equals: identifier, mode: "insensitive" } },
include: { _count: { select: { projects: true, children: true } } },
});
}
if (!client) {
client = await ctx.db.client.findFirst({
where: {
OR: [
{ name: { contains: identifier, mode: "insensitive" } },
{ code: { contains: identifier, mode: "insensitive" } },
],
},
include: { _count: { select: { projects: true, children: true } } },
});
}
if (!client) {
throw new TRPCError({ code: "NOT_FOUND", message: `Client not found: ${identifier}` });
}
return client;
}),
create: managerProcedure
.input(CreateClientSchema)
.mutation(async ({ ctx, input }) => {
File diff suppressed because it is too large Load Diff
+108 -1
View File
@@ -30,6 +30,100 @@ export const countryRouter = createTRPCRouter({
});
}),
resolveByIdentifier: protectedProcedure
.input(z.object({ identifier: z.string().trim().min(1) }))
.query(async ({ ctx, input }) => {
const identifier = input.identifier.trim();
const select = {
id: true,
code: true,
name: true,
isActive: true,
dailyWorkingHours: true,
} as const;
let country = await ctx.db.country.findUnique({
where: { id: identifier },
select,
});
if (!country) {
country = await ctx.db.country.findFirst({
where: { code: { equals: identifier.toUpperCase(), mode: "insensitive" } },
select,
});
}
if (!country) {
country = await ctx.db.country.findFirst({
where: { name: { equals: identifier, mode: "insensitive" } },
select,
});
}
if (!country) {
country = await ctx.db.country.findFirst({
where: { name: { contains: identifier, mode: "insensitive" } },
select,
});
}
if (!country) {
throw new TRPCError({ code: "NOT_FOUND", message: `Country not found: ${identifier}` });
}
return country;
}),
getByIdentifier: protectedProcedure
.input(z.object({ identifier: z.string().trim().min(1) }))
.query(async ({ ctx, input }) => {
const identifier = input.identifier.trim();
let country = await ctx.db.country.findUnique({
where: { id: identifier },
include: {
metroCities: { orderBy: { name: "asc" } },
_count: { select: { resources: true } },
},
});
if (!country) {
country = await ctx.db.country.findFirst({
where: { code: { equals: identifier.toUpperCase(), mode: "insensitive" } },
include: {
metroCities: { orderBy: { name: "asc" } },
_count: { select: { resources: true } },
},
});
}
if (!country) {
country = await ctx.db.country.findFirst({
where: { name: { equals: identifier, mode: "insensitive" } },
include: {
metroCities: { orderBy: { name: "asc" } },
_count: { select: { resources: true } },
},
});
}
if (!country) {
country = await ctx.db.country.findFirst({
where: { name: { contains: identifier, mode: "insensitive" } },
include: {
metroCities: { orderBy: { name: "asc" } },
_count: { select: { resources: true } },
},
});
}
if (!country) {
throw new TRPCError({ code: "NOT_FOUND", message: `Country not found: ${identifier}` });
}
return country;
}),
getById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
@@ -46,6 +140,19 @@ export const countryRouter = createTRPCRouter({
return country;
}),
getCityById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const city = await findUniqueOrThrow(
ctx.db.metroCity.findUnique({
where: { id: input.id },
select: { id: true, name: true, countryId: true },
}),
"Metro city",
);
return city;
}),
create: adminProcedure
.input(CreateCountrySchema)
.mutation(async ({ ctx, input }) => {
@@ -207,6 +314,6 @@ export const countryRouter = createTRPCRouter({
source: "ui",
});
return { success: true };
return { success: true, id: city.id, name: city.name };
}),
});
+287 -50
View File
@@ -1,5 +1,5 @@
import { z } from "zod";
import { createTRPCRouter, protectedProcedure, controllerProcedure } from "../trpc.js";
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
import {
getDashboardChargeabilityOverview,
getDashboardDemand,
@@ -8,25 +8,182 @@ import {
getDashboardTopValueResources,
getDashboardBudgetForecast,
getDashboardSkillGaps,
getDashboardSkillGapSummary,
getDashboardProjectHealth,
} from "@capakraken/application";
import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymization.js";
import { cacheGet, cacheSet } from "../lib/cache.js";
import { fmtEur } from "../lib/format-utils.js";
const DEFAULT_TTL = 60; // seconds
export const dashboardRouter = createTRPCRouter({
getOverview: protectedProcedure.query(async ({ ctx }) => {
const cacheKey = "overview";
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardOverview>>>(cacheKey);
if (cached) return cached;
function round1(value: number): number {
return Math.round(value * 10) / 10;
}
const result = await getDashboardOverview(ctx.db);
await cacheSet(cacheKey, result, DEFAULT_TTL);
return result;
function mapProjectHealthDetailRows(rows: Awaited<ReturnType<typeof getDashboardProjectHealth>>) {
const projects = rows
.map((project) => {
const overall = project.compositeScore;
return {
projectId: project.id,
projectName: project.projectName,
shortCode: project.shortCode,
status: project.status,
overall,
budget: project.budgetHealth,
staffing: project.staffingHealth,
timeline: project.timelineHealth,
rating: overall >= 80 ? "healthy" : overall >= 50 ? "at_risk" : "critical",
};
})
.sort((left, right) => left.overall - right.overall);
return {
projects,
summary: {
healthy: projects.filter((project) => project.rating === "healthy").length,
atRisk: projects.filter((project) => project.rating === "at_risk").length,
critical: projects.filter((project) => project.rating === "critical").length,
},
};
}
function mapBudgetForecastDetailRows(rows: Awaited<ReturnType<typeof getDashboardBudgetForecast>>) {
return {
forecasts: rows.map((forecast) => ({
projectId: forecast.projectId ?? null,
projectName: forecast.projectName,
shortCode: forecast.shortCode,
clientId: forecast.clientId,
clientName: forecast.clientName,
budget: fmtEur(forecast.budgetCents),
budgetCents: forecast.budgetCents,
spent: fmtEur(forecast.spentCents),
spentCents: forecast.spentCents,
remaining: fmtEur(forecast.remainingCents ?? (forecast.budgetCents - forecast.spentCents)),
remainingCents: forecast.remainingCents ?? (forecast.budgetCents - forecast.spentCents),
projected: forecast.burnRate > 0
? fmtEur(forecast.spentCents + Math.max(0, forecast.budgetCents - forecast.spentCents))
: fmtEur(forecast.spentCents),
projectedCents: forecast.burnRate > 0
? Math.max(forecast.spentCents, forecast.budgetCents)
: forecast.spentCents,
burnRate: fmtEur(forecast.burnRate),
burnRateCents: forecast.burnRate,
utilization: `${forecast.pctUsed}%`,
estimatedExhaustionDate: forecast.estimatedExhaustionDate,
activeAssignmentCount: forecast.activeAssignmentCount ?? null,
calendarLocations: forecast.calendarLocations ?? [],
burnStatus: forecast.pctUsed >= 100
? "ahead"
: forecast.burnRate > 0
? "on_track"
: "not_started",
})),
};
}
function mapStatisticsDetail(overview: Awaited<ReturnType<typeof getDashboardOverview>>) {
return {
activeResources: overview.activeResources,
totalProjects: overview.totalProjects,
activeProjects: overview.activeProjects,
totalAllocations: overview.totalAllocations,
approvedVacations: overview.approvedVacations,
totalEstimates: overview.totalEstimates,
totalBudget: overview.budgetSummary.totalBudgetCents > 0
? fmtEur(overview.budgetSummary.totalBudgetCents)
: "N/A",
projectsByStatus: Object.fromEntries(
overview.projectsByStatus.map((entry) => [entry.status, entry.count]),
),
topChapters: [...overview.chapterUtilization]
.sort((left, right) => right.resourceCount - left.resourceCount)
.slice(0, 10)
.map((chapter) => ({
chapter: chapter.chapter,
count: chapter.resourceCount,
})),
};
}
async function getOverviewCached(db: Parameters<typeof getDashboardOverview>[0]) {
const cacheKey = "overview";
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardOverview>>>(cacheKey);
if (cached) return cached;
const result = await getDashboardOverview(db);
await cacheSet(cacheKey, result, DEFAULT_TTL);
return result;
}
async function getPeakTimesCached(
db: Parameters<typeof getDashboardPeakTimes>[0],
input: { startDate: string; endDate: string; granularity: "week" | "month"; groupBy: "project" | "chapter" | "resource" },
) {
const cacheKey = `peakTimes:${input.startDate}:${input.endDate}:${input.granularity}:${input.groupBy}`;
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardPeakTimes>>>(cacheKey);
if (cached) return cached;
const result = await getDashboardPeakTimes(db, {
startDate: new Date(input.startDate),
endDate: new Date(input.endDate),
granularity: input.granularity,
groupBy: input.groupBy,
});
await cacheSet(cacheKey, result, DEFAULT_TTL);
return result;
}
async function getDemandCached(
db: Parameters<typeof getDashboardDemand>[0],
input: { startDate: string; endDate: string; groupBy: "project" | "person" | "chapter" },
) {
const cacheKey = `demand:${input.startDate}:${input.endDate}:${input.groupBy}`;
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardDemand>>>(cacheKey);
if (cached) return cached;
const result = await getDashboardDemand(db, {
startDate: new Date(input.startDate),
endDate: new Date(input.endDate),
groupBy: input.groupBy,
});
await cacheSet(cacheKey, result, DEFAULT_TTL);
return result;
}
async function getTopValueResourcesCached(
db: Parameters<typeof getDashboardTopValueResources>[0],
input: { limit: number; userRole: string },
) {
const cacheKey = `topValue:${input.limit}:${input.userRole}`;
const cached = await cacheGet<Awaited<ReturnType<typeof anonymizeResources>>>(cacheKey);
if (cached) return cached;
const [resources, directory] = await Promise.all([
getDashboardTopValueResources(db, {
limit: input.limit,
userRole: input.userRole,
}),
getAnonymizationDirectory(db),
]);
const result = anonymizeResources(resources, directory);
await cacheSet(cacheKey, result, DEFAULT_TTL);
return result;
}
export const dashboardRouter = createTRPCRouter({
getOverview: controllerProcedure.query(async ({ ctx }) => {
return getOverviewCached(ctx.db);
}),
getPeakTimes: protectedProcedure
getStatisticsDetail: controllerProcedure.query(async ({ ctx }) => {
const overview = await getOverviewCached(ctx.db);
return mapStatisticsDetail(overview);
}),
getPeakTimes: controllerProcedure
.input(
z.object({
startDate: z.string().datetime(),
@@ -36,42 +193,18 @@ export const dashboardRouter = createTRPCRouter({
}),
)
.query(async ({ ctx, input }) => {
const cacheKey = `peakTimes:${input.startDate}:${input.endDate}:${input.granularity}:${input.groupBy}`;
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardPeakTimes>>>(cacheKey);
if (cached) return cached;
const result = await getDashboardPeakTimes(ctx.db, {
startDate: new Date(input.startDate),
endDate: new Date(input.endDate),
granularity: input.granularity,
groupBy: input.groupBy,
});
await cacheSet(cacheKey, result, DEFAULT_TTL);
return result;
return getPeakTimesCached(ctx.db, input);
}),
getTopValueResources: protectedProcedure
getTopValueResources: controllerProcedure
.input(z.object({ limit: z.number().int().min(1).max(50).default(10) }))
.query(async ({ ctx, input }) => {
const userRole =
(ctx.session.user as { role?: string } | undefined)?.role ?? "USER";
const cacheKey = `topValue:${input.limit}:${userRole}`;
const cached = await cacheGet<Awaited<ReturnType<typeof anonymizeResources>>>(cacheKey);
if (cached) return cached;
const [resources, directory] = await Promise.all([
getDashboardTopValueResources(ctx.db, {
limit: input.limit,
userRole,
}),
getAnonymizationDirectory(ctx.db),
]);
const result = anonymizeResources(resources, directory);
await cacheSet(cacheKey, result, DEFAULT_TTL);
return result;
return getTopValueResourcesCached(ctx.db, { limit: input.limit, userRole });
}),
getDemand: protectedProcedure
getDemand: controllerProcedure
.input(
z.object({
startDate: z.string().datetime(),
@@ -80,16 +213,100 @@ export const dashboardRouter = createTRPCRouter({
}),
)
.query(async ({ ctx, input }) => {
const cacheKey = `demand:${input.startDate}:${input.endDate}:${input.groupBy}`;
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardDemand>>>(cacheKey);
if (cached) return cached;
return getDemandCached(ctx.db, input);
}),
getDetail: controllerProcedure
.input(z.object({ section: z.string().optional().default("all") }))
.query(async ({ ctx, input }) => {
const section = input.section;
const result: Record<string, unknown> = {};
const needsOverview =
section === "all"
|| section === "peak_times"
|| section === "demand_pipeline"
|| section === "chargeability_overview";
const overview = needsOverview ? await getOverviewCached(ctx.db) : null;
const now = new Date();
const rangeStart = overview?.budgetBasis.windowStart
? new Date(overview.budgetBasis.windowStart)
: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
const rangeEnd = overview?.budgetBasis.windowEnd
? new Date(overview.budgetBasis.windowEnd)
: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 5, 0));
const userRole =
(ctx.session.user as { role?: string } | undefined)?.role
?? ctx.dbUser?.systemRole
?? "USER";
if (section === "all" || section === "peak_times") {
const peakTimes = await getPeakTimesCached(ctx.db, {
startDate: rangeStart.toISOString(),
endDate: rangeEnd.toISOString(),
granularity: "month",
groupBy: "project",
});
result.peakTimes = [...peakTimes]
.sort((left, right) => right.totalHours - left.totalHours)
.slice(0, 6)
.map((entry) => ({
month: entry.period,
totalHours: round1(entry.totalHours),
totalHoursPerDay: round1(entry.totalHours),
capacityHours: round1(entry.capacityHours),
utilizationPct: entry.utilizationPct ?? null,
}));
}
if (section === "all" || section === "top_resources") {
const resources = await getTopValueResourcesCached(ctx.db, { limit: 10, userRole });
result.topResources = resources.map((resource) => {
const topResource = resource as {
displayName: string;
eid: string;
chapter: string | null;
lcrCents: number;
valueScore: number | null;
};
return {
name: topResource.displayName,
eid: topResource.eid,
chapter: topResource.chapter ?? null,
lcr: fmtEur(topResource.lcrCents),
valueScore: topResource.valueScore ?? null,
};
});
}
if (section === "all" || section === "demand_pipeline") {
const demandRows = await getDemandCached(ctx.db, {
startDate: rangeStart.toISOString(),
endDate: rangeEnd.toISOString(),
groupBy: "project",
});
result.demandPipeline = demandRows
.map((row) => ({
project: `${row.name} (${row.shortCode})`,
needed: Math.max(0, round1(row.requiredFTEs - row.resourceCount)),
requiredFTEs: row.requiredFTEs,
allocatedResources: row.resourceCount,
allocatedHours: row.allocatedHours,
calendarLocations: row.derivation?.calendarLocations ?? [],
}))
.filter((row) => row.needed > 0)
.sort((left, right) => right.needed - left.needed)
.slice(0, 15);
}
if (section === "all" || section === "chargeability_overview") {
result.chargeabilityByChapter = (overview?.chapterUtilization ?? []).map((chapter) => ({
chapter: chapter.chapter ?? "Unassigned",
headcount: chapter.resourceCount,
avgTarget: `${Math.round(chapter.avgChargeabilityTarget)}%`,
}));
}
const result = await getDashboardDemand(ctx.db, {
startDate: new Date(input.startDate),
endDate: new Date(input.endDate),
groupBy: input.groupBy,
});
await cacheSet(cacheKey, result, DEFAULT_TTL);
return result;
}),
@@ -133,7 +350,7 @@ export const dashboardRouter = createTRPCRouter({
return result;
}),
getBudgetForecast: protectedProcedure.query(async ({ ctx }) => {
getBudgetForecast: controllerProcedure.query(async ({ ctx }) => {
const cacheKey = "budgetForecast";
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardBudgetForecast>>>(cacheKey);
if (cached) return cached;
@@ -143,7 +360,12 @@ export const dashboardRouter = createTRPCRouter({
return result;
}),
getSkillGaps: protectedProcedure.query(async ({ ctx }) => {
getBudgetForecastDetail: controllerProcedure.query(async ({ ctx }) => {
const budgetForecast = await getDashboardBudgetForecast(ctx.db);
return mapBudgetForecastDetailRows(budgetForecast);
}),
getSkillGaps: controllerProcedure.query(async ({ ctx }) => {
const cacheKey = "skillGaps";
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardSkillGaps>>>(cacheKey);
if (cached) return cached;
@@ -153,7 +375,17 @@ export const dashboardRouter = createTRPCRouter({
return result;
}),
getProjectHealth: protectedProcedure.query(async ({ ctx }) => {
getSkillGapSummary: controllerProcedure.query(async ({ ctx }) => {
const cacheKey = "skillGapSummary";
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardSkillGapSummary>>>(cacheKey);
if (cached) return cached;
const result = await getDashboardSkillGapSummary(ctx.db);
await cacheSet(cacheKey, result, DEFAULT_TTL);
return result;
}),
getProjectHealth: controllerProcedure.query(async ({ ctx }) => {
const cacheKey = "projectHealth";
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardProjectHealth>>>(cacheKey);
if (cached) return cached;
@@ -162,4 +394,9 @@ export const dashboardRouter = createTRPCRouter({
await cacheSet(cacheKey, result, DEFAULT_TTL);
return result;
}),
getProjectHealthDetail: controllerProcedure.query(async ({ ctx }) => {
const projectHealth = await getDashboardProjectHealth(ctx.db);
return mapProjectHealthDetailRows(projectHealth);
}),
});
+199 -86
View File
@@ -23,6 +23,167 @@ type EntitlementSnapshot = {
pendingDays: number;
};
function mapBalanceDetail(resource: {
displayName: string;
eid: string;
}, balance: {
year: number;
entitledDays: number;
carryoverDays: number;
usedDays: number;
pendingDays: number;
remainingDays: number;
sickDays: number;
}) {
return {
resource: resource.displayName,
eid: resource.eid,
year: balance.year,
entitlement: balance.entitledDays,
carryOver: balance.carryoverDays,
taken: balance.usedDays,
pending: balance.pendingDays,
remaining: balance.remainingDays,
sickDays: balance.sickDays,
};
}
function mapYearSummaryDetail(
year: number,
summaries: Array<{
displayName: string;
eid: string;
chapter: string | null;
entitledDays: number;
carryoverDays: number;
usedDays: number;
pendingDays: number;
remainingDays: number;
}>,
resourceName?: string,
) {
const needle = resourceName?.toLowerCase();
return summaries
.filter((summary) => {
if (!needle) {
return true;
}
return summary.displayName.toLowerCase().includes(needle)
|| summary.eid.toLowerCase().includes(needle);
})
.slice(0, 50)
.map((summary) => ({
resource: summary.displayName,
eid: summary.eid,
chapter: summary.chapter ?? null,
year,
entitled: summary.entitledDays,
carryover: summary.carryoverDays,
used: summary.usedDays,
pending: summary.pendingDays,
remaining: summary.remainingDays,
}));
}
type EntitlementReadContext = Parameters<Parameters<typeof protectedProcedure["query"]>[0]>[0]["ctx"];
async function readBalanceSnapshot(
ctx: Pick<EntitlementReadContext, "db" | "dbUser">,
input: { resourceId: string; year: number },
) {
if (ctx.dbUser) {
const allowedRoles = ["ADMIN", "MANAGER", "CONTROLLER"];
if (!allowedRoles.includes(ctx.dbUser.systemRole)) {
const resource = await ctx.db.resource.findUnique({
where: { id: input.resourceId },
select: { userId: true },
});
if (!resource || resource.userId !== ctx.dbUser.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You can only view your own vacation balance",
});
}
}
}
const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
const defaultDays = settings?.vacationDefaultDays ?? 28;
const entitlement = await syncEntitlement(ctx.db, input.resourceId, input.year, defaultDays);
const sickVacationsResult = await ctx.db.vacation.findMany({
where: {
resourceId: input.resourceId,
type: VacationType.SICK,
status: VacationStatus.APPROVED,
startDate: { lte: new Date(`${input.year}-12-31T00:00:00.000Z`) },
endDate: { gte: new Date(`${input.year}-01-01T00:00:00.000Z`) },
},
select: { startDate: true, endDate: true, isHalfDay: true },
});
const sickVacations = Array.isArray(sickVacationsResult) ? sickVacationsResult : [];
const sickDays = sickVacations.reduce(
(sum, vacation) => sum + countCalendarDaysInPeriod(
vacation,
new Date(`${input.year}-01-01T00:00:00.000Z`),
new Date(`${input.year}-12-31T00:00:00.000Z`),
),
0,
);
return {
year: input.year,
resourceId: input.resourceId,
entitledDays: entitlement.entitledDays,
carryoverDays: entitlement.carryoverDays,
usedDays: entitlement.usedDays,
pendingDays: entitlement.pendingDays,
remainingDays: Math.max(
0,
entitlement.entitledDays - entitlement.usedDays - entitlement.pendingDays,
),
sickDays,
};
}
async function readYearSummarySnapshot(
ctx: Pick<EntitlementReadContext, "db">,
input: { year: number; chapter?: string },
) {
const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
const defaultDays = settings?.vacationDefaultDays ?? 28;
const resources = await ctx.db.resource.findMany({
where: {
isActive: true,
...(input.chapter ? { chapter: input.chapter } : {}),
},
select: { ...RESOURCE_BRIEF_SELECT, chapter: true },
orderBy: [{ chapter: "asc" }, { displayName: "asc" }],
});
return Promise.all(
resources.map(async (resource) => {
const entitlement = await syncEntitlement(ctx.db, resource.id, input.year, defaultDays);
return {
resourceId: resource.id,
displayName: resource.displayName,
eid: resource.eid,
chapter: resource.chapter,
entitledDays: entitlement.entitledDays,
carryoverDays: entitlement.carryoverDays,
usedDays: entitlement.usedDays,
pendingDays: entitlement.pendingDays,
remainingDays: Math.max(
0,
entitlement.entitledDays - entitlement.usedDays - entitlement.pendingDays,
),
};
}),
);
}
/**
* Get or create an entitlement record, applying carryover from previous year if needed.
*/
@@ -163,6 +324,15 @@ export const entitlementRouter = createTRPCRouter({
* Creates the entitlement record if it doesn't exist (with carryover).
*/
getBalance: protectedProcedure
.input(
z.object({
resourceId: z.string(),
year: z.number().int().min(2000).max(2100).default(new Date().getFullYear()),
}),
)
.query(async ({ ctx, input }) => readBalanceSnapshot(ctx, input)),
getBalanceDetail: protectedProcedure
.input(
z.object({
resourceId: z.string(),
@@ -170,63 +340,20 @@ export const entitlementRouter = createTRPCRouter({
}),
)
.query(async ({ ctx, input }) => {
// Ownership check: USER can only query their own balance
if (ctx.dbUser) {
const allowedRoles = ["ADMIN", "MANAGER", "CONTROLLER"];
if (!allowedRoles.includes(ctx.dbUser.systemRole)) {
const resource = await ctx.db.resource.findUnique({
where: { id: input.resourceId },
select: { userId: true },
});
if (!resource || resource.userId !== ctx.dbUser.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You can only view your own vacation balance",
});
}
}
const balance = await readBalanceSnapshot(ctx, input);
const resource = await ctx.db.resource.findUnique({
where: { id: input.resourceId },
select: { displayName: true, eid: true },
});
if (!resource) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Resource not found",
});
}
const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
const defaultDays = settings?.vacationDefaultDays ?? 28;
// Sync from real vacation records
const entitlement = await syncEntitlement(ctx.db, input.resourceId, input.year, defaultDays);
// Also count sick days (informational)
const sickVacationsResult = await ctx.db.vacation.findMany({
where: {
resourceId: input.resourceId,
type: VacationType.SICK,
status: VacationStatus.APPROVED,
startDate: { lte: new Date(`${input.year}-12-31T00:00:00.000Z`) },
endDate: { gte: new Date(`${input.year}-01-01T00:00:00.000Z`) },
},
select: { startDate: true, endDate: true, isHalfDay: true },
});
const sickVacations = Array.isArray(sickVacationsResult) ? sickVacationsResult : [];
const sickDays = sickVacations.reduce(
(sum, v) => sum + countCalendarDaysInPeriod(
v,
new Date(`${input.year}-01-01T00:00:00.000Z`),
new Date(`${input.year}-12-31T00:00:00.000Z`),
),
0,
);
return {
year: input.year,
resourceId: input.resourceId,
entitledDays: entitlement.entitledDays,
carryoverDays: entitlement.carryoverDays,
usedDays: entitlement.usedDays,
pendingDays: entitlement.pendingDays,
remainingDays: Math.max(
0,
entitlement.entitledDays - entitlement.usedDays - entitlement.pendingDays,
),
sickDays,
};
return mapBalanceDetail(resource, balance);
}),
/**
@@ -366,39 +493,25 @@ export const entitlementRouter = createTRPCRouter({
chapter: z.string().optional(),
}),
)
.query(async ({ ctx, input }) => {
const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
const defaultDays = settings?.vacationDefaultDays ?? 28;
.query(async ({ ctx, input }) => readYearSummarySnapshot(ctx, {
year: input.year,
...(input.chapter ? { chapter: input.chapter } : {}),
})),
const resources = await ctx.db.resource.findMany({
where: {
isActive: true,
...(input.chapter ? { chapter: input.chapter } : {}),
},
select: { ...RESOURCE_BRIEF_SELECT, chapter: true },
orderBy: [{ chapter: "asc" }, { displayName: "asc" }],
getYearSummaryDetail: managerProcedure
.input(
z.object({
year: z.number().int().min(2000).max(2100).default(new Date().getFullYear()),
chapter: z.string().optional(),
resourceName: z.string().optional(),
}),
)
.query(async ({ ctx, input }) => {
const summaries = await readYearSummarySnapshot(ctx, {
year: input.year,
...(input.chapter ? { chapter: input.chapter } : {}),
});
const results = await Promise.all(
resources.map(async (r) => {
const entitlement = await syncEntitlement(ctx.db, r.id, input.year, defaultDays);
return {
resourceId: r.id,
displayName: r.displayName,
eid: r.eid,
chapter: r.chapter,
entitledDays: entitlement.entitledDays,
carryoverDays: entitlement.carryoverDays,
usedDays: entitlement.usedDays,
pendingDays: entitlement.pendingDays,
remainingDays: Math.max(
0,
entitlement.entitledDays - entitlement.usedDays - entitlement.pendingDays,
),
};
}),
);
return results;
return mapYearSummaryDetail(input.year, summaries, input.resourceName);
}),
});
+314 -109
View File
@@ -47,6 +47,38 @@ import {
} from "../trpc.js";
import { emitAllocationCreated } from "../sse/event-bus.js";
type EstimateRouterErrorCode = "NOT_FOUND" | "PRECONDITION_FAILED";
type EstimateRouterErrorRule = {
code: EstimateRouterErrorCode;
messages?: readonly string[];
predicates?: readonly ((message: string) => boolean)[];
};
function rethrowEstimateRouterError(
error: unknown,
rules: readonly EstimateRouterErrorRule[],
): never {
if (!(error instanceof Error)) {
throw error;
}
const matchingRule = rules.find(
(rule) =>
rule.messages?.includes(error.message) === true ||
rule.predicates?.some((predicate) => predicate(error.message)) === true,
);
if (matchingRule) {
throw new TRPCError({
code: matchingRule.code,
message: error.message,
});
}
throw error;
}
function buildComputedMetrics(
demandLines: z.infer<typeof CreateEstimateSchema>["demandLines"],
) {
@@ -235,6 +267,199 @@ export const estimateRouter = createTRPCRouter({
return estimate;
}),
listVersions: controllerProcedure
.input(z.object({ estimateId: z.string() }))
.query(async ({ ctx, input }) => {
const estimate = await findUniqueOrThrow(
ctx.db.estimate.findUnique({
where: { id: input.estimateId },
select: {
id: true,
name: true,
status: true,
latestVersionNumber: true,
versions: {
orderBy: { versionNumber: "desc" },
select: {
id: true,
versionNumber: true,
label: true,
status: true,
notes: true,
lockedAt: true,
createdAt: true,
updatedAt: true,
_count: {
select: {
assumptions: true,
scopeItems: true,
demandLines: true,
resourceSnapshots: true,
exports: true,
},
},
},
},
},
}),
"Estimate",
);
return estimate;
}),
getVersionSnapshot: controllerProcedure
.input(z.object({ estimateId: z.string(), versionId: z.string().optional() }))
.query(async ({ ctx, input }) => {
const estimate = await ctx.db.estimate.findUnique({
where: { id: input.estimateId },
select: {
id: true,
name: true,
status: true,
baseCurrency: true,
versions: {
...(input.versionId
? { where: { id: input.versionId } }
: { orderBy: { versionNumber: "desc" as const }, take: 1 }),
select: {
id: true,
versionNumber: true,
label: true,
status: true,
notes: true,
lockedAt: true,
createdAt: true,
updatedAt: true,
assumptions: {
select: { id: true, category: true, key: true, label: true },
},
scopeItems: {
select: { id: true, scopeType: true, sequenceNo: true, name: true },
orderBy: [{ sequenceNo: "asc" }, { name: "asc" }],
},
demandLines: {
select: {
id: true,
name: true,
chapter: true,
hours: true,
costTotalCents: true,
priceTotalCents: true,
currency: true,
},
},
resourceSnapshots: {
select: {
id: true,
displayName: true,
chapter: true,
currency: true,
lcrCents: true,
ucrCents: true,
},
},
exports: {
select: {
id: true,
format: true,
fileName: true,
createdAt: true,
},
orderBy: { createdAt: "desc" },
},
},
},
},
});
if (!estimate || estimate.versions.length === 0) {
throw new TRPCError({ code: "NOT_FOUND", message: "Estimate version not found" });
}
const version = estimate.versions[0]!;
const demandSummary = summarizeEstimateDemandLines(version.demandLines);
const chapterTotals = version.demandLines.reduce<Record<string, {
lineCount: number;
hours: number;
costTotalCents: number;
priceTotalCents: number;
currency: string;
}>>((acc, line) => {
const key = line.chapter ?? "Unassigned";
const current = acc[key] ?? {
lineCount: 0,
hours: 0,
costTotalCents: 0,
priceTotalCents: 0,
currency: line.currency,
};
current.lineCount += 1;
current.hours += line.hours;
current.costTotalCents += line.costTotalCents;
current.priceTotalCents += line.priceTotalCents;
acc[key] = current;
return acc;
}, {});
const scopeTypeTotals = version.scopeItems.reduce<Record<string, number>>((acc, item) => {
acc[item.scopeType] = (acc[item.scopeType] ?? 0) + 1;
return acc;
}, {});
const assumptionCategoryTotals = version.assumptions.reduce<Record<string, number>>((acc, assumption) => {
acc[assumption.category] = (acc[assumption.category] ?? 0) + 1;
return acc;
}, {});
return {
estimate: {
id: estimate.id,
name: estimate.name,
status: estimate.status,
baseCurrency: estimate.baseCurrency,
},
version: {
id: version.id,
versionNumber: version.versionNumber,
label: version.label,
status: version.status,
notes: version.notes,
lockedAt: version.lockedAt,
createdAt: version.createdAt,
updatedAt: version.updatedAt,
},
counts: {
assumptions: version.assumptions.length,
scopeItems: version.scopeItems.length,
demandLines: version.demandLines.length,
resourceSnapshots: version.resourceSnapshots.length,
exports: version.exports.length,
},
totals: {
hours: demandSummary.totalHours,
costTotalCents: demandSummary.totalCostCents,
priceTotalCents: demandSummary.totalPriceCents,
marginCents: demandSummary.marginCents,
marginPercent: demandSummary.marginPercent,
},
chapterBreakdown: Object.entries(chapterTotals)
.sort((left, right) => right[1].hours - left[1].hours)
.map(([chapter, totals]) => ({
chapter,
...totals,
})),
scopeTypeBreakdown: Object.entries(scopeTypeTotals)
.sort((left, right) => right[1] - left[1])
.map(([scopeType, count]) => ({ scopeType, count })),
assumptionCategoryBreakdown: Object.entries(assumptionCategoryTotals)
.sort((left, right) => right[1] - left[1])
.map(([category, count]) => ({ category, count })),
exports: version.exports,
};
}),
create: managerProcedure
.input(CreateEstimateSchema)
.mutation(async ({ ctx, input }) => {
@@ -294,15 +519,12 @@ export const estimateRouter = createTRPCRouter({
input,
);
} catch (error) {
if (error instanceof Error) {
if (
error.message === "Source estimate not found" ||
error.message === "Source estimate has no versions"
) {
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
}
}
throw error;
rethrowEstimateRouterError(error, [
{
code: "NOT_FOUND",
messages: ["Source estimate not found", "Source estimate has no versions"],
},
]);
}
await ctx.db.auditLog.create({
@@ -360,19 +582,16 @@ export const estimateRouter = createTRPCRouter({
withComputedMetrics(enrichedInput, input.baseCurrency ?? "EUR"),
);
} catch (error) {
if (error instanceof Error && error.message === "Estimate not found") {
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
}
if (
error instanceof Error &&
error.message === "Estimate has no working version"
) {
throw new TRPCError({
rethrowEstimateRouterError(error, [
{
code: "NOT_FOUND",
messages: ["Estimate not found"],
},
{
code: "PRECONDITION_FAILED",
message: error.message,
});
}
throw error;
messages: ["Estimate has no working version"],
},
]);
}
await ctx.db.auditLog.create({
@@ -411,24 +630,19 @@ export const estimateRouter = createTRPCRouter({
input,
);
} catch (error) {
if (error instanceof Error) {
if (
error.message === "Estimate not found" ||
error.message === "Estimate version not found"
) {
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
}
if (
error.message === "Estimate has no working version" ||
error.message === "Only working versions can be submitted"
) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: error.message,
});
}
}
throw error;
rethrowEstimateRouterError(error, [
{
code: "NOT_FOUND",
messages: ["Estimate not found", "Estimate version not found"],
},
{
code: "PRECONDITION_FAILED",
messages: [
"Estimate has no working version",
"Only working versions can be submitted",
],
},
]);
}
await ctx.db.auditLog.create({
@@ -464,24 +678,19 @@ export const estimateRouter = createTRPCRouter({
input,
);
} catch (error) {
if (error instanceof Error) {
if (
error.message === "Estimate not found" ||
error.message === "Estimate version not found"
) {
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
}
if (
error.message === "Estimate has no submitted version" ||
error.message === "Only submitted versions can be approved"
) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: error.message,
});
}
}
throw error;
rethrowEstimateRouterError(error, [
{
code: "NOT_FOUND",
messages: ["Estimate not found", "Estimate version not found"],
},
{
code: "PRECONDITION_FAILED",
messages: [
"Estimate has no submitted version",
"Only submitted versions can be approved",
],
},
]);
}
await ctx.db.auditLog.create({
@@ -517,25 +726,20 @@ export const estimateRouter = createTRPCRouter({
input,
);
} catch (error) {
if (error instanceof Error) {
if (
error.message === "Estimate not found" ||
error.message === "Estimate version not found"
) {
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
}
if (
error.message === "Estimate already has a working version" ||
error.message === "Estimate has no locked version to revise" ||
error.message === "Source version must be locked before creating a revision"
) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: error.message,
});
}
}
throw error;
rethrowEstimateRouterError(error, [
{
code: "NOT_FOUND",
messages: ["Estimate not found", "Estimate version not found"],
},
{
code: "PRECONDITION_FAILED",
messages: [
"Estimate already has a working version",
"Estimate has no locked version to revise",
"Source version must be locked before creating a revision",
],
},
]);
}
await ctx.db.auditLog.create({
@@ -572,16 +776,16 @@ export const estimateRouter = createTRPCRouter({
input,
);
} catch (error) {
if (error instanceof Error) {
if (
error.message === "Estimate not found" ||
error.message === "Estimate version not found" ||
error.message === "Estimate has no version to export"
) {
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
}
}
throw error;
rethrowEstimateRouterError(error, [
{
code: "NOT_FOUND",
messages: [
"Estimate not found",
"Estimate version not found",
"Estimate has no version to export",
],
},
]);
}
const exportedVersion = input.versionId
@@ -620,29 +824,30 @@ export const estimateRouter = createTRPCRouter({
input,
);
} catch (error) {
if (error instanceof Error) {
if (
error.message === "Estimate not found" ||
error.message === "Estimate version not found" ||
error.message === "Linked project not found"
) {
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
}
if (
error.message === "Estimate has no approved version" ||
error.message === "Only approved versions can be handed off to planning" ||
error.message === "Estimate must be linked to a project before planning handoff" ||
error.message === "Planning handoff already exists for this approved version" ||
error.message === "Linked project has an invalid date range" ||
error.message.startsWith("Project window has no working days for demand line")
) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: error.message,
});
}
}
throw error;
rethrowEstimateRouterError(error, [
{
code: "NOT_FOUND",
messages: [
"Estimate not found",
"Estimate version not found",
"Linked project not found",
],
},
{
code: "PRECONDITION_FAILED",
messages: [
"Estimate has no approved version",
"Only approved versions can be handed off to planning",
"Estimate must be linked to a project before planning handoff",
"Planning handoff already exists for this approved version",
"Linked project has an invalid date range",
],
predicates: [
(message) =>
message.startsWith("Project window has no working days for demand line"),
],
},
]);
}
await ctx.db.auditLog.create({
+478 -49
View File
@@ -14,6 +14,7 @@ import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday
import { createTRPCRouter, adminProcedure, protectedProcedure, type TRPCContext } from "../trpc.js";
type HolidayCalendarScope = HolidayCalendarScopeInput;
type HolidayReadContext = Pick<TRPCContext, "db">;
const HOLIDAY_SCOPE = {
COUNTRY: "COUNTRY",
@@ -49,6 +50,401 @@ function clampDate(date: Date): Date {
return value;
}
function fmtDate(value: Date | null | undefined): string | null {
return value ? value.toISOString().slice(0, 10) : null;
}
function formatIsoDate(value: Date): string {
return value.toISOString().slice(0, 10);
}
function formatHolidayCalendarEntryDetail(entry: {
id: string;
date: Date;
name: string;
isRecurringAnnual?: boolean | null;
source?: string | null;
}) {
return {
id: entry.id,
date: formatIsoDate(entry.date),
name: entry.name,
isRecurringAnnual: entry.isRecurringAnnual ?? false,
source: entry.source ?? null,
};
}
function formatHolidayCalendarDetail(calendar: {
id: string;
name: string;
scopeType: string;
stateCode?: string | null;
isActive?: boolean | null;
priority?: number | null;
country?: { id: string; code: string; name: string } | null;
metroCity?: { id: string; name: string } | null;
_count?: { entries?: number | null } | null;
entries?: Array<{
id: string;
date: Date;
name: string;
isRecurringAnnual?: boolean | null;
source?: string | null;
}> | null;
}) {
const entries = calendar.entries?.map(formatHolidayCalendarEntryDetail) ?? [];
return {
id: calendar.id,
name: calendar.name,
scopeType: calendar.scopeType,
stateCode: calendar.stateCode ?? null,
isActive: calendar.isActive ?? true,
priority: calendar.priority ?? 0,
country: calendar.country
? {
id: calendar.country.id,
code: calendar.country.code,
name: calendar.country.name,
}
: null,
metroCity: calendar.metroCity
? {
id: calendar.metroCity.id,
name: calendar.metroCity.name,
}
: null,
entryCount: calendar._count?.entries ?? entries.length,
entries,
};
}
function formatResolvedHolidayDetail(holiday: {
date: string;
name: string;
scopeType: string;
calendarName: string;
sourceType: string;
}) {
return {
date: holiday.date,
name: holiday.name,
scope: holiday.scopeType,
calendarName: holiday.calendarName,
sourceType: holiday.sourceType,
};
}
function summarizeResolvedHolidaysDetail(holidays: Array<{
date: string;
name: string;
scope: string;
calendarName: string;
sourceType: string;
}>) {
const byScope = new Map<string, number>();
const bySourceType = new Map<string, number>();
const byCalendar = new Map<string, number>();
for (const holiday of holidays) {
byScope.set(holiday.scope, (byScope.get(holiday.scope) ?? 0) + 1);
bySourceType.set(holiday.sourceType, (bySourceType.get(holiday.sourceType) ?? 0) + 1);
byCalendar.set(holiday.calendarName, (byCalendar.get(holiday.calendarName) ?? 0) + 1);
}
return {
byScope: [...byScope.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([scope, count]) => ({ scope, count })),
bySourceType: [...bySourceType.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([sourceType, count]) => ({ sourceType, count })),
byCalendar: [...byCalendar.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([calendarName, count]) => ({ calendarName, count })),
};
}
const ResolveHolidaysInputSchema = z.object({
periodStart: z.coerce.date(),
periodEnd: z.coerce.date(),
countryId: z.string().optional(),
countryCode: z.string().trim().min(1).optional(),
stateCode: z.string().trim().min(1).optional(),
metroCityId: z.string().optional(),
metroCityName: z.string().trim().min(1).optional(),
}).superRefine((input, issueCtx) => {
if (!input.countryId && !input.countryCode) {
issueCtx.addIssue({
code: z.ZodIssueCode.custom,
message: "Either countryId or countryCode is required.",
path: ["countryId"],
});
}
if (input.periodEnd < input.periodStart) {
issueCtx.addIssue({
code: z.ZodIssueCode.custom,
message: "periodEnd must be on or after periodStart.",
path: ["periodEnd"],
});
}
});
const ResolveResourceHolidaysInputSchema = z.object({
resourceId: z.string(),
periodStart: z.coerce.date(),
periodEnd: z.coerce.date(),
}).superRefine((input, issueCtx) => {
if (input.periodEnd < input.periodStart) {
issueCtx.addIssue({
code: z.ZodIssueCode.custom,
message: "periodEnd must be on or after periodStart.",
path: ["periodEnd"],
});
}
});
async function readCalendarsSnapshot(
ctx: HolidayReadContext,
input?: {
includeInactive?: boolean | undefined;
countryCode?: string | undefined;
scopeType?: "COUNTRY" | "STATE" | "CITY" | undefined;
stateCode?: string | undefined;
metroCity?: string | undefined;
},
) {
const db = asHolidayCalendarDb(ctx.db);
const where = {
...(input?.includeInactive ? {} : { isActive: true }),
...(input?.countryCode
? {
country: { code: { equals: input.countryCode.trim().toUpperCase(), mode: "insensitive" as const } },
}
: {}),
...(input?.scopeType ? { scopeType: input.scopeType } : {}),
...(input?.stateCode ? { stateCode: input.stateCode.trim().toUpperCase() } : {}),
...(input?.metroCity
? {
metroCity: { name: { contains: input.metroCity.trim(), mode: "insensitive" as const } },
}
: {}),
};
return db.holidayCalendar.findMany({
where,
include: {
country: { select: { id: true, code: true, name: true } },
metroCity: { select: { id: true, name: true } },
_count: { select: { entries: true } },
entries: { orderBy: [{ date: "asc" }, { name: "asc" }] },
},
orderBy: [
{ country: { name: "asc" } },
{ scopeType: "asc" },
{ priority: "desc" },
{ name: "asc" },
],
});
}
async function readCalendarByIdentifierSnapshot(ctx: HolidayReadContext, identifier: string) {
const db = asHolidayCalendarDb(ctx.db);
const trimmedIdentifier = identifier.trim();
let calendar = await db.holidayCalendar.findUnique({
where: { id: trimmedIdentifier },
include: {
country: { select: { id: true, code: true, name: true } },
metroCity: { select: { id: true, name: true } },
entries: { orderBy: [{ date: "asc" }, { name: "asc" }] },
},
});
if (!calendar) {
calendar = await db.holidayCalendar.findFirst({
where: { name: { equals: trimmedIdentifier, mode: "insensitive" } },
include: {
country: { select: { id: true, code: true, name: true } },
metroCity: { select: { id: true, name: true } },
entries: { orderBy: [{ date: "asc" }, { name: "asc" }] },
},
});
}
if (!calendar) {
calendar = await db.holidayCalendar.findFirst({
where: { name: { contains: trimmedIdentifier, mode: "insensitive" } },
include: {
country: { select: { id: true, code: true, name: true } },
metroCity: { select: { id: true, name: true } },
entries: { orderBy: [{ date: "asc" }, { name: "asc" }] },
},
});
}
if (!calendar) {
throw new TRPCError({ code: "NOT_FOUND", message: `Holiday calendar not found: ${trimmedIdentifier}` });
}
return calendar;
}
async function readPreviewResolvedHolidaysSnapshot(
ctx: HolidayReadContext,
input: z.infer<typeof PreviewResolvedHolidaysSchema>,
) {
const country = await findUniqueOrThrow(
ctx.db.country.findUnique({
where: { id: input.countryId },
select: { id: true, code: true, name: true },
}),
"Country",
);
const metroCity = input.metroCityId
? await ctx.db.metroCity.findUnique({
where: { id: input.metroCityId },
select: { id: true, name: true, countryId: true },
})
: null;
const resolved = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), {
periodStart: new Date(`${input.year}-01-01T00:00:00.000Z`),
periodEnd: new Date(`${input.year}-12-31T00:00:00.000Z`),
countryId: input.countryId,
countryCode: country.code,
federalState: input.stateCode?.trim().toUpperCase() ?? null,
metroCityId: input.metroCityId ?? null,
metroCityName: metroCity?.name ?? null,
});
return {
locationContext: {
countryId: input.countryId,
countryCode: country.code,
stateCode: input.stateCode?.trim().toUpperCase() ?? null,
metroCityId: input.metroCityId ?? null,
metroCity: metroCity?.name ?? null,
year: input.year,
},
holidays: resolved.map((holiday) => ({
date: holiday.date,
name: holiday.name,
scopeType: holiday.scope,
calendarName: holiday.calendarName,
sourceType: holiday.sourceType,
})),
};
}
async function readResolvedHolidaysSnapshot(
ctx: HolidayReadContext,
input: z.infer<typeof ResolveHolidaysInputSchema>,
) {
let resolvedCountryCode = input.countryCode?.trim().toUpperCase() ?? null;
if (!resolvedCountryCode && input.countryId) {
const country = await findUniqueOrThrow(
ctx.db.country.findUnique({
where: { id: input.countryId },
select: { code: true },
}),
"Country",
);
resolvedCountryCode = country.code;
}
const metroCityName = input.metroCityId
? (await ctx.db.metroCity.findUnique({
where: { id: input.metroCityId },
select: { name: true },
}))?.name ?? null
: input.metroCityName?.trim() ?? null;
const resolved = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), {
periodStart: input.periodStart,
periodEnd: input.periodEnd,
countryId: input.countryId ?? null,
countryCode: resolvedCountryCode,
federalState: input.stateCode?.trim().toUpperCase() ?? null,
metroCityId: input.metroCityId ?? null,
metroCityName,
});
return {
periodStart: input.periodStart.toISOString().slice(0, 10),
periodEnd: input.periodEnd.toISOString().slice(0, 10),
locationContext: {
countryId: input.countryId ?? null,
countryCode: resolvedCountryCode,
federalState: input.stateCode?.trim().toUpperCase() ?? null,
metroCityId: input.metroCityId ?? null,
metroCity: metroCityName,
},
holidays: resolved.map((holiday) => ({
date: holiday.date,
name: holiday.name,
scopeType: holiday.scope,
calendarName: holiday.calendarName,
sourceType: holiday.sourceType,
})),
};
}
async function readResolvedResourceHolidaysSnapshot(
ctx: HolidayReadContext,
input: z.infer<typeof ResolveResourceHolidaysInputSchema>,
) {
const resource = await findUniqueOrThrow(
ctx.db.resource.findUnique({
where: { id: input.resourceId },
select: {
id: true,
eid: true,
displayName: true,
federalState: true,
countryId: true,
metroCityId: true,
country: { select: { code: true, name: true } },
metroCity: { select: { name: true } },
},
}),
"Resource",
);
const resolved = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), {
periodStart: input.periodStart,
periodEnd: input.periodEnd,
countryId: resource.countryId ?? null,
countryCode: resource.country?.code ?? null,
federalState: resource.federalState ?? null,
metroCityId: resource.metroCityId ?? null,
metroCityName: resource.metroCity?.name ?? null,
});
return {
periodStart: input.periodStart.toISOString().slice(0, 10),
periodEnd: input.periodEnd.toISOString().slice(0, 10),
resource: {
id: resource.id,
eid: resource.eid,
name: resource.displayName,
country: resource.country?.name ?? resource.country?.code ?? null,
countryCode: resource.country?.code ?? null,
federalState: resource.federalState ?? null,
metroCity: resource.metroCity?.name ?? null,
},
holidays: resolved.map((holiday) => ({
date: holiday.date,
name: holiday.name,
scopeType: holiday.scope,
calendarName: holiday.calendarName,
sourceType: holiday.sourceType,
})),
};
}
async function assertEntryDateAvailable(
db: HolidayCalendarDb,
input: {
@@ -153,26 +549,40 @@ async function assertScopeConsistency(
export const holidayCalendarRouter = createTRPCRouter({
listCalendars: protectedProcedure
.input(z.object({ includeInactive: z.boolean().optional() }).optional())
.query(async ({ ctx, input }) => {
const db = asHolidayCalendarDb(ctx.db);
const where = input?.includeInactive ? undefined : { isActive: true };
.input(z.object({
includeInactive: z.boolean().optional(),
countryCode: z.string().trim().min(1).optional(),
scopeType: z.enum(["COUNTRY", "STATE", "CITY"]).optional(),
stateCode: z.string().trim().min(1).optional(),
metroCity: z.string().trim().min(1).optional(),
}).optional())
.query(async ({ ctx, input }) => readCalendarsSnapshot(ctx, input)),
return db.holidayCalendar.findMany({
...(where ? { where } : {}),
include: {
country: { select: { id: true, code: true, name: true } },
metroCity: { select: { id: true, name: true } },
_count: { select: { entries: true } },
entries: { orderBy: [{ date: "asc" }, { name: "asc" }] },
},
orderBy: [
{ country: { name: "asc" } },
{ scopeType: "asc" },
{ priority: "desc" },
{ name: "asc" },
],
});
listCalendarsDetail: protectedProcedure
.input(z.object({
includeInactive: z.boolean().optional(),
countryCode: z.string().trim().min(1).optional(),
scopeType: z.enum(["COUNTRY", "STATE", "CITY"]).optional(),
stateCode: z.string().trim().min(1).optional(),
metroCity: z.string().trim().min(1).optional(),
}).optional())
.query(async ({ ctx, input }) => {
const calendars = await readCalendarsSnapshot(ctx, input);
return {
count: calendars.length,
calendars: calendars.map(formatHolidayCalendarDetail),
};
}),
getCalendarByIdentifier: protectedProcedure
.input(z.object({ identifier: z.string().trim().min(1) }))
.query(async ({ ctx, input }) => readCalendarByIdentifierSnapshot(ctx, input.identifier)),
getCalendarByIdentifierDetail: protectedProcedure
.input(z.object({ identifier: z.string().trim().min(1) }))
.query(async ({ ctx, input }) => {
const calendar = await readCalendarByIdentifierSnapshot(ctx, input.identifier);
return formatHolidayCalendarDetail(calendar);
}),
getCalendarById: protectedProcedure
@@ -323,7 +733,7 @@ export const holidayCalendarRouter = createTRPCRouter({
source: "ui",
});
return { success: true };
return { success: true, id: existing.id, name: existing.name };
}),
createEntry: adminProcedure
@@ -430,42 +840,61 @@ export const holidayCalendarRouter = createTRPCRouter({
source: "ui",
});
return { success: true };
return { success: true, id: existing.id, name: existing.name };
}),
previewResolvedHolidays: protectedProcedure
.input(PreviewResolvedHolidaysSchema)
.query(async ({ ctx, input }) => (await readPreviewResolvedHolidaysSnapshot(ctx, input)).holidays),
previewResolvedHolidaysDetail: protectedProcedure
.input(PreviewResolvedHolidaysSchema)
.query(async ({ ctx, input }) => {
const country = await findUniqueOrThrow(
ctx.db.country.findUnique({
where: { id: input.countryId },
select: { code: true },
}),
"Country",
);
const resolved = await readPreviewResolvedHolidaysSnapshot(ctx, input);
const holidays = resolved.holidays.map(formatResolvedHolidayDetail);
return {
count: holidays.length,
locationContext: resolved.locationContext,
summary: summarizeResolvedHolidaysDetail(holidays),
holidays,
};
}),
const metroCity = input.metroCityId
? await ctx.db.metroCity.findUnique({
where: { id: input.metroCityId },
select: { name: true },
})
: null;
resolveHolidays: protectedProcedure
.input(ResolveHolidaysInputSchema)
.query(async ({ ctx, input }) => readResolvedHolidaysSnapshot(ctx, input)),
const resolved = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), {
periodStart: new Date(`${input.year}-01-01T00:00:00.000Z`),
periodEnd: new Date(`${input.year}-12-31T00:00:00.000Z`),
countryId: input.countryId,
countryCode: country.code,
federalState: input.stateCode?.trim().toUpperCase() ?? null,
metroCityId: input.metroCityId ?? null,
metroCityName: metroCity?.name ?? null,
});
resolveHolidaysDetail: protectedProcedure
.input(ResolveHolidaysInputSchema)
.query(async ({ ctx, input }) => {
const resolved = await readResolvedHolidaysSnapshot(ctx, input);
const holidays = resolved.holidays.map(formatResolvedHolidayDetail);
return {
periodStart: resolved.periodStart,
periodEnd: resolved.periodEnd,
locationContext: resolved.locationContext,
count: holidays.length,
summary: summarizeResolvedHolidaysDetail(holidays),
holidays,
};
}),
return resolved.map((holiday) => ({
date: holiday.date,
name: holiday.name,
scopeType: holiday.scope,
calendarName: holiday.calendarName,
}));
resolveResourceHolidays: protectedProcedure
.input(ResolveResourceHolidaysInputSchema)
.query(async ({ ctx, input }) => readResolvedResourceHolidaysSnapshot(ctx, input)),
resolveResourceHolidaysDetail: protectedProcedure
.input(ResolveResourceHolidaysInputSchema)
.query(async ({ ctx, input }) => {
const resolved = await readResolvedResourceHolidaysSnapshot(ctx, input);
const holidays = resolved.holidays.map(formatResolvedHolidayDetail);
return {
periodStart: resolved.periodStart,
periodEnd: resolved.periodEnd,
resource: resolved.resource,
count: holidays.length,
summary: summarizeResolvedHolidaysDetail(holidays),
holidays,
};
}),
});
+274 -288
View File
@@ -13,6 +13,69 @@ export interface Anomaly {
message: string;
}
interface InsightDemandRecord {
headcount: number;
startDate: Date;
endDate: Date;
_count: {
assignments: number;
};
}
interface InsightProjectAssignmentRecord {
resourceId: string;
startDate: Date;
endDate: Date;
hoursPerDay: number;
dailyCostCents: number;
status: string;
}
interface InsightProjectRecord {
id: string;
name: string;
budgetCents: number;
startDate: Date;
endDate: Date;
demandRequirements: InsightDemandRecord[];
assignments: InsightProjectAssignmentRecord[];
}
interface InsightResourceRecord {
id: string;
displayName: string;
availability: unknown;
}
interface InsightAssignmentLoadRecord {
resourceId: string;
hoursPerDay: number;
}
interface InsightSnapshot {
anomalies: Anomaly[];
summary: {
total: number;
criticalCount: number;
budget: number;
staffing: number;
timeline: number;
utilization: number;
};
}
interface InsightsDbAccess {
project: {
findMany(args: Record<string, unknown>): Promise<InsightProjectRecord[]>;
};
resource: {
findMany(args: Record<string, unknown>): Promise<InsightResourceRecord[]>;
};
assignment: {
findMany(args: Record<string, unknown>): Promise<InsightAssignmentLoadRecord[]>;
};
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
/**
@@ -29,9 +92,216 @@ function countBusinessDays(start: Date, end: Date): number {
return count;
}
async function loadInsightProjects(db: InsightsDbAccess["project"]) {
return db.findMany({
where: { status: { in: ["ACTIVE", "DRAFT"] } },
include: {
demandRequirements: {
select: {
headcount: true,
startDate: true,
endDate: true,
_count: { select: { assignments: true } },
},
},
assignments: {
select: {
resourceId: true,
startDate: true,
endDate: true,
hoursPerDay: true,
dailyCostCents: true,
status: true,
},
},
},
}) as Promise<InsightProjectRecord[]>;
}
async function loadInsightResources(db: InsightsDbAccess["resource"]) {
return db.findMany({
where: { isActive: true },
select: {
id: true,
displayName: true,
availability: true,
},
}) as Promise<InsightResourceRecord[]>;
}
async function loadInsightAssignmentLoads(db: InsightsDbAccess["assignment"], now: Date) {
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
return db.findMany({
where: {
status: { in: ["ACTIVE", "CONFIRMED"] },
startDate: { lte: periodEnd },
endDate: { gte: periodStart },
},
select: {
resourceId: true,
hoursPerDay: true,
},
}) as Promise<InsightAssignmentLoadRecord[]>;
}
function summarizeAnomalies(anomalies: Anomaly[]): InsightSnapshot["summary"] {
return anomalies.reduce<InsightSnapshot["summary"]>((summary, anomaly) => {
summary.total += 1;
summary[anomaly.type] += 1;
if (anomaly.severity === "critical") {
summary.criticalCount += 1;
}
return summary;
}, {
total: 0,
criticalCount: 0,
budget: 0,
staffing: 0,
timeline: 0,
utilization: 0,
});
}
async function buildInsightSnapshot(db: InsightsDbAccess, now = new Date()): Promise<InsightSnapshot> {
const [projects, resources, activeAssignments] = await Promise.all([
loadInsightProjects(db.project),
loadInsightResources(db.resource),
loadInsightAssignmentLoads(db.assignment, now),
]);
const twoWeeksFromNow = new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000);
const anomalies: Anomaly[] = [];
for (const project of projects) {
if (project.budgetCents > 0) {
const totalDays = countBusinessDays(project.startDate, project.endDate);
const elapsedDays = countBusinessDays(project.startDate, now < project.endDate ? now : project.endDate);
if (totalDays > 0 && elapsedDays > 0) {
const expectedBurnRate = elapsedDays / totalDays;
const totalCostCents = project.assignments.reduce((sum, assignment) => {
const assignmentStart = assignment.startDate < project.startDate
? project.startDate
: assignment.startDate;
const assignmentEnd = assignment.endDate > now ? now : assignment.endDate;
if (assignmentEnd < assignmentStart) {
return sum;
}
return sum + assignment.dailyCostCents * countBusinessDays(assignmentStart, assignmentEnd);
}, 0);
const actualBurnRate = totalCostCents / project.budgetCents;
if (actualBurnRate > expectedBurnRate * 1.2) {
const overSpendPercent = Math.round(((actualBurnRate - expectedBurnRate) / expectedBurnRate) * 100);
anomalies.push({
type: "budget",
severity: actualBurnRate > expectedBurnRate * 1.5 ? "critical" : "warning",
entityId: project.id,
entityName: project.name,
message: `Burning budget ${overSpendPercent}% faster than expected. ${Math.round(actualBurnRate * 100)}% spent at ${Math.round(expectedBurnRate * 100)}% timeline.`,
});
}
}
}
const upcomingDemands = project.demandRequirements.filter(
(demand) => demand.startDate <= twoWeeksFromNow && demand.endDate >= now,
);
for (const demand of upcomingDemands) {
const unfilledCount = demand.headcount - demand._count.assignments;
const unfillPct = demand.headcount > 0 ? unfilledCount / demand.headcount : 0;
if (unfillPct > 0.3) {
anomalies.push({
type: "staffing",
severity: unfillPct > 0.6 ? "critical" : "warning",
entityId: project.id,
entityName: project.name,
message: `${unfilledCount} of ${demand.headcount} positions unfilled, starting ${demand.startDate.toISOString().slice(0, 10)}.`,
});
}
}
const overrunAssignments = project.assignments.filter(
(assignment) => assignment.endDate > project.endDate
&& (assignment.status === "ACTIVE" || assignment.status === "CONFIRMED"),
);
if (overrunAssignments.length > 0) {
anomalies.push({
type: "timeline",
severity: "warning",
entityId: project.id,
entityName: project.name,
message: `${overrunAssignments.length} assignment(s) extend beyond the project end date (${project.endDate.toISOString().slice(0, 10)}).`,
});
}
}
const resourceHoursMap = new Map<string, number>();
for (const assignment of activeAssignments) {
const currentHours = resourceHoursMap.get(assignment.resourceId) ?? 0;
resourceHoursMap.set(assignment.resourceId, currentHours + assignment.hoursPerDay);
}
for (const resource of resources) {
const availability = resource.availability as Record<string, number> | null;
if (!availability) {
continue;
}
const dailyAvailableHours = Object.values(availability).reduce((sum, hours) => sum + (hours ?? 0), 0) / 5;
if (dailyAvailableHours <= 0) {
continue;
}
const bookedHours = resourceHoursMap.get(resource.id) ?? 0;
const utilizationPercent = Math.round((bookedHours / dailyAvailableHours) * 100);
if (utilizationPercent > 110) {
anomalies.push({
type: "utilization",
severity: utilizationPercent > 130 ? "critical" : "warning",
entityId: resource.id,
entityName: resource.displayName,
message: `Resource at ${utilizationPercent}% utilization (${bookedHours.toFixed(1)}h/${dailyAvailableHours.toFixed(1)}h per day).`,
});
} else if (utilizationPercent < 40 && bookedHours > 0) {
anomalies.push({
type: "utilization",
severity: "warning",
entityId: resource.id,
entityName: resource.displayName,
message: `Resource at only ${utilizationPercent}% utilization (${bookedHours.toFixed(1)}h/${dailyAvailableHours.toFixed(1)}h per day).`,
});
}
}
anomalies.sort((left, right) => {
if (left.severity !== right.severity) {
return left.severity === "critical" ? -1 : 1;
}
return left.type.localeCompare(right.type);
});
return {
anomalies,
summary: summarizeAnomalies(anomalies),
};
}
// ─── Router ──────────────────────────────────────────────────────────────────
export const insightsRouter = createTRPCRouter({
getAnomalyDetail: controllerProcedure.query(async ({ ctx }) => {
const snapshot = await buildInsightSnapshot(ctx.db as unknown as InsightsDbAccess);
return {
anomalies: snapshot.anomalies,
count: snapshot.anomalies.length,
};
}),
/**
* Generate an AI-powered executive narrative for a project.
* Caches the result in the project's dynamicFields.aiNarrative to avoid
@@ -185,300 +455,16 @@ ${dataContext}`;
* No AI involved — pure data analysis.
*/
detectAnomalies: controllerProcedure.query(async ({ ctx }) => {
const now = new Date();
const twoWeeksFromNow = new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000);
const anomalies: Anomaly[] = [];
// Fetch all active projects with their demands and assignments
const projects = await ctx.db.project.findMany({
where: { status: { in: ["ACTIVE", "DRAFT"] } },
include: {
demandRequirements: {
select: {
id: true,
headcount: true,
startDate: true,
endDate: true,
status: true,
_count: { select: { assignments: true } },
},
},
assignments: {
select: {
id: true,
resourceId: true,
startDate: true,
endDate: true,
hoursPerDay: true,
dailyCostCents: true,
status: true,
},
},
},
});
for (const project of projects) {
// ── Budget anomaly: spending faster than expected burn rate ──
if (project.budgetCents > 0) {
const totalDays = countBusinessDays(project.startDate, project.endDate);
const elapsedDays = countBusinessDays(project.startDate, now < project.endDate ? now : project.endDate);
if (totalDays > 0 && elapsedDays > 0) {
const expectedBurnRate = elapsedDays / totalDays; // fraction of timeline elapsed
const totalCostCents = project.assignments.reduce((s, a) => {
const aStart = a.startDate < project.startDate ? project.startDate : a.startDate;
const aEnd = a.endDate > now ? now : a.endDate;
if (aEnd < aStart) return s;
const days = countBusinessDays(aStart, aEnd);
return s + a.dailyCostCents * days;
}, 0);
const actualBurnRate = totalCostCents / project.budgetCents;
if (actualBurnRate > expectedBurnRate * 1.2) {
const overSpendPercent = Math.round(((actualBurnRate - expectedBurnRate) / expectedBurnRate) * 100);
anomalies.push({
type: "budget",
severity: actualBurnRate > expectedBurnRate * 1.5 ? "critical" : "warning",
entityId: project.id,
entityName: project.name,
message: `Burning budget ${overSpendPercent}% faster than expected. ${Math.round(actualBurnRate * 100)}% spent at ${Math.round(expectedBurnRate * 100)}% timeline.`,
});
}
}
}
// ── Staffing anomaly: unfilled demands close to start ──
const upcomingDemands = project.demandRequirements.filter(
(d) => d.startDate <= twoWeeksFromNow && d.endDate >= now,
);
for (const demand of upcomingDemands) {
const unfilledCount = demand.headcount - demand._count.assignments;
const unfillPct = demand.headcount > 0 ? unfilledCount / demand.headcount : 0;
if (unfillPct > 0.3) {
anomalies.push({
type: "staffing",
severity: unfillPct > 0.6 ? "critical" : "warning",
entityId: project.id,
entityName: project.name,
message: `${unfilledCount} of ${demand.headcount} positions unfilled, starting ${demand.startDate.toISOString().slice(0, 10)}.`,
});
}
}
// ── Timeline anomaly: assignments extending beyond project end ──
const overrunAssignments = project.assignments.filter(
(a) => a.endDate > project.endDate && (a.status === "ACTIVE" || a.status === "CONFIRMED"),
);
if (overrunAssignments.length > 0) {
anomalies.push({
type: "timeline",
severity: "warning",
entityId: project.id,
entityName: project.name,
message: `${overrunAssignments.length} assignment(s) extend beyond the project end date (${project.endDate.toISOString().slice(0, 10)}).`,
});
}
}
// ── Utilization anomaly: resources at extreme utilization ──
const resources = await ctx.db.resource.findMany({
where: { isActive: true },
select: {
id: true,
displayName: true,
availability: true,
},
});
// Get all active assignments for current period
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
const activeAssignments = await ctx.db.assignment.findMany({
where: {
status: { in: ["ACTIVE", "CONFIRMED"] },
startDate: { lte: periodEnd },
endDate: { gte: periodStart },
},
select: {
resourceId: true,
hoursPerDay: true,
},
});
// Build resource utilization map
const resourceHoursMap = new Map<string, number>();
for (const assignment of activeAssignments) {
const current = resourceHoursMap.get(assignment.resourceId) ?? 0;
resourceHoursMap.set(assignment.resourceId, current + assignment.hoursPerDay);
}
for (const resource of resources) {
const avail = resource.availability as Record<string, number> | null;
if (!avail) continue;
const dailyAvailHours = Object.values(avail).reduce((s, h) => s + (h ?? 0), 0) / 5;
if (dailyAvailHours <= 0) continue;
const bookedHours = resourceHoursMap.get(resource.id) ?? 0;
const utilizationPercent = Math.round((bookedHours / dailyAvailHours) * 100);
if (utilizationPercent > 110) {
anomalies.push({
type: "utilization",
severity: utilizationPercent > 130 ? "critical" : "warning",
entityId: resource.id,
entityName: resource.displayName,
message: `Resource at ${utilizationPercent}% utilization (${bookedHours.toFixed(1)}h/${dailyAvailHours.toFixed(1)}h per day).`,
});
} else if (utilizationPercent < 40 && utilizationPercent > 0) {
// Only flag under-utilization if resource has at least some bookings
// to avoid flagging bench resources
if (bookedHours > 0) {
anomalies.push({
type: "utilization",
severity: "warning",
entityId: resource.id,
entityName: resource.displayName,
message: `Resource at only ${utilizationPercent}% utilization (${bookedHours.toFixed(1)}h/${dailyAvailHours.toFixed(1)}h per day).`,
});
}
}
}
// Sort: critical first, then by type
anomalies.sort((a, b) => {
if (a.severity !== b.severity) return a.severity === "critical" ? -1 : 1;
return a.type.localeCompare(b.type);
});
return anomalies;
const snapshot = await buildInsightSnapshot(ctx.db as unknown as InsightsDbAccess);
return snapshot.anomalies;
}),
/**
* Dashboard-friendly summary: anomaly counts by category + total.
*/
getInsightsSummary: controllerProcedure.query(async ({ ctx }) => {
// Re-use the detectAnomalies logic inline (calling it directly would
// require the full context to be passed through — simpler to share code
// via the router caller pattern, but for now we duplicate the call).
const now = new Date();
const twoWeeksFromNow = new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000);
const projects = await ctx.db.project.findMany({
where: { status: { in: ["ACTIVE", "DRAFT"] } },
include: {
demandRequirements: {
select: {
headcount: true,
startDate: true,
endDate: true,
_count: { select: { assignments: true } },
},
},
assignments: {
select: {
resourceId: true,
startDate: true,
endDate: true,
hoursPerDay: true,
dailyCostCents: true,
status: true,
},
},
},
});
let budgetCount = 0;
let staffingCount = 0;
let timelineCount = 0;
let criticalCount = 0;
for (const project of projects) {
// Budget check
if (project.budgetCents > 0) {
const totalDays = countBusinessDays(project.startDate, project.endDate);
const elapsedDays = countBusinessDays(project.startDate, now < project.endDate ? now : project.endDate);
if (totalDays > 0 && elapsedDays > 0) {
const expectedBurnRate = elapsedDays / totalDays;
const totalCostCents = project.assignments.reduce((s, a) => {
const aStart = a.startDate < project.startDate ? project.startDate : a.startDate;
const aEnd = a.endDate > now ? now : a.endDate;
if (aEnd < aStart) return s;
return s + a.dailyCostCents * countBusinessDays(aStart, aEnd);
}, 0);
const actualBurnRate = totalCostCents / project.budgetCents;
if (actualBurnRate > expectedBurnRate * 1.2) {
budgetCount++;
if (actualBurnRate > expectedBurnRate * 1.5) criticalCount++;
}
}
}
// Staffing check
const upcomingDemands = project.demandRequirements.filter(
(d) => d.startDate <= twoWeeksFromNow && d.endDate >= now,
);
for (const demand of upcomingDemands) {
const unfillPct = demand.headcount > 0 ? (demand.headcount - demand._count.assignments) / demand.headcount : 0;
if (unfillPct > 0.3) {
staffingCount++;
if (unfillPct > 0.6) criticalCount++;
}
}
// Timeline check
const overruns = project.assignments.filter(
(a) => a.endDate > project.endDate && (a.status === "ACTIVE" || a.status === "CONFIRMED"),
);
if (overruns.length > 0) timelineCount++;
}
// Utilization check
const resources = await ctx.db.resource.findMany({
where: { isActive: true },
select: { id: true, availability: true },
});
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
const activeAssignments = await ctx.db.assignment.findMany({
where: {
status: { in: ["ACTIVE", "CONFIRMED"] },
startDate: { lte: periodEnd },
endDate: { gte: periodStart },
},
select: { resourceId: true, hoursPerDay: true },
});
const resourceHoursMap = new Map<string, number>();
for (const a of activeAssignments) {
resourceHoursMap.set(a.resourceId, (resourceHoursMap.get(a.resourceId) ?? 0) + a.hoursPerDay);
}
let utilizationCount = 0;
for (const resource of resources) {
const avail = resource.availability as Record<string, number> | null;
if (!avail) continue;
const dailyAvail = Object.values(avail).reduce((s, h) => s + (h ?? 0), 0) / 5;
if (dailyAvail <= 0) continue;
const booked = resourceHoursMap.get(resource.id) ?? 0;
const pct = Math.round((booked / dailyAvail) * 100);
if (pct > 110) {
utilizationCount++;
if (pct > 130) criticalCount++;
} else if (pct < 40 && booked > 0) {
utilizationCount++;
}
}
const total = budgetCount + staffingCount + timelineCount + utilizationCount;
return {
total,
criticalCount,
budget: budgetCount,
staffing: staffingCount,
timeline: timelineCount,
utilization: utilizationCount,
};
const snapshot = await buildInsightSnapshot(ctx.db as unknown as InsightsDbAccess);
return snapshot.summary;
}),
/**
+155
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { PermissionKey, parseTaskAction, resolvePermissions } from "@capakraken/shared";
import { findUniqueOrThrow } from "../db/helpers.js";
import { createTRPCRouter, managerProcedure, protectedProcedure } from "../trpc.js";
import {
@@ -12,6 +13,7 @@ import {
import { createNotification } from "../lib/create-notification.js";
import { resolveRecipients } from "../lib/notification-targeting.js";
import { sendEmail } from "../lib/email.js";
import { getTaskAction } from "../lib/task-actions.js";
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -260,6 +262,49 @@ export const notificationRouter = createTRPCRouter({
};
}),
/** Get one task/approval visible to the current user */
getTaskDetail: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const userId = await resolveUserId(ctx);
const task = await ctx.db.notification.findFirst({
where: {
id: input.id,
OR: [{ userId }, { assigneeId: userId }],
category: { in: ["TASK", "APPROVAL"] },
},
select: {
id: true,
title: true,
body: true,
type: true,
priority: true,
category: true,
taskStatus: true,
taskAction: true,
dueDate: true,
entityId: true,
entityType: true,
completedAt: true,
completedBy: true,
createdAt: true,
userId: true,
assigneeId: true,
sender: { select: { id: true, name: true, email: true } },
},
});
if (!task) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Task not found or you do not have permission",
});
}
return task;
}),
/** Update task status */
updateTaskStatus: protectedProcedure
.input(
@@ -312,6 +357,101 @@ export const notificationRouter = createTRPCRouter({
return updated;
}),
/** Execute the machine-readable action associated with a task */
executeTaskAction: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const userId = await resolveUserId(ctx);
const task = await ctx.db.notification.findFirst({
where: {
id: input.id,
OR: [{ userId }, { assigneeId: userId }],
category: { in: ["TASK", "APPROVAL"] },
},
select: {
id: true,
userId: true,
assigneeId: true,
taskAction: true,
taskStatus: true,
},
});
if (!task) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Task not found or you do not have permission",
});
}
if (!task.taskAction) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "This task has no executable action",
});
}
if (task.taskStatus === "DONE") {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "This task is already completed",
});
}
const parsed = parseTaskAction(task.taskAction);
if (!parsed) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Invalid taskAction format: ${task.taskAction}`,
});
}
const handler = getTaskAction(parsed.action);
if (!handler) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Unknown action: ${parsed.action}`,
});
}
const permissions = resolvePermissions(
ctx.dbUser.systemRole as import("@capakraken/shared").SystemRole,
ctx.dbUser.permissionOverrides as import("@capakraken/shared").PermissionOverrides | null,
ctx.roleDefaults ?? undefined,
);
if (handler.permission && !permissions.has(handler.permission as PermissionKey)) {
throw new TRPCError({
code: "FORBIDDEN",
message: `Permission denied: you need "${handler.permission}" to perform this action`,
});
}
const actionResult = await handler.execute(parsed.entityId, ctx.db, userId);
if (!actionResult.success) {
throw new TRPCError({
code: "BAD_REQUEST",
message: actionResult.message,
});
}
const completedTask = await ctx.db.notification.update({
where: { id: input.id },
data: {
taskStatus: "DONE",
completedAt: new Date(),
completedBy: userId,
},
});
emitTaskCompleted(task.userId, task.id);
if (task.assigneeId && task.assigneeId !== task.userId) {
emitTaskCompleted(task.assigneeId, task.id);
}
return {
task: completedTask,
actionResult,
};
}),
// ═══════════════════════════════════════════════════════════════════════════
// REMINDERS
// ═══════════════════════════════════════════════════════════════════════════
@@ -542,6 +682,21 @@ export const notificationRouter = createTRPCRouter({
});
}),
/** Get one broadcast with sender context */
getBroadcastById: managerProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
return findUniqueOrThrow(
ctx.db.notificationBroadcast.findUnique({
where: { id: input.id },
include: {
sender: { select: { id: true, name: true, email: true } },
},
}),
"Broadcast",
);
}),
// ═══════════════════════════════════════════════════════════════════════════
// TASK CREATION (Manager+)
// ═══════════════════════════════════════════════════════════════════════════
+92
View File
@@ -78,6 +78,98 @@ export const orgUnitRouter = createTRPCRouter({
return unit;
}),
resolveByIdentifier: protectedProcedure
.input(z.object({ identifier: z.string().trim().min(1) }))
.query(async ({ ctx, input }) => {
const identifier = input.identifier.trim();
const select = {
id: true,
name: true,
shortName: true,
level: true,
isActive: true,
} as const;
let unit = await ctx.db.orgUnit.findUnique({
where: { id: identifier },
select,
});
if (!unit) {
unit = await ctx.db.orgUnit.findFirst({
where: { name: { equals: identifier, mode: "insensitive" } },
select,
});
}
if (!unit) {
unit = await ctx.db.orgUnit.findFirst({
where: { shortName: { equals: identifier, mode: "insensitive" } },
select,
});
}
if (!unit) {
unit = await ctx.db.orgUnit.findFirst({
where: {
OR: [
{ name: { contains: identifier, mode: "insensitive" } },
{ shortName: { contains: identifier, mode: "insensitive" } },
],
},
select,
});
}
if (!unit) {
throw new TRPCError({ code: "NOT_FOUND", message: `Org unit not found: ${identifier}` });
}
return unit;
}),
getByIdentifier: protectedProcedure
.input(z.object({ identifier: z.string().trim().min(1) }))
.query(async ({ ctx, input }) => {
const identifier = input.identifier.trim();
let unit = await ctx.db.orgUnit.findUnique({
where: { id: identifier },
include: { _count: { select: { resources: true } } },
});
if (!unit) {
unit = await ctx.db.orgUnit.findFirst({
where: { name: { equals: identifier, mode: "insensitive" } },
include: { _count: { select: { resources: true } } },
});
}
if (!unit) {
unit = await ctx.db.orgUnit.findFirst({
where: { shortName: { equals: identifier, mode: "insensitive" } },
include: { _count: { select: { resources: true } } },
});
}
if (!unit) {
unit = await ctx.db.orgUnit.findFirst({
where: {
OR: [
{ name: { contains: identifier, mode: "insensitive" } },
{ shortName: { contains: identifier, mode: "insensitive" } },
],
},
include: { _count: { select: { resources: true } } },
});
}
if (!unit) {
throw new TRPCError({ code: "NOT_FOUND", message: `Org unit not found: ${identifier}` });
}
return unit;
}),
create: adminProcedure
.input(CreateOrgUnitSchema)
.mutation(async ({ ctx, input }) => {
+486 -11
View File
@@ -16,17 +16,481 @@ import { adminProcedure, controllerProcedure, createTRPCRouter, managerProcedure
import { createDalleClient, isDalleConfigured, loggedAiCall, parseAiError } from "../ai-client.js";
import { generateGeminiImage, isGeminiConfigured, parseGeminiError } from "../gemini-client.js";
import { invalidateDashboardCache } from "../lib/cache.js";
import { logger } from "../lib/logger.js";
import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
import { validateImageDataUrl } from "../lib/image-validation.js";
import type { TRPCContext } from "../trpc.js";
import {
calculateEffectiveBookedHours,
loadResourceDailyAvailabilityContexts,
} from "../lib/resource-capacity.js";
import { fmtEur } from "../lib/format-utils.js";
const MAX_COVER_SIZE = 4 * 1024 * 1024; // 4 MB base64 string length limit (client compresses before upload)
const PROJECT_SUMMARY_SELECT = {
id: true,
shortCode: true,
name: true,
status: true,
startDate: true,
endDate: true,
client: { select: { name: true } },
} as const;
const PROJECT_SUMMARY_DETAIL_SELECT = {
...PROJECT_SUMMARY_SELECT,
budgetCents: true,
winProbability: true,
_count: { select: { assignments: true, estimates: true } },
} as const;
const PROJECT_IDENTIFIER_SELECT = {
id: true,
shortCode: true,
name: true,
status: true,
startDate: true,
endDate: true,
} as const;
const PROJECT_DETAIL_SELECT = {
...PROJECT_IDENTIFIER_SELECT,
id: true,
shortCode: true,
name: true,
status: true,
orderType: true,
allocationType: true,
budgetCents: true,
winProbability: true,
startDate: true,
endDate: true,
responsiblePerson: true,
client: { select: { name: true } },
utilizationCategory: { select: { code: true, name: true } },
_count: { select: { assignments: true, estimates: true } },
} as const;
function runProjectBackgroundEffect(
effectName: string,
execute: () => unknown,
metadata: Record<string, unknown> = {},
): void {
void Promise.resolve()
.then(execute)
.catch((error) => {
logger.error(
{ err: error, effectName, ...metadata },
"Project background side effect failed",
);
});
}
function invalidateDashboardCacheInBackground(): void {
runProjectBackgroundEffect("invalidateDashboardCache", () => invalidateDashboardCache());
}
function dispatchProjectWebhookInBackground(
db: TRPCContext["db"],
event: string,
payload: Record<string, unknown>,
): void {
runProjectBackgroundEffect(
"dispatchWebhooks",
() => dispatchWebhooks(db, event, payload),
{ event },
);
}
function formatDate(value: Date | null): string | null {
return value ? value.toISOString().slice(0, 10) : null;
}
function mapProjectSummary(project: {
id: string;
shortCode: string;
name: string;
status: string;
startDate: Date | null;
endDate: Date | null;
client: { name: string } | null;
}) {
return {
id: project.id,
code: project.shortCode,
name: project.name,
status: project.status,
start: formatDate(project.startDate),
end: formatDate(project.endDate),
client: project.client?.name ?? null,
};
}
function mapProjectSummaryDetail(project: {
id: string;
shortCode: string;
name: string;
status: string;
budgetCents: number | null;
winProbability: number;
startDate: Date | null;
endDate: Date | null;
client: { name: string } | null;
_count: { assignments: number; estimates: number };
}) {
return {
id: project.id,
code: project.shortCode,
name: project.name,
status: project.status,
budget: project.budgetCents && project.budgetCents > 0 ? fmtEur(project.budgetCents) : "Not set",
winProbability: `${project.winProbability}%`,
start: formatDate(project.startDate),
end: formatDate(project.endDate),
client: project.client?.name ?? null,
assignmentCount: project._count.assignments,
estimateCount: project._count.estimates,
};
}
function mapProjectDetail(
project: {
id: string;
shortCode: string;
name: string;
status: string;
orderType: string;
allocationType: string;
budgetCents: number | null;
winProbability: number;
startDate: Date | null;
endDate: Date | null;
responsiblePerson: string | null;
client: { name: string } | null;
utilizationCategory: { code: string; name: string } | null;
_count: { assignments: number; estimates: number };
},
topAssignments: Array<{
resource: { displayName: string; eid: string };
role: string | null;
status: string;
hoursPerDay: number;
startDate: Date;
endDate: Date;
}>,
) {
return {
id: project.id,
code: project.shortCode,
name: project.name,
status: project.status,
orderType: project.orderType,
allocationType: project.allocationType,
budget: project.budgetCents && project.budgetCents > 0 ? fmtEur(project.budgetCents) : "Not set",
budgetCents: project.budgetCents,
winProbability: `${project.winProbability}%`,
start: formatDate(project.startDate),
end: formatDate(project.endDate),
responsible: project.responsiblePerson,
client: project.client?.name ?? null,
category: project.utilizationCategory?.name ?? null,
assignmentCount: project._count.assignments,
estimateCount: project._count.estimates,
topAllocations: topAssignments.map((assignment) => ({
resource: assignment.resource.displayName,
eid: assignment.resource.eid,
role: assignment.role ?? null,
status: assignment.status,
hoursPerDay: assignment.hoursPerDay,
start: formatDate(assignment.startDate),
end: formatDate(assignment.endDate),
})),
};
}
async function readProjectSummariesSnapshot(
ctx: Pick<TRPCContext, "db">,
input: {
search?: string | undefined;
status?: ProjectStatus | undefined;
limit: number;
},
) {
const buildWhere = (search: string | undefined) => ({
...(input.status ? { status: input.status } : {}),
...(search
? {
OR: [
{ name: { contains: search, mode: "insensitive" as const } },
{ shortCode: { contains: search, mode: "insensitive" as const } },
],
}
: {}),
});
let projects = await ctx.db.project.findMany({
where: buildWhere(input.search),
select: PROJECT_SUMMARY_SELECT,
take: input.limit,
orderBy: { name: "asc" },
});
if (projects.length === 0 && input.search) {
const words = input.search.split(/[\s,._\-/]+/).filter((word) => word.length >= 2);
if (words.length > 1) {
const candidates = await ctx.db.project.findMany({
where: {
...(input.status ? { status: input.status } : {}),
OR: words.flatMap((word) => ([
{ name: { contains: word, mode: "insensitive" as const } },
{ shortCode: { contains: word, mode: "insensitive" as const } },
])),
},
select: PROJECT_SUMMARY_SELECT,
take: input.limit * 2,
orderBy: { name: "asc" },
});
projects = candidates
.map((project) => {
const haystack = `${project.name} ${project.shortCode}`.toLowerCase();
const matchCount = words.filter((word) => haystack.includes(word.toLowerCase())).length;
return { project, matchCount };
})
.sort((left, right) => right.matchCount - left.matchCount)
.slice(0, input.limit)
.map((entry) => entry.project);
}
}
return {
items: projects,
exactMatch: input.search
? projects.some((project) =>
project.name.toLowerCase().includes(input.search!.toLowerCase())
|| project.shortCode.toLowerCase().includes(input.search!.toLowerCase()))
: true,
};
}
async function readProjectSummaryDetailsSnapshot(
ctx: Pick<TRPCContext, "db">,
input: {
search?: string | undefined;
status?: ProjectStatus | undefined;
limit: number;
},
) {
const buildWhere = (search: string | undefined) => ({
...(input.status ? { status: input.status } : {}),
...(search
? {
OR: [
{ name: { contains: search, mode: "insensitive" as const } },
{ shortCode: { contains: search, mode: "insensitive" as const } },
],
}
: {}),
});
let projects = await ctx.db.project.findMany({
where: buildWhere(input.search),
select: PROJECT_SUMMARY_DETAIL_SELECT,
take: input.limit,
orderBy: { name: "asc" },
});
if (projects.length === 0 && input.search) {
const words = input.search.split(/[\s,._\-/]+/).filter((word) => word.length >= 2);
if (words.length > 1) {
const candidates = await ctx.db.project.findMany({
where: {
...(input.status ? { status: input.status } : {}),
OR: words.flatMap((word) => ([
{ name: { contains: word, mode: "insensitive" as const } },
{ shortCode: { contains: word, mode: "insensitive" as const } },
])),
},
select: PROJECT_SUMMARY_DETAIL_SELECT,
take: input.limit * 2,
orderBy: { name: "asc" },
});
projects = candidates
.map((project) => {
const haystack = `${project.name} ${project.shortCode}`.toLowerCase();
const matchCount = words.filter((word) => haystack.includes(word.toLowerCase())).length;
return { project, matchCount };
})
.sort((left, right) => right.matchCount - left.matchCount)
.slice(0, input.limit)
.map((entry) => entry.project);
}
}
return {
items: projects,
exactMatch: input.search
? projects.some((project) =>
project.name.toLowerCase().includes(input.search!.toLowerCase())
|| project.shortCode.toLowerCase().includes(input.search!.toLowerCase()))
: true,
};
}
async function resolveProjectIdentifierSnapshot(
ctx: Pick<TRPCContext, "db">,
identifier: string,
) {
let project = await ctx.db.project.findUnique({
where: { id: identifier },
select: PROJECT_IDENTIFIER_SELECT,
});
if (!project) {
project = await ctx.db.project.findUnique({
where: { shortCode: identifier },
select: PROJECT_IDENTIFIER_SELECT,
});
}
if (!project) {
project = await ctx.db.project.findFirst({
where: { name: { equals: identifier, mode: "insensitive" } },
select: PROJECT_IDENTIFIER_SELECT,
});
}
if (!project) {
project = await ctx.db.project.findFirst({
where: { name: { contains: identifier, mode: "insensitive" } },
select: PROJECT_IDENTIFIER_SELECT,
});
}
if (!project) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
return project;
}
async function readProjectByIdentifierDetailSnapshot(
ctx: Pick<TRPCContext, "db">,
identifier: string,
) {
const projectIdentity = await resolveProjectIdentifierSnapshot(ctx, identifier);
const project = await ctx.db.project.findUnique({
where: { id: projectIdentity.id },
select: PROJECT_DETAIL_SELECT,
});
if (!project) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
const topAssignments = await ctx.db.assignment.findMany({
where: {
projectId: project.id,
status: { not: "CANCELLED" },
},
select: {
resource: { select: { displayName: true, eid: true } },
role: true,
status: true,
hoursPerDay: true,
startDate: true,
endDate: true,
},
take: 10,
orderBy: { startDate: "desc" },
});
return {
...project,
topAssignments,
};
}
export const projectRouter = createTRPCRouter({
list: protectedProcedure
resolveByIdentifier: protectedProcedure
.input(z.object({ identifier: z.string() }))
.query(async ({ ctx, input }) => {
const select = {
id: true,
shortCode: true,
name: true,
status: true,
responsiblePerson: true,
startDate: true,
endDate: true,
} as const;
let project = await ctx.db.project.findUnique({
where: { id: input.identifier },
select,
});
if (!project) {
project = await ctx.db.project.findUnique({
where: { shortCode: input.identifier },
select,
});
}
if (!project) {
project = await ctx.db.project.findFirst({
where: { name: { equals: input.identifier, mode: "insensitive" } },
select,
});
}
if (!project) {
project = await ctx.db.project.findFirst({
where: { name: { contains: input.identifier, mode: "insensitive" } },
select,
});
}
if (!project) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
return project;
}),
searchSummaries: protectedProcedure
.input(z.object({
search: z.string().optional(),
status: z.nativeEnum(ProjectStatus).optional(),
limit: z.number().int().min(1).max(50).default(20),
}))
.query(async ({ ctx, input }) => {
const { items, exactMatch } = await readProjectSummariesSnapshot(ctx, input);
const formatted = items.map(mapProjectSummary);
if (items.length > 0 && input.search && !exactMatch) {
return {
suggestions: formatted,
note: `No exact match for "${input.search}". These projects match some of the search terms:`,
};
}
return formatted;
}),
searchSummariesDetail: controllerProcedure
.input(z.object({
search: z.string().optional(),
status: z.nativeEnum(ProjectStatus).optional(),
limit: z.number().int().min(1).max(50).default(20),
}))
.query(async ({ ctx, input }) => {
const { items, exactMatch } = await readProjectSummaryDetailsSnapshot(ctx, input);
const formatted = items.map(mapProjectSummaryDetail);
if (items.length > 0 && input.search && !exactMatch) {
return {
suggestions: formatted,
note: `No exact match for "${input.search}". These projects match some of the search terms:`,
};
}
return formatted;
}),
list: controllerProcedure
.input(
PaginationInputSchema.extend({
status: z.nativeEnum(ProjectStatus).optional(),
@@ -90,7 +554,7 @@ export const projectRouter = createTRPCRouter({
};
}),
getById: protectedProcedure
getById: controllerProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const [project, planningRead] = await Promise.all([
@@ -113,7 +577,18 @@ export const projectRouter = createTRPCRouter({
};
}),
getShoringRatio: protectedProcedure
getByIdentifier: protectedProcedure
.input(z.object({ identifier: z.string() }))
.query(async ({ ctx, input }) => resolveProjectIdentifierSnapshot(ctx, input.identifier)),
getByIdentifierDetail: controllerProcedure
.input(z.object({ identifier: z.string() }))
.query(async ({ ctx, input }) => {
const project = await readProjectByIdentifierDetailSnapshot(ctx, input.identifier);
return mapProjectDetail(project, project.topAssignments);
}),
getShoringRatio: controllerProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
const project = await ctx.db.project.findUnique({
@@ -241,8 +716,8 @@ export const projectRouter = createTRPCRouter({
},
});
void invalidateDashboardCache();
void dispatchWebhooks(ctx.db, "project.created", {
invalidateDashboardCacheInBackground();
dispatchProjectWebhookInBackground(ctx.db, "project.created", {
id: project.id,
shortCode: project.shortCode,
name: project.name,
@@ -302,7 +777,7 @@ export const projectRouter = createTRPCRouter({
},
});
void invalidateDashboardCache();
invalidateDashboardCacheInBackground();
return updated;
}),
@@ -314,8 +789,8 @@ export const projectRouter = createTRPCRouter({
where: { id: input.id },
data: { status: input.status },
});
void invalidateDashboardCache();
void dispatchWebhooks(ctx.db, "project.status_changed", {
invalidateDashboardCacheInBackground();
dispatchProjectWebhookInBackground(ctx.db, "project.status_changed", {
id: result.id,
shortCode: result.shortCode,
name: result.name,
@@ -348,7 +823,7 @@ export const projectRouter = createTRPCRouter({
},
});
void invalidateDashboardCache();
invalidateDashboardCacheInBackground();
return { count: updated.length };
}),
@@ -454,7 +929,7 @@ export const projectRouter = createTRPCRouter({
});
});
void invalidateDashboardCache();
invalidateDashboardCacheInBackground();
return { id: input.id, name: project.name };
}),
@@ -494,7 +969,7 @@ export const projectRouter = createTRPCRouter({
});
});
void invalidateDashboardCache();
invalidateDashboardCacheInBackground();
return { count: projects.length };
}),
+239 -1
View File
@@ -11,6 +11,7 @@ import { findUniqueOrThrow } from "../db/helpers.js";
import { createTRPCRouter, controllerProcedure, managerProcedure } from "../trpc.js";
import { ROLE_BRIEF_SELECT } from "../db/selects.js";
import { createAuditEntry } from "../lib/audit.js";
import { fmtEur } from "../lib/format-utils.js";
const lineSelect = {
id: true,
@@ -30,6 +31,118 @@ const lineSelect = {
updatedAt: true,
} as const;
async function lookupBestRateMatch(
db: Pick<import("@capakraken/db").PrismaClient, "rateCard" | "role">,
input: {
clientId?: string | undefined;
chapter?: string | undefined;
managementLevelId?: string | undefined;
roleName?: string | undefined;
seniority?: string | undefined;
},
) {
const rateCardWhere: Prisma.RateCardWhereInput = { isActive: true };
if (input.clientId) {
rateCardWhere.OR = [
{ clientId: input.clientId },
{ clientId: null },
];
}
const rateCards = await db.rateCard.findMany({
where: rateCardWhere,
include: {
lines: {
select: {
id: true,
chapter: true,
seniority: true,
costRateCents: true,
billRateCents: true,
role: { select: { id: true, name: true } },
},
},
client: { select: { id: true, name: true } },
},
orderBy: [{ effectiveFrom: "desc" }],
});
if (rateCards.length === 0) {
return {
bestMatch: null,
alternatives: [],
totalCandidates: 0,
message: "No active rate cards found.",
};
}
let roleId: string | undefined;
if (input.roleName) {
const role = await db.role.findFirst({
where: { name: { contains: input.roleName, mode: "insensitive" } },
select: { id: true },
});
if (role) roleId = role.id;
}
const scoredLines: Array<{
rateCardName: string;
clientId: string | null;
clientName: string | null;
lineId: string;
chapter: string | null;
seniority: string | null;
roleName: string | null;
costRateCents: number;
billRateCents: number | null;
score: number;
}> = [];
for (const card of rateCards) {
for (const line of card.lines) {
let score = 0;
let mismatch = false;
if (roleId && line.role) {
if (line.role.id === roleId) score += 4;
else mismatch = true;
}
if (input.chapter && line.chapter) {
if (line.chapter.toLowerCase() === input.chapter.toLowerCase()) score += 2;
else mismatch = true;
}
if (input.seniority && line.seniority) {
if (line.seniority.toLowerCase() === input.seniority.toLowerCase()) score += 1;
else mismatch = true;
}
if (input.clientId && card.client?.id === input.clientId) score += 3;
if (!mismatch) {
scoredLines.push({
rateCardName: card.name,
clientId: card.client?.id ?? null,
clientName: card.client?.name ?? null,
lineId: line.id,
chapter: line.chapter,
seniority: line.seniority,
roleName: line.role?.name ?? null,
costRateCents: line.costRateCents,
billRateCents: line.billRateCents ?? null,
score,
});
}
}
}
scoredLines.sort((a, b) => b.score - a.score);
return {
bestMatch: scoredLines[0] ?? null,
alternatives: scoredLines.slice(1, 4),
totalCandidates: scoredLines.length,
};
}
export const rateCardRouter = createTRPCRouter({
list: controllerProcedure
.input(
@@ -92,6 +205,131 @@ export const rateCardRouter = createTRPCRouter({
return rateCard;
}),
lookupBestMatch: controllerProcedure
.input(z.object({
clientId: z.string().optional(),
chapter: z.string().optional(),
managementLevelId: z.string().optional(),
roleName: z.string().optional(),
seniority: z.string().optional(),
}))
.query(async ({ ctx, input }) => lookupBestRateMatch(ctx.db, input)),
resolveBestRate: controllerProcedure
.input(z.object({
resourceId: z.string().optional(),
roleName: z.string().optional(),
date: z.coerce.date().optional(),
}))
.query(async ({ ctx, input }) => {
const effectiveAt = input.date ?? new Date();
if (input.resourceId) {
const resource = await findUniqueOrThrow(
ctx.db.resource.findUnique({
where: { id: input.resourceId },
select: {
id: true,
displayName: true,
chapter: true,
areaRole: { select: { name: true } },
},
}),
"Resource",
);
const resolved = await lookupBestRateMatch(ctx.db, {
...(resource.chapter ? { chapter: resource.chapter } : {}),
...(resource.areaRole?.name ? { roleName: resource.areaRole.name } : {}),
});
if (resolved.bestMatch) {
return {
rateCard: resolved.bestMatch.rateCardName,
resource: resource.displayName,
rate: fmtEur(resolved.bestMatch.costRateCents),
rateCents: resolved.bestMatch.costRateCents,
matchedBy: resolved.bestMatch.roleName ? `role: ${resolved.bestMatch.roleName}` : "best_match",
};
}
}
if (input.roleName) {
const match = await lookupBestRateMatch(ctx.db, { roleName: input.roleName });
if (match.bestMatch) {
return {
rateCard: match.bestMatch.rateCardName,
rate: fmtEur(match.bestMatch.costRateCents),
rateCents: match.bestMatch.costRateCents,
matchedBy: match.bestMatch.roleName ? `role: ${match.bestMatch.roleName}` : "best_match",
alternatives: match.alternatives.map((alternative) => ({
rateCard: alternative.rateCardName,
role: alternative.roleName,
chapter: alternative.chapter,
seniority: alternative.seniority,
costRate: fmtEur(alternative.costRateCents),
billRate: alternative.billRateCents != null ? fmtEur(alternative.billRateCents) : null,
})),
};
}
if (match.totalCandidates === 0) {
return { error: "No matching rate card line found." };
}
}
const cards = await ctx.db.rateCard.findMany({
where: {
isActive: true,
OR: [
{ effectiveFrom: null },
{ effectiveFrom: { lte: effectiveAt } },
],
AND: [
{
OR: [
{ effectiveTo: null },
{ effectiveTo: { gte: effectiveAt } },
],
},
],
},
include: {
_count: { select: { lines: true } },
client: { select: { id: true, name: true, code: true } },
},
orderBy: [{ isActive: "desc" }, { effectiveFrom: "desc" }, { name: "asc" }],
});
const card = cards[0];
if (!card) {
return { error: "No active rate card found for the given date." };
}
const detail = await findUniqueOrThrow(
ctx.db.rateCard.findUnique({
where: { id: card.id },
include: {
client: { select: { id: true, name: true, code: true } },
lines: {
select: lineSelect,
orderBy: [{ chapter: "asc" }, { seniority: "asc" }, { createdAt: "asc" }],
},
},
}),
"Rate card",
);
return {
rateCard: detail.name,
lines: detail.lines.map((line) => ({
role: line.role?.name ?? null,
seniority: line.seniority,
chapter: line.chapter,
location: line.location,
costRate: fmtEur(line.costRateCents),
billRate: line.billRateCents != null ? fmtEur(line.billRateCents) : null,
})),
};
}),
create: managerProcedure
.input(CreateRateCardSchema)
.mutation(async ({ ctx, input }) => {
@@ -362,7 +600,7 @@ export const rateCardRouter = createTRPCRouter({
// ─── Rate resolution ───────────────────────────────────────────────────────
resolveRate: controllerProcedure
resolveRateLine: controllerProcedure
.input(z.object({
rateCardId: z.string(),
roleId: z.string().optional(),
+189 -24
View File
@@ -163,6 +163,7 @@ const ENTITY_MAP = {
} as const;
type EntityKey = keyof typeof ENTITY_MAP;
const PERIOD_MONTH_PATTERN = /^\d{4}-(0[1-9]|1[0-2])$/;
/** Allowlist of top-level scalar fields per entity that can be filtered/sorted on. */
const ALLOWED_SCALAR_FIELDS: Record<EntityKey, Set<string>> = {
@@ -190,6 +191,158 @@ function getValidScalarField(entity: EntityKey, field: string): string | null {
return null;
}
function getColumnDef(entity: EntityKey, columnKey: string): ColumnDef | undefined {
return COLUMN_MAP[entity].find((column) => column.key === columnKey);
}
function assertKnownColumns(entity: EntityKey, columns: string[]): void {
const invalidColumns = columns.filter((column) => !getColumnDef(entity, column));
if (invalidColumns.length > 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Unknown columns for ${entity}: ${invalidColumns.join(", ")}`,
});
}
}
function assertValidFilterField(entity: EntityKey, field: string): string {
if (entity === "resource_month") {
if (!getColumnDef(entity, field)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Unknown filter field for ${entity}: ${field}`,
});
}
return field;
}
const validField = getValidScalarField(entity, field);
if (!validField) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Unsupported filter field for ${entity}: ${field}`,
});
}
return validField;
}
function assertValidSortField(entity: EntityKey, field: string): void {
if (entity === "resource_month") {
if (!getColumnDef(entity, field)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Unknown sort field for ${entity}: ${field}`,
});
}
return;
}
if (!getValidScalarField(entity, field)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Unsupported sort field for ${entity}: ${field}`,
});
}
}
function assertValidGroupField(entity: EntityKey, field: string): void {
const knownField =
entity === "resource_month"
? getColumnDef(entity, field)?.key
: getValidScalarField(entity, field);
if (!knownField) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Unsupported group field for ${entity}: ${field}`,
});
}
}
function parseFilterValueOrThrow(def: ColumnDef, value: string): unknown {
if (def.dataType === "number") {
const parsed = Number(value);
if (Number.isNaN(parsed)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Invalid numeric filter value for ${def.key}: ${value}`,
});
}
return parsed;
}
if (def.dataType === "boolean") {
if (value !== "true" && value !== "false") {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Invalid boolean filter value for ${def.key}: ${value}`,
});
}
return value === "true";
}
if (def.dataType === "date") {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Invalid date filter value for ${def.key}: ${value}`,
});
}
return parsed;
}
return value;
}
function validateReportInput(input: ReportInput | z.infer<typeof ReportTemplateConfigSchema>): void {
assertKnownColumns(input.entity, input.columns);
if (input.periodMonth && !PERIOD_MONTH_PATTERN.test(input.periodMonth)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Invalid periodMonth: ${input.periodMonth}. Expected YYYY-MM with a month between 01 and 12.`,
});
}
if (input.entity !== "resource_month" && input.periodMonth) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "periodMonth is only supported for resource_month reports",
});
}
if (input.sortBy) {
assertValidSortField(input.entity, input.sortBy);
}
if (input.groupBy) {
assertValidGroupField(input.entity, input.groupBy);
}
for (const filter of input.filters) {
const field = assertValidFilterField(input.entity, filter.field);
const def = getColumnDef(input.entity, field);
if (!def) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Unknown filter field for ${input.entity}: ${filter.field}`,
});
}
if (filter.op === "contains" || filter.op === "in") {
if (def.dataType !== "string") {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Filter operator ${filter.op} is only supported for string fields like ${def.key}`,
});
}
continue;
}
void parseFilterValueOrThrow(def, filter.value);
}
}
/**
* Build a Prisma `select` object from the requested columns.
* Always includes `id`. For relation columns like "country.name",
@@ -254,24 +407,15 @@ function buildWhere(
const where: Record<string, unknown> = {};
for (const filter of filters) {
const field = getValidScalarField(entity, filter.field);
if (!field) continue;
const entityColumns = COLUMN_MAP[entity];
const colDef = entityColumns.find((c) => c.key === field);
const dataType = colDef?.dataType ?? "string";
// Parse value based on data type
let parsedValue: unknown = filter.value;
if (dataType === "number") {
parsedValue = Number(filter.value);
if (Number.isNaN(parsedValue as number)) continue;
} else if (dataType === "boolean") {
parsedValue = filter.value === "true";
} else if (dataType === "date") {
parsedValue = new Date(filter.value);
if (Number.isNaN((parsedValue as Date).getTime())) continue;
const field = assertValidFilterField(entity, filter.field);
const colDef = getColumnDef(entity, field);
if (!colDef) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Unknown filter field for ${entity}: ${filter.field}`,
});
}
const parsedValue = parseFilterValueOrThrow(colDef, filter.value);
switch (filter.op) {
case "eq":
@@ -293,14 +437,28 @@ function buildWhere(
where[field] = { lte: parsedValue };
break;
case "contains":
if (dataType === "string") {
where[field] = { contains: filter.value, mode: "insensitive" };
if (colDef.dataType !== "string") {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Filter operator contains is only supported for string fields like ${field}`,
});
}
where[field] = { contains: filter.value, mode: "insensitive" };
break;
case "in":
if (dataType === "string") {
where[field] = { in: filter.value.split(",").map((v) => v.trim()) };
if (colDef.dataType !== "string") {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Filter operator in is only supported for string fields like ${field}`,
});
}
where[field] = { in: filter.value.split(",").map((v) => v.trim()) };
break;
default:
throw new TRPCError({
code: "BAD_REQUEST",
message: `Unsupported filter operator: ${filter.op}`,
});
break;
}
}
@@ -355,7 +513,7 @@ const ReportInputSchema = z.object({
groupBy: z.string().optional(),
sortBy: z.string().optional(),
sortDir: z.enum(["asc", "desc"]).default("asc"),
periodMonth: z.string().regex(/^\d{4}-\d{2}$/).optional(),
periodMonth: z.string().regex(PERIOD_MONTH_PATTERN).optional(),
limit: z.number().int().min(1).max(5000).default(50),
offset: z.number().int().min(0).default(0),
});
@@ -440,6 +598,7 @@ export const reportRouter = createTRPCRouter({
config: ReportTemplateConfigSchema,
}))
.mutation(async ({ ctx, input }) => {
validateReportInput(input.config);
const reportTemplate = getReportTemplateDelegate(ctx.db);
const payload = input.config as unknown as Prisma.InputJsonValue;
const entity = toTemplateEntity(input.config.entity);
@@ -568,6 +727,8 @@ async function executeReportQuery(
db: any,
input: ReportInput,
): Promise<{ rows: Record<string, unknown>[]; columns: string[]; totalCount: number }> {
validateReportInput(input);
if (input.entity === "resource_month") {
return executeResourceMonthReport(db, input);
}
@@ -579,9 +740,13 @@ async function executeReportQuery(
let orderBy: Record<string, string> | undefined;
if (sortBy) {
const validField = getValidScalarField(entity, sortBy);
if (validField) {
orderBy = { [validField]: sortDir };
if (!validField) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Unsupported sort field for ${entity}: ${sortBy}`,
});
}
orderBy = { [validField]: sortDir };
}
const modelDelegate = getModelDelegate(db, entity);
File diff suppressed because it is too large Load Diff
+81
View File
@@ -80,6 +80,87 @@ export const roleRouter = createTRPCRouter({
return attachPlanningEntryCounts(ctx.db, roles);
}),
resolveByIdentifier: protectedProcedure
.input(z.object({ identifier: z.string().trim().min(1) }))
.query(async ({ ctx, input }) => {
const identifier = input.identifier.trim();
const select = {
id: true,
name: true,
color: true,
isActive: true,
} as const;
let role = await ctx.db.role.findUnique({
where: { id: identifier },
select,
});
if (!role) {
role = await ctx.db.role.findUnique({
where: { name: identifier },
select,
});
}
if (!role) {
role = await ctx.db.role.findFirst({
where: { name: { equals: identifier, mode: "insensitive" } },
select,
});
}
if (!role) {
role = await ctx.db.role.findFirst({
where: { name: { contains: identifier, mode: "insensitive" } },
select,
});
}
if (!role) {
throw new TRPCError({ code: "NOT_FOUND", message: "Role not found" });
}
return role;
}),
getByIdentifier: protectedProcedure
.input(z.object({ identifier: z.string() }))
.query(async ({ ctx, input }) => {
const select = {
id: true,
name: true,
description: true,
color: true,
isActive: true,
_count: { select: { resourceRoles: true } },
} as const;
let role = await ctx.db.role.findUnique({
where: { id: input.identifier },
select,
});
if (!role) {
role = await ctx.db.role.findUnique({
where: { name: input.identifier },
select,
});
}
if (!role) {
role = await ctx.db.role.findFirst({
where: { name: { equals: input.identifier, mode: "insensitive" } },
select,
});
}
if (!role) {
role = await ctx.db.role.findFirst({
where: { name: { contains: input.identifier, mode: "insensitive" } },
select,
});
}
if (!role) {
throw new TRPCError({ code: "NOT_FOUND", message: "Role not found" });
}
return attachSinglePlanningEntryCount(ctx.db, role);
}),
getById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
File diff suppressed because it is too large Load Diff
+565 -41
View File
@@ -8,10 +8,10 @@ import {
updateDemandRequirement,
updateAllocationEntry,
} from "@capakraken/application";
import { Prisma, VacationType } from "@capakraken/db";
import type { PrismaClient } from "@capakraken/db";
import { calculateAllocation, computeBudgetStatus, validateShift, DEFAULT_CALCULATION_RULES } from "@capakraken/engine";
import type { CalculationRule, AbsenceDay } from "@capakraken/shared";
import { VacationType } from "@capakraken/db";
import { AllocationStatus, PermissionKey, ShiftProjectSchema, UpdateAllocationHoursSchema } from "@capakraken/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
@@ -28,8 +28,10 @@ import {
} from "../sse/event-bus.js";
import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.js";
import { buildTimelineShiftPlan } from "./timeline-shift-planning.js";
import { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
import { controllerProcedure, createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
import { anonymizeResource, getAnonymizationDirectory } from "../lib/anonymization.js";
import { logger } from "../lib/logger.js";
import type { TRPCContext } from "../trpc.js";
type ShiftDbClient = Pick<
PrismaClient,
@@ -52,6 +54,20 @@ export type TimelineEntriesFilters = {
countryCodes?: string[] | undefined;
};
const TimelineWindowFiltersSchema = z.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
resourceIds: z.array(z.string()).optional(),
projectIds: z.array(z.string()).optional(),
clientIds: z.array(z.string()).optional(),
chapters: z.array(z.string()).optional(),
eids: z.array(z.string()).optional(),
countryCodes: z.array(z.string()).optional(),
});
type TimelineWindowFiltersInput = z.infer<typeof TimelineWindowFiltersSchema>;
type TimelineSelfServiceContext = Pick<TRPCContext, "db" | "dbUser">;
export function getAssignmentResourceIds(
readModel: ReturnType<typeof buildSplitAllocationReadModel>,
): string[] {
@@ -64,6 +80,215 @@ export function getAssignmentResourceIds(
];
}
function fmtDate(value: Date | null | undefined): string | null {
if (!value) {
return null;
}
return value.toISOString().slice(0, 10);
}
function createUtcDate(year: number, month: number, day: number): Date {
return new Date(Date.UTC(year, month, day, 0, 0, 0, 0));
}
function createTimelineDateRange(input: {
startDate?: string | undefined;
endDate?: string | undefined;
durationDays?: number | undefined;
}): { startDate: Date; endDate: Date } {
const now = new Date();
const startDate = input.startDate
? new Date(`${input.startDate}T00:00:00.000Z`)
: createUtcDate(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
if (Number.isNaN(startDate.getTime())) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Invalid startDate: ${input.startDate}`,
});
}
const endDate = input.endDate
? new Date(`${input.endDate}T00:00:00.000Z`)
: createUtcDate(
startDate.getUTCFullYear(),
startDate.getUTCMonth(),
startDate.getUTCDate() + Math.max((input.durationDays ?? 21) - 1, 0),
);
if (Number.isNaN(endDate.getTime())) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Invalid endDate: ${input.endDate}`,
});
}
if (endDate < startDate) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "endDate must be on or after startDate.",
});
}
return { startDate, endDate };
}
function normalizeStringList(values?: string[] | undefined): string[] | undefined {
const normalized = values
?.map((value) => value.trim())
.filter((value) => value.length > 0);
return normalized && normalized.length > 0 ? normalized : undefined;
}
function createTimelineFilters(input: {
resourceIds?: string[] | undefined;
projectIds?: string[] | undefined;
clientIds?: string[] | undefined;
chapters?: string[] | undefined;
eids?: string[] | undefined;
countryCodes?: string[] | undefined;
}): Omit<TimelineEntriesFilters, "startDate" | "endDate"> {
return {
resourceIds: normalizeStringList(input.resourceIds),
projectIds: normalizeStringList(input.projectIds),
clientIds: normalizeStringList(input.clientIds),
chapters: normalizeStringList(input.chapters),
eids: normalizeStringList(input.eids),
countryCodes: normalizeStringList(input.countryCodes),
};
}
function createEmptyTimelineEntriesView() {
return buildSplitAllocationReadModel({
demandRequirements: [],
assignments: [],
});
}
async function findOwnedTimelineResourceId(
ctx: TimelineSelfServiceContext,
): Promise<string | null> {
if (!ctx.dbUser?.id) {
return null;
}
if (!ctx.db.resource || typeof ctx.db.resource.findFirst !== "function") {
return null;
}
const resource = await ctx.db.resource.findFirst({
where: { userId: ctx.dbUser.id },
select: { id: true },
});
return resource?.id ?? null;
}
async function buildSelfServiceTimelineInput(
ctx: TimelineSelfServiceContext,
input: TimelineWindowFiltersInput,
): Promise<TimelineEntriesFilters | null> {
const ownedResourceId = await findOwnedTimelineResourceId(ctx);
if (!ownedResourceId) {
return null;
}
return {
startDate: input.startDate,
endDate: input.endDate,
resourceIds: [ownedResourceId],
projectIds: normalizeStringList(input.projectIds),
clientIds: normalizeStringList(input.clientIds),
};
}
function summarizeTimelineEntries(readModel: {
allocations: Array<{ projectId: string | null; resourceId: string | null }>;
demands: Array<{ projectId: string | null }>;
assignments: Array<{ projectId: string | null; resourceId: string | null }>;
}) {
const projectIds = new Set<string>();
const resourceIds = new Set<string>();
for (const entry of [...readModel.allocations, ...readModel.demands, ...readModel.assignments]) {
if (entry.projectId) {
projectIds.add(entry.projectId);
}
}
for (const assignment of [...readModel.allocations, ...readModel.assignments]) {
if (assignment.resourceId) {
resourceIds.add(assignment.resourceId);
}
}
return {
allocationCount: readModel.allocations.length,
demandCount: readModel.demands.length,
assignmentCount: readModel.assignments.length,
projectCount: projectIds.size,
resourceCount: resourceIds.size,
};
}
function formatHolidayOverlays(
overlays: Array<{
id: string;
resourceId: string;
startDate: Date;
endDate: Date;
note?: string | null;
scope?: string | null;
calendarName?: string | null;
sourceType?: string | null;
}>,
) {
return overlays.map((overlay) => ({
id: overlay.id,
resourceId: overlay.resourceId,
startDate: fmtDate(overlay.startDate),
endDate: fmtDate(overlay.endDate),
note: overlay.note ?? null,
scope: overlay.scope ?? null,
calendarName: overlay.calendarName ?? null,
sourceType: overlay.sourceType ?? null,
}));
}
function summarizeHolidayOverlays(
overlays: ReturnType<typeof formatHolidayOverlays>,
) {
const resourceIds = new Set<string>();
const byScope = new Map<string, number>();
for (const overlay of overlays) {
resourceIds.add(overlay.resourceId);
const scope = overlay.scope ?? "UNKNOWN";
byScope.set(scope, (byScope.get(scope) ?? 0) + 1);
}
return {
overlayCount: overlays.length,
holidayResourceCount: resourceIds.size,
byScope: [...byScope.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([scope, count]) => ({ scope, count })),
};
}
function rangesOverlap(
leftStart: Date,
leftEnd: Date,
rightStart: Date,
rightEnd: Date,
): boolean {
return leftStart <= rightEnd && rightStart <= leftEnd;
}
function toDate(value: Date | string): Date {
return value instanceof Date ? value : new Date(value);
}
export async function loadTimelineEntriesReadModel(
db: TimelineEntriesDbClient,
input: TimelineEntriesFilters,
@@ -147,6 +372,14 @@ export async function loadTimelineHolidayOverlays(
input: TimelineEntriesFilters,
) {
const readModel = await loadTimelineEntriesReadModel(db, input);
return loadTimelineHolidayOverlaysForReadModel(db, input, readModel);
}
async function loadTimelineHolidayOverlaysForReadModel(
db: TimelineEntriesDbClient,
input: TimelineEntriesFilters,
readModel: ReturnType<typeof buildSplitAllocationReadModel>,
) {
const resourceIds = [...new Set(
readModel.assignments
.map((assignment) => assignment.resourceId)
@@ -380,17 +613,56 @@ function anonymizeResourceOnEntry<T extends { resource?: { id: string } | null }
}
/** Load active calculation rules from DB, falling back to defaults if none configured. */
function isMissingOptionalTableError(error: unknown, tableHints: string[]): boolean {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code !== "P2021") {
return false;
}
const table = typeof error.meta?.table === "string" ? error.meta.table.toLowerCase() : "";
const message = error.message.toLowerCase();
return tableHints.some((hint) => table.includes(hint) || message.includes(hint));
}
if (typeof error !== "object" || error === null || !("code" in error)) {
return false;
}
const candidate = error as {
code?: unknown;
message?: unknown;
meta?: { table?: unknown };
};
const code = typeof candidate.code === "string" ? candidate.code : "";
if (code !== "P2021") {
return false;
}
const table = typeof candidate.meta?.table === "string" ? candidate.meta.table.toLowerCase() : "";
const message = typeof candidate.message === "string" ? candidate.message.toLowerCase() : "";
return tableHints.some((hint) => table.includes(hint) || message.includes(hint));
}
async function loadCalculationRules(db: PrismaClient): Promise<CalculationRule[]> {
const calculationRuleModel = (db as PrismaClient & {
calculationRule?: { findMany?: (args: unknown) => Promise<unknown[]> };
}).calculationRule;
if (!calculationRuleModel || typeof calculationRuleModel.findMany !== "function") {
return DEFAULT_CALCULATION_RULES;
}
try {
const rules = await db.calculationRule.findMany({
const rules = await calculationRuleModel.findMany({
where: { isActive: true },
orderBy: [{ priority: "desc" }],
});
if (rules.length > 0) {
return rules as unknown as CalculationRule[];
}
} catch {
// table may not exist yet
} catch (error) {
if (!isMissingOptionalTableError(error, ["calculationrule", "calculation_rule", "calculation_rules"])) {
logger.error({ err: error }, "Failed to load active calculation rules for timeline");
throw error;
}
}
return DEFAULT_CALCULATION_RULES;
}
@@ -440,8 +712,14 @@ async function buildAbsenceDays(
cur.setDate(cur.getDate() + 1);
}
}
} catch {
// vacation table may not exist yet
} catch (error) {
if (!isMissingOptionalTableError(error, ["vacation", "vacations"])) {
logger.error(
{ err: error, resourceId, startDate, endDate },
"Failed to load timeline absence days",
);
throw error;
}
}
return { absenceDays, legacyVacationDates };
@@ -452,38 +730,16 @@ export const timelineRouter = createTRPCRouter({
* Get all timeline entries (projects + allocations) for a date range.
* Includes project startDate, endDate, staffingReqs for demand overlay.
*/
getEntries: protectedProcedure
.input(
z.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
resourceIds: z.array(z.string()).optional(),
projectIds: z.array(z.string()).optional(),
clientIds: z.array(z.string()).optional(),
chapters: z.array(z.string()).optional(),
eids: z.array(z.string()).optional(),
countryCodes: z.array(z.string()).optional(),
}),
)
getEntries: controllerProcedure
.input(TimelineWindowFiltersSchema)
.query(async ({ ctx, input }) => {
const readModel = await loadTimelineEntriesReadModel(ctx.db, input);
const directory = await getAnonymizationDirectory(ctx.db);
return readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory));
}),
getEntriesView: protectedProcedure
.input(
z.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
resourceIds: z.array(z.string()).optional(),
projectIds: z.array(z.string()).optional(),
clientIds: z.array(z.string()).optional(),
chapters: z.array(z.string()).optional(),
eids: z.array(z.string()).optional(),
countryCodes: z.array(z.string()).optional(),
}),
)
getEntriesView: controllerProcedure
.input(TimelineWindowFiltersSchema)
.query(async ({ ctx, input }) => {
const [readModel, directory] = await Promise.all([
loadTimelineEntriesReadModel(ctx.db, input),
@@ -497,11 +753,47 @@ export const timelineRouter = createTRPCRouter({
};
}),
getHolidayOverlays: protectedProcedure
getMyEntriesView: protectedProcedure
.input(TimelineWindowFiltersSchema)
.query(async ({ ctx, input }) => {
const selfServiceInput = await buildSelfServiceTimelineInput(ctx, input);
if (!selfServiceInput) {
return createEmptyTimelineEntriesView();
}
const [readModel, directory] = await Promise.all([
loadTimelineEntriesReadModel(ctx.db, selfServiceInput),
getAnonymizationDirectory(ctx.db),
]);
return {
...readModel,
allocations: readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory)),
assignments: readModel.assignments.map((assignment) => anonymizeResourceOnEntry(assignment, directory)),
};
}),
getHolidayOverlays: controllerProcedure
.input(TimelineWindowFiltersSchema)
.query(async ({ ctx, input }) => loadTimelineHolidayOverlays(ctx.db, input)),
getMyHolidayOverlays: protectedProcedure
.input(TimelineWindowFiltersSchema)
.query(async ({ ctx, input }) => {
const selfServiceInput = await buildSelfServiceTimelineInput(ctx, input);
if (!selfServiceInput) {
return [];
}
return loadTimelineHolidayOverlays(ctx.db, selfServiceInput);
}),
getEntriesDetail: controllerProcedure
.input(
z.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
startDate: z.string().optional(),
endDate: z.string().optional(),
durationDays: z.number().int().min(1).max(366).optional(),
resourceIds: z.array(z.string()).optional(),
projectIds: z.array(z.string()).optional(),
clientIds: z.array(z.string()).optional(),
@@ -510,7 +802,73 @@ export const timelineRouter = createTRPCRouter({
countryCodes: z.array(z.string()).optional(),
}),
)
.query(async ({ ctx, input }) => loadTimelineHolidayOverlays(ctx.db, input)),
.query(async ({ ctx, input }) => {
const { startDate, endDate } = createTimelineDateRange(input);
const filters = createTimelineFilters(input);
const timelineInput = { ...filters, startDate, endDate };
const [readModel, directory] = await Promise.all([
loadTimelineEntriesReadModel(ctx.db, timelineInput),
getAnonymizationDirectory(ctx.db),
]);
const holidayOverlays = await loadTimelineHolidayOverlaysForReadModel(
ctx.db,
timelineInput,
readModel,
);
const formattedHolidayOverlays = formatHolidayOverlays(holidayOverlays);
return {
period: {
startDate: fmtDate(startDate),
endDate: fmtDate(endDate),
},
filters,
summary: {
...summarizeTimelineEntries(readModel),
...summarizeHolidayOverlays(formattedHolidayOverlays),
},
allocations: readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory)),
demands: readModel.demands,
assignments: readModel.assignments.map((assignment) => anonymizeResourceOnEntry(assignment, directory)),
holidayOverlays: formattedHolidayOverlays,
};
}),
getHolidayOverlayDetail: controllerProcedure
.input(
z.object({
startDate: z.string().optional(),
endDate: z.string().optional(),
durationDays: z.number().int().min(1).max(366).optional(),
resourceIds: z.array(z.string()).optional(),
projectIds: z.array(z.string()).optional(),
clientIds: z.array(z.string()).optional(),
chapters: z.array(z.string()).optional(),
eids: z.array(z.string()).optional(),
countryCodes: z.array(z.string()).optional(),
}),
)
.query(async ({ ctx, input }) => {
const { startDate, endDate } = createTimelineDateRange(input);
const filters = createTimelineFilters(input);
const holidayOverlays = await loadTimelineHolidayOverlays(ctx.db, {
...filters,
startDate,
endDate,
});
const formattedOverlays = formatHolidayOverlays(holidayOverlays);
return {
period: {
startDate: fmtDate(startDate),
endDate: fmtDate(endDate),
},
filters,
summary: summarizeHolidayOverlays(formattedOverlays),
overlays: formattedOverlays,
};
}),
/**
* Get full project context for a project:
@@ -519,7 +877,7 @@ export const timelineRouter = createTRPCRouter({
* - all assignment bookings for the same resources (for cross-project overlap display)
* Used when: drag starts or project panel opens.
*/
getProjectContext: protectedProcedure
getProjectContext: controllerProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
const {
@@ -548,6 +906,122 @@ export const timelineRouter = createTRPCRouter({
};
}),
getProjectContextDetail: controllerProcedure
.input(
z.object({
projectId: z.string(),
startDate: z.string().optional(),
endDate: z.string().optional(),
durationDays: z.number().int().min(1).max(366).optional(),
}),
)
.query(async ({ ctx, input }) => {
const projectContext = await loadTimelineProjectContext(ctx.db, input.projectId);
const directory = await getAnonymizationDirectory(ctx.db);
const derivedStartDate = input.startDate
? createTimelineDateRange({ startDate: input.startDate, durationDays: 1 }).startDate
: projectContext.project.startDate
?? projectContext.assignments[0]?.startDate
?? projectContext.demands[0]?.startDate
?? createTimelineDateRange({ durationDays: 1 }).startDate;
const derivedEndDate = input.endDate
? createTimelineDateRange({ startDate: fmtDate(derivedStartDate) ?? undefined, endDate: input.endDate }).endDate
: projectContext.project.endDate
?? createTimelineDateRange({
startDate: fmtDate(derivedStartDate) ?? undefined,
durationDays: input.durationDays ?? 21,
}).endDate;
if (derivedEndDate < derivedStartDate) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "endDate must be on or after startDate.",
});
}
const holidayOverlays = projectContext.resourceIds.length > 0
? await loadTimelineHolidayOverlays(ctx.db, {
startDate: derivedStartDate,
endDate: derivedEndDate,
resourceIds: projectContext.resourceIds,
projectIds: [input.projectId],
})
: [];
const formattedHolidayOverlays = formatHolidayOverlays(holidayOverlays);
const assignmentConflicts = projectContext.assignments
.filter((assignment) => assignment.resourceId && assignment.resource)
.map((assignment) => {
const overlaps = projectContext.allResourceAllocations
.filter((booking) => (
booking.resourceId === assignment.resourceId
&& booking.id !== assignment.id
&& rangesOverlap(
toDate(booking.startDate),
toDate(booking.endDate),
toDate(assignment.startDate),
toDate(assignment.endDate),
)
))
.map((booking) => ({
id: booking.id,
projectId: booking.projectId,
projectName: booking.project?.name ?? null,
projectShortCode: booking.project?.shortCode ?? null,
startDate: fmtDate(toDate(booking.startDate)),
endDate: fmtDate(toDate(booking.endDate)),
hoursPerDay: booking.hoursPerDay,
status: booking.status,
sameProject: booking.projectId === input.projectId,
}));
return {
assignmentId: assignment.id,
resourceId: assignment.resourceId!,
resourceName: assignment.resource?.displayName ?? null,
startDate: fmtDate(toDate(assignment.startDate)),
endDate: fmtDate(toDate(assignment.endDate)),
hoursPerDay: assignment.hoursPerDay,
overlapCount: overlaps.length,
crossProjectOverlapCount: overlaps.filter((booking) => !booking.sameProject).length,
overlaps,
};
});
return {
project: projectContext.project,
period: {
startDate: fmtDate(derivedStartDate),
endDate: fmtDate(derivedEndDate),
},
summary: {
...summarizeTimelineEntries({
allocations: projectContext.allocations,
demands: projectContext.demands,
assignments: projectContext.assignments,
}),
resourceIds: projectContext.resourceIds.length,
allResourceAllocationCount: projectContext.allResourceAllocations.length,
conflictedAssignmentCount: assignmentConflicts.filter((item) => item.crossProjectOverlapCount > 0).length,
...summarizeHolidayOverlays(formattedHolidayOverlays),
},
allocations: projectContext.allocations.map((allocation) =>
anonymizeResourceOnEntry(allocation, directory),
),
demands: projectContext.demands,
assignments: projectContext.assignments.map((assignment) =>
anonymizeResourceOnEntry(assignment, directory),
),
allResourceAllocations: projectContext.allResourceAllocations.map((allocation) =>
anonymizeResourceOnEntry(allocation, directory),
),
assignmentConflicts,
holidayOverlays: formattedHolidayOverlays,
resourceIds: projectContext.resourceIds,
};
}),
/**
* Inline update of an allocation's hours, dates, includeSaturday, or role.
* Recalculates dailyCostCents and emits SSE.
@@ -682,10 +1156,50 @@ export const timelineRouter = createTRPCRouter({
* Preview a project shift validate without committing.
* Returns cost impact, conflicts, warnings.
*/
previewShift: protectedProcedure
previewShift: controllerProcedure
.input(ShiftProjectSchema)
.query(async ({ ctx, input }) => previewTimelineProjectShift(ctx.db, input)),
getShiftPreviewDetail: controllerProcedure
.input(ShiftProjectSchema)
.query(async ({ ctx, input }) => {
const [project, preview] = await Promise.all([
findUniqueOrThrow(
ctx.db.project.findUnique({
where: { id: input.projectId },
select: {
id: true,
name: true,
shortCode: true,
status: true,
responsiblePerson: true,
startDate: true,
endDate: true,
},
}),
"Project",
),
previewTimelineProjectShift(ctx.db, input),
]);
return {
project: {
id: project.id,
name: project.name,
shortCode: project.shortCode,
status: project.status,
responsiblePerson: project.responsiblePerson,
startDate: fmtDate(project.startDate),
endDate: fmtDate(project.endDate),
},
requestedShift: {
newStartDate: fmtDate(input.newStartDate),
newEndDate: fmtDate(input.newEndDate),
},
preview,
};
}),
/**
* Apply a project shift validate, then commit all allocation date changes.
* Reads includeSaturday from each allocation's metadata.
@@ -1044,7 +1558,7 @@ export const timelineRouter = createTRPCRouter({
/**
* Get budget status for a project.
*/
getBudgetStatus: protectedProcedure
getBudgetStatus: controllerProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
const project = await findUniqueOrThrow(
@@ -1052,6 +1566,8 @@ export const timelineRouter = createTRPCRouter({
where: { id: input.projectId },
select: {
id: true,
name: true,
shortCode: true,
budgetCents: true,
winProbability: true,
startDate: true,
@@ -1066,7 +1582,7 @@ export const timelineRouter = createTRPCRouter({
projectIds: [project.id],
});
return computeBudgetStatus(
const budgetStatus = computeBudgetStatus(
project.budgetCents,
project.winProbability,
bookings.map((booking) => ({
@@ -1079,5 +1595,13 @@ export const timelineRouter = createTRPCRouter({
project.startDate,
project.endDate,
);
return {
...budgetStatus,
projectName: project.name,
projectCode: project.shortCode,
totalAllocations: bookings.length,
budgetCents: project.budgetCents,
};
}),
});
+231 -9
View File
@@ -15,14 +15,126 @@ import { createAuditEntry } from "../lib/audit.js";
import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.js";
import { countCalendarDaysInPeriod, countVacationChargeableDays } from "../lib/vacation-day-count.js";
import { loadResourceHolidayContext } from "../lib/resource-holiday-context.js";
import { logger } from "../lib/logger.js";
import type { TRPCContext } from "../trpc.js";
/** Types that consume from annual leave balance */
const BALANCE_TYPES = new Set<VacationType>([VacationType.ANNUAL, VacationType.OTHER]);
type VacationReadContext = Pick<TRPCContext, "db" | "dbUser">;
function canManageVacationReads(ctx: { dbUser: { systemRole: string } | null }): boolean {
const role = ctx.dbUser?.systemRole;
return role === "ADMIN" || role === "MANAGER";
}
function runVacationBackgroundEffect(
effectName: string,
execute: () => unknown,
metadata: Record<string, unknown> = {},
): void {
void Promise.resolve()
.then(execute)
.catch((error) => {
logger.error(
{ err: error, effectName, ...metadata },
"Vacation background side effect failed",
);
});
}
function notifyVacationStatusInBackground(
db: Parameters<Parameters<typeof protectedProcedure["query"]>[0]>[0]["ctx"]["db"],
vacationId: string,
resourceId: string,
newStatus: VacationStatus,
rejectionReason?: string | null,
): void {
runVacationBackgroundEffect(
"notifyVacationStatus",
() => notifyVacationStatus(db, vacationId, resourceId, newStatus, rejectionReason),
{ vacationId, resourceId, newStatus },
);
}
function dispatchVacationWebhookInBackground(
db: Parameters<Parameters<typeof protectedProcedure["query"]>[0]>[0]["ctx"]["db"],
event: string,
payload: Record<string, unknown>,
): void {
runVacationBackgroundEffect(
"dispatchWebhooks",
() => dispatchWebhooks(db, event, payload),
{ event },
);
}
async function findOwnedResourceId(
ctx: VacationReadContext,
): Promise<string | null> {
if (!ctx.dbUser?.id) {
return null;
}
if (!ctx.db.resource || typeof ctx.db.resource.findFirst !== "function") {
return null;
}
const resource = await ctx.db.resource.findFirst({
where: { userId: ctx.dbUser.id },
select: { id: true },
});
return resource?.id ?? null;
}
async function assertCanReadVacationResource(
ctx: VacationReadContext,
resourceId: string,
): Promise<void> {
if (canManageVacationReads(ctx)) {
return;
}
const ownedResourceId = await findOwnedResourceId(ctx);
if (!ownedResourceId || ownedResourceId !== resourceId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You can only view vacation data for your own resource",
});
}
}
function isSameUtcDay(left: Date, right: Date): boolean {
return left.toISOString().slice(0, 10) === right.toISOString().slice(0, 10);
}
function mapTeamOverlapDetail(params: {
resource: { displayName: string; chapter: string | null };
startDate: Date;
endDate: Date;
overlaps: Array<{
type: VacationType;
status: VacationStatus;
startDate: Date;
endDate: Date;
resource: { displayName: string };
}>;
}) {
return {
resource: params.resource.displayName,
chapter: params.resource.chapter,
period: `${params.startDate.toISOString().slice(0, 10)} to ${params.endDate.toISOString().slice(0, 10)}`,
overlappingVacations: params.overlaps.map((vacation) => ({
resource: vacation.resource.displayName,
type: vacation.type,
status: vacation.status,
start: vacation.startDate.toISOString().slice(0, 10),
end: vacation.endDate.toISOString().slice(0, 10),
})),
overlapCount: params.overlaps.length,
};
}
const PreviewVacationRequestSchema = z.object({
resourceId: z.string(),
type: z.nativeEnum(VacationType),
@@ -224,9 +336,25 @@ export const vacationRouter = createTRPCRouter({
}),
)
.query(async ({ ctx, input }) => {
let resourceIdFilter = input.resourceId;
if (!canManageVacationReads(ctx)) {
const ownedResourceId = await findOwnedResourceId(ctx);
if (input.resourceId && input.resourceId !== ownedResourceId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You can only view vacation data for your own resource",
});
}
if (!ownedResourceId) {
return [];
}
resourceIdFilter = ownedResourceId;
}
const vacations = await ctx.db.vacation.findMany({
where: {
...(input.resourceId ? { resourceId: input.resourceId } : {}),
...(resourceIdFilter ? { resourceId: resourceIdFilter } : {}),
...(input.status ? { status: Array.isArray(input.status) ? { in: input.status } : input.status } : {}),
...(input.type ? { type: input.type } : {}),
...(input.startDate ? { endDate: { gte: input.startDate } } : {}),
@@ -254,15 +382,38 @@ export const vacationRouter = createTRPCRouter({
ctx.db.vacation.findUnique({
where: { id: input.id },
include: {
resource: { select: RESOURCE_BRIEF_SELECT },
resource: { select: { ...RESOURCE_BRIEF_SELECT, userId: true } },
requestedBy: { select: { id: true, name: true, email: true } },
approvedBy: { select: { id: true, name: true, email: true } },
},
}),
"Vacation",
);
if (!canManageVacationReads(ctx)) {
const isOwnVacation = vacation.resource?.userId === ctx.dbUser?.id || vacation.requestedById === ctx.dbUser?.id;
if (!isOwnVacation) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You can only view your own vacation data",
});
}
}
const directory = await getAnonymizationDirectory(ctx.db);
return anonymizeVacationRecord(vacation, directory);
const anonymized = anonymizeVacationRecord(vacation, directory);
return {
...anonymized,
resource: anonymized.resource
? {
id: anonymized.resource.id,
displayName: anonymized.resource.displayName,
eid: anonymized.resource.eid,
lcrCents: anonymized.resource.lcrCents,
chapter: anonymized.resource.chapter,
}
: null,
};
}),
/**
@@ -475,7 +626,7 @@ export const vacationRouter = createTRPCRouter({
summary: `Approved vacation (was ${existing.status})`,
});
void dispatchWebhooks(ctx.db, "vacation.approved", {
dispatchVacationWebhookInBackground(ctx.db, "vacation.approved", {
id: updated.id,
resourceId: updated.resourceId,
startDate: updated.startDate.toISOString(),
@@ -497,7 +648,7 @@ export const vacationRouter = createTRPCRouter({
});
if (existing.status === VacationStatus.PENDING) {
void notifyVacationStatus(ctx.db, updated.id, updated.resourceId, VacationStatus.APPROVED);
notifyVacationStatusInBackground(ctx.db, updated.id, updated.resourceId, VacationStatus.APPROVED);
}
return { ...updated, warnings: conflictResult.warnings };
@@ -558,7 +709,13 @@ export const vacationRouter = createTRPCRouter({
summary: `Rejected vacation${input.rejectionReason ? `: ${input.rejectionReason}` : ""}`,
});
void notifyVacationStatus(ctx.db, updated.id, updated.resourceId, VacationStatus.REJECTED, input.rejectionReason);
notifyVacationStatusInBackground(
ctx.db,
updated.id,
updated.resourceId,
VacationStatus.REJECTED,
input.rejectionReason,
);
return updated;
}),
@@ -599,7 +756,7 @@ export const vacationRouter = createTRPCRouter({
for (const v of vacations) {
emitVacationUpdated({ id: v.id, resourceId: v.resourceId, status: VacationStatus.APPROVED });
void notifyVacationStatus(ctx.db, v.id, v.resourceId, VacationStatus.APPROVED);
notifyVacationStatusInBackground(ctx.db, v.id, v.resourceId, VacationStatus.APPROVED);
void createAuditEntry({
db: ctx.db,
@@ -668,7 +825,13 @@ export const vacationRouter = createTRPCRouter({
for (const v of vacations) {
emitVacationUpdated({ id: v.id, resourceId: v.resourceId, status: VacationStatus.REJECTED });
void notifyVacationStatus(ctx.db, v.id, v.resourceId, VacationStatus.REJECTED, input.rejectionReason);
notifyVacationStatusInBackground(
ctx.db,
v.id,
v.resourceId,
VacationStatus.REJECTED,
input.rejectionReason,
);
void createAuditEntry({
db: ctx.db,
@@ -773,6 +936,8 @@ export const vacationRouter = createTRPCRouter({
}),
)
.query(async ({ ctx, input }) => {
await assertCanReadVacationResource(ctx, input.resourceId);
return ctx.db.vacation.findMany({
where: {
resourceId: input.resourceId,
@@ -798,7 +963,7 @@ export const vacationRouter = createTRPCRouter({
return ctx.db.vacation.findMany({
where: { status: VacationStatus.PENDING },
include: {
resource: { select: RESOURCE_BRIEF_SELECT },
resource: { select: { ...RESOURCE_BRIEF_SELECT, chapter: true } },
requestedBy: { select: { id: true, name: true, email: true } },
},
orderBy: { startDate: "asc" },
@@ -818,6 +983,8 @@ export const vacationRouter = createTRPCRouter({
}),
)
.query(async ({ ctx, input }) => {
await assertCanReadVacationResource(ctx, input.resourceId);
// Find the chapter of the requesting resource
const resource = await ctx.db.resource.findUnique({
where: { id: input.resourceId },
@@ -842,6 +1009,61 @@ export const vacationRouter = createTRPCRouter({
});
}),
getTeamOverlapDetail: protectedProcedure
.input(
z.object({
resourceId: z.string(),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
}),
)
.query(async ({ ctx, input }) => {
await assertCanReadVacationResource(ctx, input.resourceId);
const resource = await ctx.db.resource.findUnique({
where: { id: input.resourceId },
select: { displayName: true, chapter: true },
});
if (!resource) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Resource not found",
});
}
if (!resource.chapter) {
return mapTeamOverlapDetail({
resource,
startDate: input.startDate,
endDate: input.endDate,
overlaps: [],
});
}
const overlaps = await ctx.db.vacation.findMany({
where: {
resource: { chapter: resource.chapter },
resourceId: { not: input.resourceId },
status: { in: [VacationStatus.APPROVED, VacationStatus.PENDING] },
startDate: { lte: input.endDate },
endDate: { gte: input.startDate },
},
include: {
resource: { select: RESOURCE_BRIEF_SELECT },
},
orderBy: { startDate: "asc" },
take: 20,
});
return mapTeamOverlapDetail({
resource,
startDate: input.startDate,
endDate: input.endDate,
overlaps,
});
}),
/**
* Batch-create public holidays for all resources (or a chapter) for a given year+state.
* Admin-only. Creates as APPROVED automatically.
+142 -51
View File
@@ -1,20 +1,34 @@
import { Redis } from "ioredis";
import { SSE_EVENT_TYPES, type SseEventType } from "@capakraken/shared";
import { PermissionKey, SSE_EVENT_TYPES, SystemRole, type SseEventType } from "@capakraken/shared";
export type SseAudience = string;
export interface SseEvent {
type: SseEventType;
payload: Record<string, unknown>;
timestamp: string;
audience: SseAudience[];
}
type Subscriber = (event: SseEvent) => void;
interface Subscription {
fn: Subscriber;
audiences: Set<SseAudience>;
includeUnscoped: boolean;
}
export interface SseSubscriptionOptions {
audiences?: Iterable<SseAudience>;
includeUnscoped?: boolean;
}
// Module-level subscriber registry (shared between EventBus and publishLocal)
const subscribers = new Set<Subscriber>();
const subscribers = new Set<Subscription>();
// ---------------------------------------------------------------------------
// Debounce buffer: aggregates rapid events of the same type within a 50ms
// window and delivers a single event per type to subscribers.
// Debounce buffer: aggregates rapid events of the same type and audience within
// a 50ms window and delivers a single event per scope to subscribers.
// ---------------------------------------------------------------------------
const DEBOUNCE_MS = 50;
@@ -23,48 +37,76 @@ interface BufferEntry {
payloads: Record<string, unknown>[];
timer: ReturnType<typeof setTimeout>;
firstTimestamp: string;
audience: SseAudience[];
}
const debounceBuffer = new Map<SseEventType, BufferEntry>();
const debounceBuffer = new Map<string, BufferEntry>();
function normalizeAudiences(audiences?: Iterable<SseAudience>): SseAudience[] {
return [...new Set(Array.from(audiences ?? [], (audience) => audience.trim()).filter(Boolean))].sort();
}
function getBufferKey(type: SseEventType, audience: readonly SseAudience[]): string {
return `${type}::${audience.length > 0 ? audience.join("|") : "__unscoped__"}`;
}
function matchesSubscription(event: SseEvent, subscription: Subscription): boolean {
if (event.audience.length === 0) {
return subscription.includeUnscoped;
}
return event.audience.some((audience) => subscription.audiences.has(audience));
}
function deliverEvent(event: SseEvent): void {
for (const subscription of subscribers) {
if (matchesSubscription(event, subscription)) {
subscription.fn(event);
}
}
}
export const userAudience = (userId: string): SseAudience => `user:${userId}`;
export const roleAudience = (role: string): SseAudience => `role:${role}`;
export const permissionAudience = (permission: string): SseAudience => `permission:${permission}`;
/** Flush a single event type from the buffer and deliver to subscribers. */
function flushEventType(type: SseEventType): void {
const entry = debounceBuffer.get(type);
function flushEventType(type: SseEventType, audience: readonly SseAudience[]): void {
const key = getBufferKey(type, audience);
const entry = debounceBuffer.get(key);
if (!entry) return;
debounceBuffer.delete(type);
debounceBuffer.delete(key);
const event: SseEvent =
entry.payloads.length === 1
? { type, payload: entry.payloads[0]!, timestamp: entry.firstTimestamp }
? { type, payload: entry.payloads[0]!, timestamp: entry.firstTimestamp, audience: entry.audience }
: {
type,
payload: { _batch: entry.payloads },
timestamp: entry.firstTimestamp,
audience: entry.audience,
};
for (const fn of subscribers) {
fn(event);
}
deliverEvent(event);
}
/** Flush all pending debounce timers immediately (for cleanup / tests). */
export function flushPendingEvents(): void {
for (const [type, entry] of debounceBuffer) {
for (const [key, entry] of debounceBuffer) {
clearTimeout(entry.timer);
debounceBuffer.delete(type);
debounceBuffer.delete(key);
const [type] = key.split("::") as [SseEventType];
const event: SseEvent =
entry.payloads.length === 1
? { type, payload: entry.payloads[0]!, timestamp: entry.firstTimestamp }
? { type, payload: entry.payloads[0]!, timestamp: entry.firstTimestamp, audience: entry.audience }
: {
type,
payload: { _batch: entry.payloads },
timestamp: entry.firstTimestamp,
audience: entry.audience,
};
for (const fn of subscribers) {
fn(event);
}
deliverEvent(event);
}
}
@@ -101,9 +143,21 @@ function setupSubscriber(): void {
});
subscriber.on("message", (_channel: string, message: string) => {
try {
const parsed = JSON.parse(message) as { type: SseEventType; payload: Record<string, unknown>; timestamp: string };
publishLocal({ type: parsed.type, payload: parsed.payload, timestamp: parsed.timestamp });
} catch { /* ignore parse errors */ }
const parsed = JSON.parse(message) as {
type: SseEventType;
payload: Record<string, unknown>;
timestamp: string;
audience?: SseAudience[];
};
publishLocal({
type: parsed.type,
payload: parsed.payload,
timestamp: parsed.timestamp,
audience: normalizeAudiences(parsed.audience),
});
} catch {
// ignore parse errors
}
});
} catch (e) {
console.warn("[Redis setupSubscriber] Redis unavailable, SSE will be local-only:", e);
@@ -115,28 +169,47 @@ function setupSubscriber(): void {
* Gracefully degrades to in-memory delivery when Redis is unavailable.
*/
class EventBus {
subscribe(fn: Subscriber): () => void {
subscribers.add(fn);
return () => subscribers.delete(fn);
subscribe(fn: Subscriber, options: SseSubscriptionOptions = {}): () => void {
const subscription: Subscription = {
fn,
audiences: new Set(normalizeAudiences(options.audiences)),
includeUnscoped: options.includeUnscoped ?? true,
};
subscribers.add(subscription);
return () => subscribers.delete(subscription);
}
publish(event: SseEvent): void {
const normalizedEvent: SseEvent = {
...event,
audience: normalizeAudiences(event.audience),
};
// Broadcast via Redis (all instances receive via subscriber.on("message"))
try {
const pub = getPublisher();
void pub.publish(CHANNEL, JSON.stringify({ type: event.type, payload: event.payload, timestamp: event.timestamp }));
void pub.publish(
CHANNEL,
JSON.stringify({
type: normalizedEvent.type,
payload: normalizedEvent.payload,
timestamp: normalizedEvent.timestamp,
audience: normalizedEvent.audience,
}),
);
} catch (e) {
console.warn("[Redis emit] fallback to local-only:", e);
// Deliver locally when Redis is unavailable
publishLocal(event);
publishLocal(normalizedEvent);
}
}
emit(type: SseEventType, payload: Record<string, unknown>): void {
emit(type: SseEventType, payload: Record<string, unknown>, audience: Iterable<SseAudience> = []): void {
this.publish({
type,
payload,
timestamp: new Date().toISOString(),
audience: normalizeAudiences(audience),
});
}
@@ -145,23 +218,26 @@ class EventBus {
}
}
// Local delivery with debounce: buffer events of the same type within a 50ms
// window and then deliver a single (possibly aggregated) event to subscribers.
// Local delivery with debounce: buffer events of the same type and audience
// within a 50ms window and then deliver a single (possibly aggregated) event.
function publishLocal(event: SseEvent): void {
const existing = debounceBuffer.get(event.type);
const audience = normalizeAudiences(event.audience);
const key = getBufferKey(event.type, audience);
const existing = debounceBuffer.get(key);
if (existing) {
// Another event of the same type is already buffered — append payload and
// reset the timer so the window starts fresh from the latest arrival.
existing.payloads.push(event.payload);
clearTimeout(existing.timer);
existing.timer = setTimeout(() => flushEventType(event.type), DEBOUNCE_MS);
existing.timer = setTimeout(() => flushEventType(event.type, audience), DEBOUNCE_MS);
} else {
// First event of this type — start a new debounce window.
debounceBuffer.set(event.type, {
// First event of this type and audience — start a new debounce window.
debounceBuffer.set(key, {
payloads: [event.payload],
timer: setTimeout(() => flushEventType(event.type), DEBOUNCE_MS),
timer: setTimeout(() => flushEventType(event.type, audience), DEBOUNCE_MS),
firstTimestamp: event.timestamp,
audience,
});
}
}
@@ -174,58 +250,73 @@ setupSubscriber();
// Helper emitters
export const emitAllocationCreated = (allocation: Record<string, unknown>) =>
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_CREATED, allocation);
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_CREATED, allocation, [permissionAudience(PermissionKey.MANAGE_ALLOCATIONS)]);
export const emitAllocationUpdated = (allocation: Record<string, unknown>) =>
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_UPDATED, allocation);
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_UPDATED, allocation, [permissionAudience(PermissionKey.MANAGE_ALLOCATIONS)]);
export const emitAllocationDeleted = (allocationId: string, projectId: string) =>
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_DELETED, { allocationId, projectId });
eventBus.emit(
SSE_EVENT_TYPES.ALLOCATION_DELETED,
{ allocationId, projectId },
[permissionAudience(PermissionKey.MANAGE_ALLOCATIONS)],
);
export const emitProjectShifted = (project: Record<string, unknown>) =>
eventBus.emit(SSE_EVENT_TYPES.PROJECT_SHIFTED, project);
eventBus.emit(SSE_EVENT_TYPES.PROJECT_SHIFTED, project, [permissionAudience(PermissionKey.MANAGE_ALLOCATIONS)]);
export const emitBudgetWarning = (projectId: string, payload: Record<string, unknown>) =>
eventBus.emit(SSE_EVENT_TYPES.BUDGET_WARNING, { projectId, ...payload });
eventBus.emit(
SSE_EVENT_TYPES.BUDGET_WARNING,
{ projectId, ...payload },
[permissionAudience(PermissionKey.MANAGE_ALLOCATIONS)],
);
export const emitVacationCreated = (vacation: Record<string, unknown>) =>
eventBus.emit(SSE_EVENT_TYPES.VACATION_CREATED, vacation);
eventBus.emit(SSE_EVENT_TYPES.VACATION_CREATED, vacation, [permissionAudience(PermissionKey.MANAGE_ALLOCATIONS)]);
export const emitVacationUpdated = (vacation: Record<string, unknown>) =>
eventBus.emit(SSE_EVENT_TYPES.VACATION_UPDATED, vacation);
eventBus.emit(SSE_EVENT_TYPES.VACATION_UPDATED, vacation, [permissionAudience(PermissionKey.MANAGE_ALLOCATIONS)]);
export const emitVacationDeleted = (vacationId: string, resourceId: string) =>
eventBus.emit(SSE_EVENT_TYPES.VACATION_DELETED, { vacationId, resourceId });
eventBus.emit(
SSE_EVENT_TYPES.VACATION_DELETED,
{ vacationId, resourceId },
[permissionAudience(PermissionKey.MANAGE_ALLOCATIONS)],
);
export const emitRoleCreated = (role: Record<string, unknown>) =>
eventBus.emit(SSE_EVENT_TYPES.ROLE_CREATED, role);
eventBus.emit(SSE_EVENT_TYPES.ROLE_CREATED, role, [permissionAudience(PermissionKey.MANAGE_ROLES)]);
export const emitRoleUpdated = (role: Record<string, unknown>) =>
eventBus.emit(SSE_EVENT_TYPES.ROLE_UPDATED, role);
eventBus.emit(SSE_EVENT_TYPES.ROLE_UPDATED, role, [permissionAudience(PermissionKey.MANAGE_ROLES)]);
export const emitRoleDeleted = (roleId: string) =>
eventBus.emit(SSE_EVENT_TYPES.ROLE_DELETED, { roleId });
eventBus.emit(SSE_EVENT_TYPES.ROLE_DELETED, { roleId }, [permissionAudience(PermissionKey.MANAGE_ROLES)]);
export function emitNotificationCreated(userId: string, notificationId: string): void {
eventBus.emit(SSE_EVENT_TYPES.NOTIFICATION_CREATED, { userId, notificationId });
eventBus.emit(SSE_EVENT_TYPES.NOTIFICATION_CREATED, { userId, notificationId }, [userAudience(userId)]);
}
export function emitTaskAssigned(userId: string, notificationId: string): void {
eventBus.emit(SSE_EVENT_TYPES.TASK_ASSIGNED, { userId, notificationId });
eventBus.emit(SSE_EVENT_TYPES.TASK_ASSIGNED, { userId, notificationId }, [userAudience(userId)]);
}
export function emitTaskCompleted(userId: string, notificationId: string): void {
eventBus.emit(SSE_EVENT_TYPES.TASK_COMPLETED, { userId, notificationId });
eventBus.emit(SSE_EVENT_TYPES.TASK_COMPLETED, { userId, notificationId }, [userAudience(userId)]);
}
export function emitTaskStatusChanged(userId: string, notificationId: string): void {
eventBus.emit(SSE_EVENT_TYPES.TASK_STATUS_CHANGED, { userId, notificationId });
eventBus.emit(SSE_EVENT_TYPES.TASK_STATUS_CHANGED, { userId, notificationId }, [userAudience(userId)]);
}
export function emitReminderDue(userId: string, notificationId: string): void {
eventBus.emit(SSE_EVENT_TYPES.REMINDER_DUE, { userId, notificationId });
eventBus.emit(SSE_EVENT_TYPES.REMINDER_DUE, { userId, notificationId }, [userAudience(userId)]);
}
export function emitBroadcastSent(broadcastId: string, recipientCount: number): void {
eventBus.emit(SSE_EVENT_TYPES.BROADCAST_SENT, { broadcastId, recipientCount });
eventBus.emit(SSE_EVENT_TYPES.BROADCAST_SENT, { broadcastId, recipientCount }, [
roleAudience(SystemRole.ADMIN),
roleAudience(SystemRole.MANAGER),
]);
}
@@ -70,6 +70,12 @@ describe("dashboard use-cases", () => {
},
]),
},
vacation: {
count: vi.fn().mockResolvedValue(2),
},
estimate: {
count: vi.fn().mockResolvedValue(5),
},
};
const result = await getDashboardOverview(db as never);
@@ -83,6 +89,8 @@ describe("dashboard use-cases", () => {
{ status: "ACTIVE", count: 1 },
{ status: "DRAFT", count: 1 },
]);
expect(result.approvedVacations).toBe(2);
expect(result.totalEstimates).toBe(5);
expect(result.chapterUtilization).toEqual([
{ chapter: "CGI", resourceCount: 2, avgChargeabilityTarget: 70 },
{ chapter: "Unassigned", resourceCount: 1, avgChargeabilityTarget: 0 },
@@ -134,6 +142,12 @@ describe("dashboard use-cases", () => {
auditLog: {
findMany: vi.fn().mockResolvedValue([]),
},
vacation: {
count: vi.fn().mockResolvedValue(0),
},
estimate: {
count: vi.fn().mockResolvedValue(0),
},
};
const result = await getDashboardOverview(db as never);
@@ -242,12 +256,20 @@ describe("dashboard use-cases", () => {
auditLog: {
findMany: vi.fn().mockResolvedValue([]),
},
vacation: {
count: vi.fn().mockResolvedValue(1),
},
estimate: {
count: vi.fn().mockResolvedValue(2),
},
};
const result = await getDashboardOverview(db as never);
expect(result.totalAllocations).toBe(3);
expect(result.activeAllocations).toBe(2);
expect(result.approvedVacations).toBe(1);
expect(result.totalEstimates).toBe(2);
expect(result.budgetSummary).toEqual({
totalBudgetCents: 100_000,
totalCostCents: 4_000,
@@ -954,6 +976,12 @@ describe("dashboard use-cases", () => {
auditLog: {
findMany: vi.fn().mockResolvedValue([]),
},
vacation: {
count: vi.fn().mockResolvedValue(1),
},
estimate: {
count: vi.fn().mockResolvedValue(4),
},
};
const result = await getDashboardOverview(db as never);
@@ -963,6 +991,8 @@ describe("dashboard use-cases", () => {
totalCostCents: 1_000,
avgUtilizationPercent: 10,
});
expect(result.approvedVacations).toBe(1);
expect(result.totalEstimates).toBe(4);
});
it("excludes regional public holidays from project health budget usage", async () => {
+2
View File
@@ -91,7 +91,9 @@ export {
type DemandRowDerivation,
type DashboardDemandRow,
getDashboardSkillGaps,
getDashboardSkillGapSummary,
type SkillGapRow,
type DashboardSkillGapSummary,
getDashboardProjectHealth,
type ProjectHealthRow,
} from "./use-cases/dashboard/index.js";
@@ -1,4 +1,5 @@
import type { PrismaClient } from "@capakraken/db";
import { VacationStatus } from "@capakraken/db";
import { AllocationStatus } from "@capakraken/shared";
import { buildSplitAllocationReadModel } from "../allocation/build-split-allocation-read-model.js";
import { calculateInclusiveDays } from "./shared.js";
@@ -25,6 +26,8 @@ export async function getDashboardOverview(db: PrismaClient) {
budgetAssignments,
recentActivity,
allResources,
approvedVacations,
totalEstimates,
] = await Promise.all([
db.resource.count(),
db.resource.count({ where: { isActive: true } }),
@@ -95,6 +98,8 @@ export async function getDashboardOverview(db: PrismaClient) {
db.resource.findMany({
select: { chapter: true, chargeabilityTarget: true },
}),
db.vacation.count({ where: { status: VacationStatus.APPROVED } }),
db.estimate.count(),
]);
const planningReadModel = buildSplitAllocationReadModel({
@@ -200,6 +205,8 @@ export async function getDashboardOverview(db: PrismaClient) {
totalAllocations,
activeAllocations,
cancelledAllocations,
approvedVacations,
totalEstimates,
budgetSummary: {
totalBudgetCents,
totalCostCents,
@@ -10,6 +10,7 @@ export interface ProjectHealthRow {
id: string;
projectName: string;
shortCode: string;
status: string;
clientId: string | null;
clientName: string | null;
budgetHealth: number;
@@ -74,6 +75,7 @@ export async function getDashboardProjectHealth(
id: true,
name: true,
shortCode: true,
status: true,
budgetCents: true,
endDate: true,
clientId: true,
@@ -232,6 +234,7 @@ export async function getDashboardProjectHealth(
id: p.id,
projectName: p.name,
shortCode: p.shortCode,
status: p.status,
clientId: p.clientId,
clientName: p.client?.name ?? null,
budgetHealth,
@@ -12,6 +12,31 @@ interface SkillEntry {
level?: number;
}
export interface SkillGapSummaryRoleGap {
role: string;
needed: number;
filled: number;
gap: number;
fillRate: number;
}
export interface SkillSupplySummaryRow {
skill: string;
resourceCount: number;
}
export interface ResourcesByRoleSummaryRow {
role: string;
count: number;
}
export interface DashboardSkillGapSummary {
roleGaps: SkillGapSummaryRoleGap[];
totalOpenPositions: number;
skillSupplyTop10: SkillSupplySummaryRow[];
resourcesByRole: ResourcesByRoleSummaryRow[];
}
export async function getDashboardSkillGaps(
db: PrismaClient,
): Promise<SkillGapRow[]> {
@@ -87,3 +112,86 @@ export async function getDashboardSkillGaps(
rows.sort((a, b) => a.gap - b.gap);
return rows.slice(0, 10);
}
export async function getDashboardSkillGapSummary(
db: PrismaClient,
): Promise<DashboardSkillGapSummary> {
const now = new Date();
const demands = await db.demandRequirement.findMany({
where: {
project: { status: { in: ["ACTIVE", "DRAFT"] } },
status: { not: "CANCELLED" },
endDate: { gte: now },
},
select: {
role: true,
headcount: true,
roleEntity: { select: { name: true } },
_count: { select: { assignments: true } },
},
});
const demandByRole = new Map<string, { needed: number; filled: number }>();
for (const demand of demands) {
const roleName = demand.roleEntity?.name ?? demand.role ?? "Unknown";
const existing = demandByRole.get(roleName) ?? { needed: 0, filled: 0 };
existing.needed += demand.headcount;
existing.filled += Math.min(demand._count.assignments, demand.headcount);
demandByRole.set(roleName, existing);
}
const resources = await db.resource.findMany({
where: { isActive: true },
select: {
skills: true,
areaRole: { select: { name: true } },
},
});
const skillSupply = new Map<string, number>();
const supplyByRole = new Map<string, number>();
for (const resource of resources) {
const rawSkills = Array.isArray(resource.skills)
? resource.skills as Array<Record<string, unknown>>
: [];
for (const entry of rawSkills) {
const skillName = typeof entry.skill === "string"
? entry.skill
: typeof entry.name === "string"
? entry.name
: null;
if (!skillName) continue;
skillSupply.set(skillName.toLowerCase(), (skillSupply.get(skillName.toLowerCase()) ?? 0) + 1);
}
const roleName = resource.areaRole?.name;
if (roleName) {
supplyByRole.set(roleName, (supplyByRole.get(roleName) ?? 0) + 1);
}
}
const roleGaps = [...demandByRole.entries()]
.map(([role, { needed, filled }]) => ({
role,
needed,
filled,
gap: needed - filled,
fillRate: needed > 0 ? Math.round((filled / needed) * 100) : 100,
}))
.filter((gap) => gap.gap > 0)
.sort((left, right) => right.gap - left.gap);
return {
roleGaps,
totalOpenPositions: roleGaps.reduce((sum, gap) => sum + gap.gap, 0),
skillSupplyTop10: [...skillSupply.entries()]
.sort((left, right) => right[1] - left[1])
.slice(0, 10)
.map(([skill, resourceCount]) => ({ skill, resourceCount })),
resourcesByRole: [...supplyByRole.entries()]
.sort((left, right) => right[1] - left[1])
.map(([role, count]) => ({ role, count })),
};
}
@@ -37,7 +37,9 @@ export {
export {
getDashboardSkillGaps,
getDashboardSkillGapSummary,
type SkillGapRow,
type DashboardSkillGapSummary,
} from "./get-skill-gaps.js";
export {
@@ -0,0 +1,8 @@
-- GIN indexes for JSONB dynamic field filtering
-- Run: psql $DATABASE_URL -f packages/db/prisma/migrations/20260310_jsonb_gin_indexes.sql
CREATE INDEX IF NOT EXISTS idx_resource_dynamic_fields_gin
ON resources USING gin ("dynamicFields" jsonb_path_ops);
CREATE INDEX IF NOT EXISTS idx_project_dynamic_fields_gin
ON projects USING gin ("dynamicFields" jsonb_path_ops);
@@ -0,0 +1,71 @@
-- Additive persistence split for planning demand vs assignment
-- Run: psql $DATABASE_URL -f packages/db/prisma/migrations/20260313_demand_assignment_additive.sql
CREATE TABLE IF NOT EXISTS demand_requirements (
id text PRIMARY KEY,
"legacyAllocationId" text UNIQUE,
"projectId" text NOT NULL REFERENCES projects(id),
"startDate" date NOT NULL,
"endDate" date NOT NULL,
"hoursPerDay" double precision NOT NULL,
percentage double precision NOT NULL,
role text,
"roleId" text REFERENCES roles(id),
headcount integer NOT NULL DEFAULT 1,
status "AllocationStatus" NOT NULL DEFAULT 'PROPOSED',
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
"createdAt" timestamptz NOT NULL DEFAULT now(),
"updatedAt" timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS assignments (
id text PRIMARY KEY,
"legacyAllocationId" text UNIQUE,
"demandRequirementId" text REFERENCES demand_requirements(id),
"resourceId" text NOT NULL REFERENCES resources(id),
"projectId" text NOT NULL REFERENCES projects(id),
"startDate" date NOT NULL,
"endDate" date NOT NULL,
"hoursPerDay" double precision NOT NULL,
percentage double precision NOT NULL,
role text,
"roleId" text REFERENCES roles(id),
"dailyCostCents" integer NOT NULL,
status "AllocationStatus" NOT NULL DEFAULT 'PROPOSED',
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
"createdAt" timestamptz NOT NULL DEFAULT now(),
"updatedAt" timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS unique_assignment
ON assignments ("resourceId", "projectId", "startDate", "endDate");
CREATE INDEX IF NOT EXISTS idx_demand_requirements_legacy_allocation_id
ON demand_requirements ("legacyAllocationId");
CREATE INDEX IF NOT EXISTS idx_demand_requirements_project_id
ON demand_requirements ("projectId");
CREATE INDEX IF NOT EXISTS idx_demand_requirements_start_end
ON demand_requirements ("startDate", "endDate");
CREATE INDEX IF NOT EXISTS idx_demand_requirements_status
ON demand_requirements (status);
CREATE INDEX IF NOT EXISTS idx_assignments_legacy_allocation_id
ON assignments ("legacyAllocationId");
CREATE INDEX IF NOT EXISTS idx_assignments_demand_requirement_id
ON assignments ("demandRequirementId");
CREATE INDEX IF NOT EXISTS idx_assignments_resource_id
ON assignments ("resourceId");
CREATE INDEX IF NOT EXISTS idx_assignments_project_id
ON assignments ("projectId");
CREATE INDEX IF NOT EXISTS idx_assignments_start_end
ON assignments ("startDate", "endDate");
CREATE INDEX IF NOT EXISTS idx_assignments_status
ON assignments (status);
@@ -0,0 +1 @@
ALTER TYPE "DispoImportSourceKind" ADD VALUE IF NOT EXISTS 'ROSTER';
@@ -0,0 +1,3 @@
ALTER TABLE "staged_resources"
ADD COLUMN IF NOT EXISTS "lcrCents" INTEGER,
ADD COLUMN IF NOT EXISTS "ucrCents" INTEGER;
@@ -0,0 +1,328 @@
-- Dispo import staging foundation
-- Run: psql $DATABASE_URL -f packages/db/prisma/migrations/20260314_dispo_import_staging.sql
DO $$
BEGIN
CREATE TYPE "ImportBatchStatus" AS ENUM (
'DRAFT',
'STAGING',
'STAGED',
'REVIEW_READY',
'APPROVED',
'COMMITTING',
'COMMITTED',
'FAILED',
'CANCELLED'
);
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
DO $$
BEGIN
CREATE TYPE "StagedRecordStatus" AS ENUM (
'PARSED',
'NORMALIZED',
'UNRESOLVED',
'APPROVED',
'REJECTED',
'COMMITTED',
'FAILED'
);
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
DO $$
BEGIN
CREATE TYPE "DispoImportSourceKind" AS ENUM (
'REFERENCE',
'PLANNING',
'CHARGEABILITY'
);
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
DO $$
BEGIN
CREATE TYPE "DispoStagedRecordType" AS ENUM (
'RESOURCE',
'CLIENT',
'PROJECT',
'ASSIGNMENT',
'VACATION',
'AVAILABILITY_RULE',
'UNRESOLVED'
);
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
CREATE TABLE IF NOT EXISTS import_batches (
id text PRIMARY KEY,
"sourceSystem" text NOT NULL DEFAULT 'DISPO_V2',
status "ImportBatchStatus" NOT NULL DEFAULT 'DRAFT',
"referenceSourceFile" text,
"planningSourceFile" text,
"chargeabilitySourceFile" text,
notes text,
summary jsonb NOT NULL DEFAULT '{}'::jsonb,
"startedAt" timestamptz,
"stagedAt" timestamptz,
"approvedAt" timestamptz,
"committedAt" timestamptz,
"failedAt" timestamptz,
"createdAt" timestamptz NOT NULL DEFAULT now(),
"updatedAt" timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS staged_resources (
id text PRIMARY KEY,
"importBatchId" text NOT NULL REFERENCES import_batches(id) ON DELETE CASCADE,
status "StagedRecordStatus" NOT NULL DEFAULT 'PARSED',
"sourceKind" "DispoImportSourceKind" NOT NULL,
"sourceWorkbook" text NOT NULL,
"sourceSheet" text NOT NULL,
"sourceRow" integer NOT NULL,
"sourceColumn" text,
"canonicalExternalId" text NOT NULL,
"enterpriseId" text,
eid text,
"displayName" text,
email text,
chapter text,
"chapterCode" text,
"managementLevelGroupName" text,
"managementLevelName" text,
"countryCode" text,
"metroCityName" text,
"clientUnitName" text,
"resourceType" "ResourceType",
"chargeabilityTarget" double precision,
fte double precision,
availability jsonb,
"roleTokens" text[] NOT NULL DEFAULT ARRAY[]::text[],
warnings text[] NOT NULL DEFAULT ARRAY[]::text[],
"errorMessage" text,
"rawPayload" jsonb NOT NULL,
"normalizedData" jsonb NOT NULL DEFAULT '{}'::jsonb,
"createdAt" timestamptz NOT NULL DEFAULT now(),
"updatedAt" timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS staged_clients (
id text PRIMARY KEY,
"importBatchId" text NOT NULL REFERENCES import_batches(id) ON DELETE CASCADE,
status "StagedRecordStatus" NOT NULL DEFAULT 'PARSED',
"sourceKind" "DispoImportSourceKind" NOT NULL,
"sourceWorkbook" text NOT NULL,
"sourceSheet" text NOT NULL,
"sourceRow" integer NOT NULL,
"sourceColumn" text,
"clientCode" text,
"parentClientCode" text,
name text NOT NULL,
"sortOrder" integer,
"isActive" boolean NOT NULL DEFAULT true,
warnings text[] NOT NULL DEFAULT ARRAY[]::text[],
"errorMessage" text,
"rawPayload" jsonb NOT NULL,
"normalizedData" jsonb NOT NULL DEFAULT '{}'::jsonb,
"createdAt" timestamptz NOT NULL DEFAULT now(),
"updatedAt" timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS staged_projects (
id text PRIMARY KEY,
"importBatchId" text NOT NULL REFERENCES import_batches(id) ON DELETE CASCADE,
status "StagedRecordStatus" NOT NULL DEFAULT 'PARSED',
"sourceKind" "DispoImportSourceKind" NOT NULL,
"sourceWorkbook" text NOT NULL,
"sourceSheet" text NOT NULL,
"sourceRow" integer NOT NULL,
"sourceColumn" text,
"projectKey" text NOT NULL,
"shortCode" text,
name text,
"clientCode" text,
"utilizationCategoryCode" text,
"orderType" "OrderType",
"allocationType" "AllocationType",
"winProbability" integer,
"isInternal" boolean NOT NULL DEFAULT false,
"isTbd" boolean NOT NULL DEFAULT false,
"startDate" date,
"endDate" date,
warnings text[] NOT NULL DEFAULT ARRAY[]::text[],
"errorMessage" text,
"rawPayload" jsonb NOT NULL,
"normalizedData" jsonb NOT NULL DEFAULT '{}'::jsonb,
"createdAt" timestamptz NOT NULL DEFAULT now(),
"updatedAt" timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS staged_assignments (
id text PRIMARY KEY,
"importBatchId" text NOT NULL REFERENCES import_batches(id) ON DELETE CASCADE,
status "StagedRecordStatus" NOT NULL DEFAULT 'PARSED',
"sourceKind" "DispoImportSourceKind" NOT NULL,
"sourceWorkbook" text NOT NULL,
"sourceSheet" text NOT NULL,
"sourceRow" integer NOT NULL,
"sourceColumn" text,
"resourceExternalId" text NOT NULL,
"projectKey" text,
"assignmentDate" date,
"startDate" date,
"endDate" date,
"hoursPerDay" double precision,
percentage double precision,
"slotFraction" double precision,
"roleToken" text,
"roleName" text,
"chapterToken" text,
"utilizationCategoryCode" text,
"winProbability" integer,
"isInternal" boolean NOT NULL DEFAULT false,
"isUnassigned" boolean NOT NULL DEFAULT false,
"isTbd" boolean NOT NULL DEFAULT false,
warnings text[] NOT NULL DEFAULT ARRAY[]::text[],
"errorMessage" text,
"rawPayload" jsonb NOT NULL,
"normalizedData" jsonb NOT NULL DEFAULT '{}'::jsonb,
"createdAt" timestamptz NOT NULL DEFAULT now(),
"updatedAt" timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS staged_vacations (
id text PRIMARY KEY,
"importBatchId" text NOT NULL REFERENCES import_batches(id) ON DELETE CASCADE,
status "StagedRecordStatus" NOT NULL DEFAULT 'PARSED',
"sourceKind" "DispoImportSourceKind" NOT NULL,
"sourceWorkbook" text NOT NULL,
"sourceSheet" text NOT NULL,
"sourceRow" integer NOT NULL,
"sourceColumn" text,
"resourceExternalId" text NOT NULL,
"vacationType" "VacationType" NOT NULL,
"startDate" date NOT NULL,
"endDate" date NOT NULL,
note text,
"holidayName" text,
"isHalfDay" boolean NOT NULL DEFAULT false,
"halfDayPart" text,
"isPublicHoliday" boolean NOT NULL DEFAULT false,
warnings text[] NOT NULL DEFAULT ARRAY[]::text[],
"errorMessage" text,
"rawPayload" jsonb NOT NULL,
"normalizedData" jsonb NOT NULL DEFAULT '{}'::jsonb,
"createdAt" timestamptz NOT NULL DEFAULT now(),
"updatedAt" timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS staged_availability_rules (
id text PRIMARY KEY,
"importBatchId" text NOT NULL REFERENCES import_batches(id) ON DELETE CASCADE,
status "StagedRecordStatus" NOT NULL DEFAULT 'PARSED',
"sourceKind" "DispoImportSourceKind" NOT NULL,
"sourceWorkbook" text NOT NULL,
"sourceSheet" text NOT NULL,
"sourceRow" integer NOT NULL,
"sourceColumn" text,
"resourceExternalId" text NOT NULL,
"ruleType" text NOT NULL,
weekday integer,
"effectiveStartDate" date,
"effectiveEndDate" date,
"availableHours" double precision,
percentage double precision,
"isResolved" boolean NOT NULL DEFAULT false,
warnings text[] NOT NULL DEFAULT ARRAY[]::text[],
"errorMessage" text,
"rawPayload" jsonb NOT NULL,
"normalizedData" jsonb NOT NULL DEFAULT '{}'::jsonb,
"createdAt" timestamptz NOT NULL DEFAULT now(),
"updatedAt" timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS staged_unresolved_records (
id text PRIMARY KEY,
"importBatchId" text NOT NULL REFERENCES import_batches(id) ON DELETE CASCADE,
status "StagedRecordStatus" NOT NULL DEFAULT 'UNRESOLVED',
"sourceKind" "DispoImportSourceKind" NOT NULL,
"sourceWorkbook" text NOT NULL,
"sourceSheet" text NOT NULL,
"sourceRow" integer NOT NULL,
"sourceColumn" text,
"recordType" "DispoStagedRecordType" NOT NULL,
"resourceExternalId" text,
"projectKey" text,
message text NOT NULL,
"resolutionHint" text,
warnings text[] NOT NULL DEFAULT ARRAY[]::text[],
"rawPayload" jsonb NOT NULL,
"normalizedData" jsonb NOT NULL DEFAULT '{}'::jsonb,
"createdAt" timestamptz NOT NULL DEFAULT now(),
"updatedAt" timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_import_batches_status
ON import_batches (status);
CREATE INDEX IF NOT EXISTS idx_staged_resources_batch_status
ON staged_resources ("importBatchId", status);
CREATE INDEX IF NOT EXISTS idx_staged_resources_canonical_external_id
ON staged_resources ("canonicalExternalId");
CREATE INDEX IF NOT EXISTS idx_staged_clients_batch_status
ON staged_clients ("importBatchId", status);
CREATE INDEX IF NOT EXISTS idx_staged_clients_client_code
ON staged_clients ("clientCode");
CREATE INDEX IF NOT EXISTS idx_staged_projects_batch_status
ON staged_projects ("importBatchId", status);
CREATE INDEX IF NOT EXISTS idx_staged_projects_project_key
ON staged_projects ("projectKey");
CREATE INDEX IF NOT EXISTS idx_staged_assignments_batch_status
ON staged_assignments ("importBatchId", status);
CREATE INDEX IF NOT EXISTS idx_staged_assignments_resource_external_id
ON staged_assignments ("resourceExternalId");
CREATE INDEX IF NOT EXISTS idx_staged_assignments_project_key
ON staged_assignments ("projectKey");
CREATE INDEX IF NOT EXISTS idx_staged_assignments_assignment_date
ON staged_assignments ("assignmentDate");
CREATE INDEX IF NOT EXISTS idx_staged_vacations_batch_status
ON staged_vacations ("importBatchId", status);
CREATE INDEX IF NOT EXISTS idx_staged_vacations_resource_external_id
ON staged_vacations ("resourceExternalId");
CREATE INDEX IF NOT EXISTS idx_staged_vacations_start_end
ON staged_vacations ("startDate", "endDate");
CREATE INDEX IF NOT EXISTS idx_staged_availability_rules_batch_status
ON staged_availability_rules ("importBatchId", status);
CREATE INDEX IF NOT EXISTS idx_staged_availability_rules_resource_external_id
ON staged_availability_rules ("resourceExternalId");
CREATE INDEX IF NOT EXISTS idx_staged_unresolved_records_batch_status
ON staged_unresolved_records ("importBatchId", status);
CREATE INDEX IF NOT EXISTS idx_staged_unresolved_records_record_type
ON staged_unresolved_records ("recordType");
CREATE INDEX IF NOT EXISTS idx_staged_unresolved_records_resource_external_id
ON staged_unresolved_records ("resourceExternalId");
CREATE INDEX IF NOT EXISTS idx_staged_unresolved_records_project_key
ON staged_unresolved_records ("projectKey");
@@ -0,0 +1,29 @@
CREATE TYPE "AssistantApprovalStatus" AS ENUM ('PENDING', 'APPROVED', 'CANCELLED', 'EXPIRED');
CREATE TABLE "assistant_approvals" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"conversationId" TEXT NOT NULL,
"toolName" TEXT NOT NULL,
"toolArguments" TEXT NOT NULL,
"summary" TEXT NOT NULL,
"status" "AssistantApprovalStatus" NOT NULL DEFAULT 'PENDING',
"approvedAt" TIMESTAMP(3),
"cancelledAt" TIMESTAMP(3),
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "assistant_approvals_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "assistant_approvals_userId_conversationId_status_expiresAt_idx"
ON "assistant_approvals"("userId", "conversationId", "status", "expiresAt");
CREATE INDEX "assistant_approvals_status_expiresAt_idx"
ON "assistant_approvals"("status", "expiresAt");
ALTER TABLE "assistant_approvals"
ADD CONSTRAINT "assistant_approvals_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "users"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,143 @@
BEGIN;
UPDATE "holiday_calendars"
SET "stateCode" = UPPER("stateCode")
WHERE "stateCode" IS NOT NULL;
UPDATE "holiday_calendars"
SET "stateCode" = NULL,
"metroCityId" = NULL
WHERE "scopeType" = 'COUNTRY';
UPDATE "holiday_calendars"
SET "metroCityId" = NULL
WHERE "scopeType" = 'STATE';
CREATE TEMP TABLE "tmp_holiday_calendar_merge_map" (
"duplicate_id" TEXT PRIMARY KEY,
"keeper_id" TEXT NOT NULL
) ON COMMIT DROP;
WITH ranked AS (
SELECT
"id",
FIRST_VALUE("id") OVER (
PARTITION BY "countryId"
ORDER BY "priority" DESC, "createdAt" DESC, "id" DESC
) AS "keeper_id",
ROW_NUMBER() OVER (
PARTITION BY "countryId"
ORDER BY "priority" DESC, "createdAt" DESC, "id" DESC
) AS "rn"
FROM "holiday_calendars"
WHERE "scopeType" = 'COUNTRY'
)
INSERT INTO "tmp_holiday_calendar_merge_map" ("duplicate_id", "keeper_id")
SELECT "id", "keeper_id"
FROM ranked
WHERE "rn" > 1;
WITH ranked AS (
SELECT
"id",
FIRST_VALUE("id") OVER (
PARTITION BY "countryId", "stateCode"
ORDER BY "priority" DESC, "createdAt" DESC, "id" DESC
) AS "keeper_id",
ROW_NUMBER() OVER (
PARTITION BY "countryId", "stateCode"
ORDER BY "priority" DESC, "createdAt" DESC, "id" DESC
) AS "rn"
FROM "holiday_calendars"
WHERE "scopeType" = 'STATE'
AND "stateCode" IS NOT NULL
)
INSERT INTO "tmp_holiday_calendar_merge_map" ("duplicate_id", "keeper_id")
SELECT "id", "keeper_id"
FROM ranked
WHERE "rn" > 1
ON CONFLICT ("duplicate_id") DO NOTHING;
WITH ranked AS (
SELECT
"id",
FIRST_VALUE("id") OVER (
PARTITION BY "countryId", "metroCityId"
ORDER BY "priority" DESC, "createdAt" DESC, "id" DESC
) AS "keeper_id",
ROW_NUMBER() OVER (
PARTITION BY "countryId", "metroCityId"
ORDER BY "priority" DESC, "createdAt" DESC, "id" DESC
) AS "rn"
FROM "holiday_calendars"
WHERE "scopeType" = 'CITY'
AND "metroCityId" IS NOT NULL
)
INSERT INTO "tmp_holiday_calendar_merge_map" ("duplicate_id", "keeper_id")
SELECT "id", "keeper_id"
FROM ranked
WHERE "rn" > 1
ON CONFLICT ("duplicate_id") DO NOTHING;
UPDATE "holiday_calendar_entries" AS "entry"
SET "holidayCalendarId" = "map"."keeper_id"
FROM "tmp_holiday_calendar_merge_map" AS "map"
WHERE "entry"."holidayCalendarId" = "map"."duplicate_id"
AND NOT EXISTS (
SELECT 1
FROM "holiday_calendar_entries" AS "existing"
WHERE "existing"."holidayCalendarId" = "map"."keeper_id"
AND "existing"."date" = "entry"."date"
);
WITH ranked_entries AS (
SELECT
"id",
ROW_NUMBER() OVER (
PARTITION BY "holidayCalendarId", "date"
ORDER BY "isRecurringAnnual" DESC, "updatedAt" DESC, "createdAt" DESC, "id" DESC
) AS "rn"
FROM "holiday_calendar_entries"
)
DELETE FROM "holiday_calendar_entries" AS "entry"
USING ranked_entries
WHERE "entry"."id" = ranked_entries."id"
AND ranked_entries."rn" > 1;
DELETE FROM "holiday_calendars" AS "calendar"
USING "tmp_holiday_calendar_merge_map" AS "map"
WHERE "calendar"."id" = "map"."duplicate_id";
DROP INDEX IF EXISTS "holiday_calendar_entries_holidayCalendarId_date_name_key";
DROP INDEX IF EXISTS "holiday_calendar_entries_holidayCalendarId_date_key";
CREATE UNIQUE INDEX "holiday_calendar_entries_holidayCalendarId_date_key"
ON "holiday_calendar_entries" ("holidayCalendarId", "date");
ALTER TABLE "holiday_calendars"
DROP CONSTRAINT IF EXISTS "holiday_calendars_scope_fields_check";
ALTER TABLE "holiday_calendars"
ADD CONSTRAINT "holiday_calendars_scope_fields_check"
CHECK (
("scopeType" = 'COUNTRY' AND "stateCode" IS NULL AND "metroCityId" IS NULL)
OR ("scopeType" = 'STATE' AND "stateCode" IS NOT NULL AND "metroCityId" IS NULL)
OR ("scopeType" = 'CITY' AND "metroCityId" IS NOT NULL)
);
DROP INDEX IF EXISTS "holiday_calendars_country_scope_unique";
DROP INDEX IF EXISTS "holiday_calendars_state_scope_unique";
DROP INDEX IF EXISTS "holiday_calendars_city_scope_unique";
CREATE UNIQUE INDEX "holiday_calendars_country_scope_unique"
ON "holiday_calendars" ("countryId")
WHERE "scopeType" = 'COUNTRY';
CREATE UNIQUE INDEX "holiday_calendars_state_scope_unique"
ON "holiday_calendars" ("countryId", "stateCode")
WHERE "scopeType" = 'STATE' AND "stateCode" IS NOT NULL;
CREATE UNIQUE INDEX "holiday_calendars_city_scope_unique"
ON "holiday_calendars" ("countryId", "metroCityId")
WHERE "scopeType" = 'CITY' AND "metroCityId" IS NOT NULL;
COMMIT;
@@ -0,0 +1,52 @@
CREATE TYPE "HolidayCalendarScope" AS ENUM ('COUNTRY', 'STATE', 'CITY');
CREATE TABLE "holiday_calendars" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"scopeType" "HolidayCalendarScope" NOT NULL,
"countryId" TEXT NOT NULL,
"stateCode" TEXT,
"metroCityId" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"priority" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "holiday_calendars_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "holiday_calendar_entries" (
"id" TEXT NOT NULL,
"holidayCalendarId" TEXT NOT NULL,
"date" DATE NOT NULL,
"name" TEXT NOT NULL,
"isRecurringAnnual" BOOLEAN NOT NULL DEFAULT false,
"source" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "holiday_calendar_entries_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "holiday_calendars_countryId_scopeType_idx" ON "holiday_calendars"("countryId", "scopeType");
CREATE INDEX "holiday_calendars_countryId_stateCode_idx" ON "holiday_calendars"("countryId", "stateCode");
CREATE INDEX "holiday_calendars_metroCityId_idx" ON "holiday_calendars"("metroCityId");
CREATE INDEX "holiday_calendar_entries_date_idx" ON "holiday_calendar_entries"("date");
CREATE UNIQUE INDEX "holiday_calendar_entries_holidayCalendarId_date_name_key"
ON "holiday_calendar_entries"("holidayCalendarId", "date", "name");
ALTER TABLE "holiday_calendars"
ADD CONSTRAINT "holiday_calendars_countryId_fkey"
FOREIGN KEY ("countryId") REFERENCES "countries"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "holiday_calendars"
ADD CONSTRAINT "holiday_calendars_metroCityId_fkey"
FOREIGN KEY ("metroCityId") REFERENCES "metro_cities"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "holiday_calendar_entries"
ADD CONSTRAINT "holiday_calendar_entries_holidayCalendarId_fkey"
FOREIGN KEY ("holidayCalendarId") REFERENCES "holiday_calendars"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,26 @@
CREATE TYPE "ReportTemplateEntity" AS ENUM ('RESOURCE', 'PROJECT', 'ASSIGNMENT', 'RESOURCE_MONTH');
CREATE TABLE "report_templates" (
"id" TEXT NOT NULL,
"ownerId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"entity" "ReportTemplateEntity" NOT NULL,
"config" JSONB NOT NULL,
"isShared" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "report_templates_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "report_templates_ownerId_updatedAt_idx"
ON "report_templates"("ownerId", "updatedAt");
CREATE UNIQUE INDEX "report_templates_ownerId_name_key"
ON "report_templates"("ownerId", "name");
ALTER TABLE "report_templates"
ADD CONSTRAINT "report_templates_ownerId_fkey"
FOREIGN KEY ("ownerId") REFERENCES "users"("id")
ON DELETE CASCADE ON UPDATE CASCADE;