feat(platform): harden access scoping and delivery baseline
This commit is contained in:
@@ -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,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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
+3322
-5125
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
]),
|
||||
);
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
@@ -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+)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
+1207
-75
File diff suppressed because it is too large
Load Diff
@@ -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 }) => {
|
||||
|
||||
+971
-290
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user