feat(platform): checkpoint current implementation state

This commit is contained in:
2026-04-01 07:42:03 +02:00
parent 3e53471f05
commit 8c5be51251
125 changed files with 10269 additions and 17808 deletions
@@ -27,6 +27,7 @@ const batches = [
"src/__tests__/assistant-tools-timeline-resource-selection.test.ts",
"src/__tests__/assistant-tools-advanced-resource-ranking.test.ts",
"src/__tests__/assistant-tools-advanced-timeline-entries-view.test.ts",
"src/__tests__/assistant-tools-advanced-timeline-holiday-overlays.test.ts",
"src/__tests__/assistant-tools-advanced-project-timeline-context.test.ts",
"src/__tests__/assistant-tools-advanced-project-shift-preview.test.ts",
],
@@ -81,6 +82,8 @@ const batches = [
"src/__tests__/assistant-tools-settings-role-config-admin.test.ts",
"src/__tests__/assistant-tools-import.test.ts",
"src/__tests__/assistant-tools-export.test.ts",
"src/__tests__/assistant-tools-export-projects.test.ts",
"src/__tests__/assistant-tools-holiday-resolution-errors.test.ts",
"src/__tests__/ai-client.test.ts",
],
},
@@ -1,631 +0,0 @@
import { describe, expect, it } from "vitest";
import { PermissionKey, SystemRole, type PermissionKey as PermissionKeyValue } from "@capakraken/shared";
import { getAvailableAssistantTools } from "../router/assistant-tool-policy.js";
import { TOOL_DEFINITIONS } from "../router/assistant-tools.js";
function getToolNames(
permissions: PermissionKeyValue[],
userRole: SystemRole = SystemRole.ADMIN,
) {
return getAvailableAssistantTools(new Set(permissions), userRole).map((tool) => tool.function.name);
}
describe("assistant tool policy", () => {
it("hides advanced tools unless the dedicated assistant permission is granted", () => {
const withoutAdvanced = getToolNames([
PermissionKey.VIEW_PLANNING,
PermissionKey.VIEW_COSTS,
]);
const withAdvanced = getToolNames([
PermissionKey.VIEW_PLANNING,
PermissionKey.VIEW_COSTS,
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
]);
expect(withoutAdvanced).not.toContain("find_best_project_resource");
expect(withAdvanced).toContain("find_best_project_resource");
expect(withAdvanced).toContain("get_chargeability_report");
expect(withAdvanced).toContain("get_resource_computation_graph");
expect(withAdvanced).toContain("get_project_computation_graph");
});
it("keeps user self-service tools available to plain authenticated users", () => {
const userNames = getToolNames([], SystemRole.USER);
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("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", () => {
const names = getToolNames([PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS]);
expect(names).not.toContain("find_best_project_resource");
expect(names).not.toContain("get_chargeability_report");
expect(names).not.toContain("get_resource_computation_graph");
expect(names).not.toContain("get_project_computation_graph");
});
it("keeps controller-grade readmodels hidden from plain users while allowing controller roles", () => {
const controllerNames = getToolNames([
PermissionKey.VIEW_COSTS,
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
], SystemRole.CONTROLLER);
const userNames = getToolNames([
PermissionKey.VIEW_COSTS,
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
], SystemRole.USER);
expect(controllerNames).toContain("query_change_history");
expect(controllerNames).toContain("get_entity_timeline");
expect(controllerNames).toContain("search_by_skill");
expect(controllerNames).toContain("export_resources_csv");
expect(controllerNames).toContain("export_projects_csv");
expect(controllerNames).toContain("list_audit_log_entries");
expect(controllerNames).toContain("get_audit_log_entry");
expect(controllerNames).toContain("get_audit_log_timeline");
expect(controllerNames).toContain("get_audit_activity_summary");
expect(controllerNames).toContain("get_chargeability_report");
expect(controllerNames).toContain("get_resource_computation_graph");
expect(controllerNames).toContain("get_project_computation_graph");
expect(userNames).not.toContain("query_change_history");
expect(userNames).not.toContain("get_entity_timeline");
expect(userNames).not.toContain("search_by_skill");
expect(userNames).not.toContain("export_resources_csv");
expect(userNames).not.toContain("export_projects_csv");
expect(userNames).not.toContain("list_audit_log_entries");
expect(userNames).not.toContain("get_audit_log_entry");
expect(userNames).not.toContain("get_audit_log_timeline");
expect(userNames).not.toContain("get_audit_activity_summary");
expect(userNames).not.toContain("get_chargeability_report");
expect(userNames).not.toContain("get_resource_computation_graph");
expect(userNames).not.toContain("get_project_computation_graph");
});
it("keeps entity-scoped comment tools available to plain authenticated users", () => {
const userNames = getToolNames([], SystemRole.USER);
expect(userNames).toContain("list_comments");
expect(userNames).toContain("create_comment");
expect(userNames).toContain("resolve_comment");
});
it("keeps planning read tools behind the explicit planning permission", () => {
const userWithoutPlanning = getToolNames([], SystemRole.USER);
const userWithPlanning = getToolNames([PermissionKey.VIEW_PLANNING], SystemRole.USER);
expect(userWithoutPlanning).not.toContain("list_allocations");
expect(userWithoutPlanning).not.toContain("list_demands");
expect(userWithoutPlanning).not.toContain("list_blueprints");
expect(userWithoutPlanning).not.toContain("get_blueprint");
expect(userWithoutPlanning).not.toContain("list_clients");
expect(userWithoutPlanning).not.toContain("list_roles");
expect(userWithoutPlanning).not.toContain("list_management_levels");
expect(userWithoutPlanning).not.toContain("list_utilization_categories");
expect(userWithoutPlanning).not.toContain("check_resource_availability");
expect(userWithoutPlanning).not.toContain("find_capacity");
expect(userWithoutPlanning).not.toContain("get_staffing_suggestions");
expect(userWithoutPlanning).not.toContain("find_best_project_resource");
expect(userWithPlanning).toContain("list_allocations");
expect(userWithPlanning).toContain("list_demands");
expect(userWithPlanning).toContain("list_blueprints");
expect(userWithPlanning).toContain("get_blueprint");
expect(userWithPlanning).toContain("list_clients");
expect(userWithPlanning).toContain("list_roles");
expect(userWithPlanning).toContain("list_management_levels");
expect(userWithPlanning).toContain("list_utilization_categories");
expect(userWithPlanning).toContain("check_resource_availability");
expect(userWithPlanning).toContain("find_capacity");
expect(userWithPlanning).not.toContain("get_staffing_suggestions");
expect(userWithPlanning).not.toContain("find_best_project_resource");
});
it("keeps cost-aware staffing assistant tools behind cost and advanced gates", () => {
const planningOnly = getToolNames([PermissionKey.VIEW_PLANNING], SystemRole.USER);
const planningAndCosts = getToolNames([
PermissionKey.VIEW_PLANNING,
PermissionKey.VIEW_COSTS,
], SystemRole.USER);
const planningCostsAndAdvanced = getToolNames([
PermissionKey.VIEW_PLANNING,
PermissionKey.VIEW_COSTS,
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
], SystemRole.USER);
expect(planningOnly).not.toContain("get_staffing_suggestions");
expect(planningOnly).not.toContain("find_best_project_resource");
expect(planningAndCosts).toContain("get_staffing_suggestions");
expect(planningAndCosts).not.toContain("find_best_project_resource");
expect(planningCostsAndAdvanced).toContain("get_staffing_suggestions");
expect(planningCostsAndAdvanced).toContain("find_best_project_resource");
});
it("keeps controller-only project and dashboard reads hidden from plain users", () => {
const controllerNames = getToolNames([
PermissionKey.VIEW_COSTS,
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
], SystemRole.CONTROLLER);
const userNames = getToolNames([
PermissionKey.VIEW_COSTS,
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
PermissionKey.VIEW_PLANNING,
], SystemRole.USER);
expect(controllerNames).toContain("search_projects");
expect(controllerNames).toContain("get_project");
expect(controllerNames).toContain("get_statistics");
expect(controllerNames).toContain("get_dashboard_detail");
expect(controllerNames).toContain("get_skill_gaps");
expect(controllerNames).toContain("get_project_health");
expect(controllerNames).toContain("get_budget_forecast");
expect(controllerNames).toContain("get_budget_status");
expect(controllerNames).toContain("get_shoring_ratio");
expect(userNames).not.toContain("search_projects");
expect(userNames).not.toContain("get_project");
expect(userNames).not.toContain("get_statistics");
expect(userNames).not.toContain("get_dashboard_detail");
expect(userNames).not.toContain("get_skill_gaps");
expect(userNames).not.toContain("get_project_health");
expect(userNames).not.toContain("get_budget_forecast");
expect(userNames).not.toContain("get_budget_status");
expect(userNames).not.toContain("get_shoring_ratio");
});
it("keeps legacy controller-only analysis and report tools hidden from plain users", () => {
const controllerNames = getToolNames([
PermissionKey.VIEW_COSTS,
PermissionKey.VIEW_PLANNING,
], SystemRole.CONTROLLER);
const userNames = getToolNames([
PermissionKey.VIEW_COSTS,
PermissionKey.VIEW_PLANNING,
], SystemRole.USER);
expect(controllerNames).toContain("detect_anomalies");
expect(controllerNames).toContain("get_insights_summary");
expect(controllerNames).toContain("run_report");
expect(controllerNames).toContain("lookup_rate");
expect(controllerNames).toContain("simulate_scenario");
expect(controllerNames).toContain("generate_project_narrative");
expect(controllerNames).toContain("list_rate_cards");
expect(controllerNames).toContain("resolve_rate");
expect(userNames).not.toContain("detect_anomalies");
expect(userNames).not.toContain("get_insights_summary");
expect(userNames).not.toContain("run_report");
expect(userNames).not.toContain("lookup_rate");
expect(userNames).not.toContain("simulate_scenario");
expect(userNames).not.toContain("generate_project_narrative");
expect(userNames).not.toContain("list_rate_cards");
expect(userNames).not.toContain("resolve_rate");
});
it("keeps cost-sensitive legacy rate tools hidden without viewCosts", () => {
const controllerWithoutCosts = getToolNames([], SystemRole.CONTROLLER);
const controllerWithCosts = getToolNames([PermissionKey.VIEW_COSTS], SystemRole.CONTROLLER);
expect(controllerWithoutCosts).not.toContain("list_rate_cards");
expect(controllerWithoutCosts).not.toContain("resolve_rate");
expect(controllerWithCosts).toContain("list_rate_cards");
expect(controllerWithCosts).toContain("resolve_rate");
});
it("requires both controller role and advanced assistant access for timeline detail tools", () => {
const controllerWithAdvanced = getToolNames([
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
], SystemRole.CONTROLLER);
const controllerWithoutAdvanced = getToolNames([], SystemRole.CONTROLLER);
const userWithAdvanced = getToolNames([
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
], SystemRole.USER);
expect(controllerWithAdvanced).toContain("get_timeline_entries_view");
expect(controllerWithAdvanced).toContain("get_timeline_holiday_overlays");
expect(controllerWithAdvanced).toContain("get_project_timeline_context");
expect(controllerWithAdvanced).toContain("preview_project_shift");
expect(controllerWithoutAdvanced).not.toContain("get_timeline_entries_view");
expect(controllerWithoutAdvanced).not.toContain("get_timeline_holiday_overlays");
expect(controllerWithoutAdvanced).not.toContain("get_project_timeline_context");
expect(controllerWithoutAdvanced).not.toContain("preview_project_shift");
expect(userWithAdvanced).not.toContain("get_timeline_entries_view");
expect(userWithAdvanced).not.toContain("get_timeline_holiday_overlays");
expect(userWithAdvanced).not.toContain("get_project_timeline_context");
expect(userWithAdvanced).not.toContain("preview_project_shift");
});
it("exposes self-service timeline tools to authenticated users without advanced assistant access", () => {
const userNames = getToolNames([], SystemRole.USER);
const viewerNames = getToolNames([], SystemRole.VIEWER);
const controllerNames = getToolNames([], SystemRole.CONTROLLER);
expect(userNames).toContain("get_my_timeline_entries_view");
expect(userNames).toContain("get_my_timeline_holiday_overlays");
expect(viewerNames).toContain("get_my_timeline_entries_view");
expect(viewerNames).toContain("get_my_timeline_holiday_overlays");
expect(controllerNames).toContain("get_my_timeline_entries_view");
expect(controllerNames).toContain("get_my_timeline_holiday_overlays");
});
it("keeps timeline write parity tools behind manager/admin role, manageAllocations, and advanced assistant access", () => {
const managerNames = getToolNames([
PermissionKey.MANAGE_ALLOCATIONS,
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
], SystemRole.MANAGER);
const userNames = getToolNames([
PermissionKey.MANAGE_ALLOCATIONS,
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
], SystemRole.USER);
const missingAdvancedNames = getToolNames([
PermissionKey.MANAGE_ALLOCATIONS,
], SystemRole.MANAGER);
expect(managerNames).toContain("update_timeline_allocation_inline");
expect(managerNames).toContain("apply_timeline_project_shift");
expect(managerNames).toContain("quick_assign_timeline_resource");
expect(managerNames).toContain("batch_quick_assign_timeline_resources");
expect(managerNames).toContain("batch_shift_timeline_allocations");
expect(userNames).not.toContain("update_timeline_allocation_inline");
expect(userNames).not.toContain("apply_timeline_project_shift");
expect(userNames).not.toContain("quick_assign_timeline_resource");
expect(userNames).not.toContain("batch_quick_assign_timeline_resources");
expect(userNames).not.toContain("batch_shift_timeline_allocations");
expect(missingAdvancedNames).not.toContain("update_timeline_allocation_inline");
expect(missingAdvancedNames).not.toContain("quick_assign_timeline_resource");
});
it("keeps 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("search_estimates");
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).toContain("search_estimates");
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("search_estimates");
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("search_estimates");
expect(managerWithoutCosts).toContain("list_estimate_versions");
expect(managerWithoutCosts).not.toContain("get_estimate_version_snapshot");
expect(userNames).not.toContain("search_estimates");
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);
const adminNames = getToolNames([], SystemRole.ADMIN);
const userNames = getToolNames([PermissionKey.IMPORT_DATA], SystemRole.USER);
expect(managerNames).toContain("import_csv_data");
expect(controllerNames).toContain("export_resources_csv");
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 managerNames = getToolNames([], SystemRole.MANAGER);
const userNames = getToolNames([], SystemRole.USER);
expect(adminNames).toContain("get_system_settings");
expect(adminNames).toContain("update_system_settings");
expect(adminNames).toContain("clear_stored_runtime_secrets");
expect(adminNames).toContain("test_ai_connection");
expect(adminNames).toContain("test_smtp_connection");
expect(adminNames).toContain("test_gemini_connection");
expect(adminNames).toContain("list_system_role_configs");
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(managerNames).not.toContain("get_system_settings");
expect(managerNames).not.toContain("update_system_settings");
expect(managerNames).not.toContain("clear_stored_runtime_secrets");
expect(managerNames).not.toContain("test_ai_connection");
expect(managerNames).not.toContain("get_ai_configured");
expect(managerNames).not.toContain("list_system_role_configs");
expect(managerNames).not.toContain("update_system_role_config");
expect(managerNames).not.toContain("list_webhooks");
expect(managerNames).not.toContain("create_webhook");
expect(userNames).not.toContain("get_system_settings");
expect(userNames).not.toContain("update_system_settings");
expect(userNames).not.toContain("clear_stored_runtime_secrets");
expect(userNames).not.toContain("test_ai_connection");
expect(userNames).not.toContain("get_ai_configured");
expect(userNames).not.toContain("list_system_role_configs");
expect(userNames).not.toContain("update_system_role_config");
expect(userNames).not.toContain("list_webhooks");
expect(userNames).not.toContain("create_webhook");
});
it("keeps client deletion admin-only while still allowing manager client maintenance", () => {
const adminNames = getToolNames([], SystemRole.ADMIN);
const managerNames = getToolNames([], SystemRole.MANAGER);
const userNames = getToolNames([], SystemRole.USER);
expect(adminNames).toContain("create_client");
expect(adminNames).toContain("update_client");
expect(adminNames).toContain("delete_client");
expect(managerNames).toContain("create_client");
expect(managerNames).toContain("update_client");
expect(managerNames).not.toContain("delete_client");
expect(userNames).not.toContain("create_client");
expect(userNames).not.toContain("update_client");
expect(userNames).not.toContain("delete_client");
});
it("keeps holiday calendar catalog tools admin-only while leaving preview available", () => {
const adminNames = getToolNames([], SystemRole.ADMIN);
const managerNames = getToolNames([], SystemRole.MANAGER);
const userNames = getToolNames([], SystemRole.USER);
expect(adminNames).toContain("list_holiday_calendars");
expect(adminNames).toContain("get_holiday_calendar");
expect(adminNames).toContain("preview_resolved_holiday_calendar");
expect(adminNames).toContain("create_holiday_calendar");
expect(managerNames).not.toContain("list_holiday_calendars");
expect(managerNames).not.toContain("get_holiday_calendar");
expect(managerNames).toContain("preview_resolved_holiday_calendar");
expect(userNames).not.toContain("list_holiday_calendars");
expect(userNames).not.toContain("get_holiday_calendar");
expect(userNames).toContain("preview_resolved_holiday_calendar");
expect(managerNames).not.toContain("create_holiday_calendar");
expect(managerNames).not.toContain("update_holiday_calendar");
expect(managerNames).not.toContain("delete_holiday_calendar");
expect(managerNames).not.toContain("create_holiday_calendar_entry");
expect(managerNames).not.toContain("update_holiday_calendar_entry");
expect(managerNames).not.toContain("delete_holiday_calendar_entry");
});
it("keeps country and metro-city mutation tools admin-only while leaving read tools available", () => {
const adminNames = getToolNames([], SystemRole.ADMIN);
const managerNames = getToolNames([], SystemRole.MANAGER);
const userNames = getToolNames([], SystemRole.USER);
const userWithResourceOverview = getToolNames([PermissionKey.VIEW_ALL_RESOURCES], SystemRole.USER);
const userWithManagedResources = getToolNames([PermissionKey.MANAGE_RESOURCES], SystemRole.USER);
expect(adminNames).toContain("list_countries");
expect(adminNames).toContain("create_country");
expect(adminNames).toContain("update_country");
expect(adminNames).toContain("create_metro_city");
expect(adminNames).toContain("update_metro_city");
expect(adminNames).toContain("delete_metro_city");
expect(managerNames).toContain("list_countries");
expect(managerNames).not.toContain("create_country");
expect(managerNames).not.toContain("update_country");
expect(managerNames).not.toContain("create_metro_city");
expect(managerNames).not.toContain("update_metro_city");
expect(managerNames).not.toContain("delete_metro_city");
expect(userNames).not.toContain("search_resources");
expect(userNames).not.toContain("get_country");
expect(userNames).not.toContain("list_org_units");
expect(userWithResourceOverview).toContain("search_resources");
expect(userWithResourceOverview).toContain("get_country");
expect(userWithResourceOverview).toContain("list_org_units");
expect(userWithManagedResources).toContain("search_resources");
expect(userWithManagedResources).toContain("get_country");
expect(userWithManagedResources).toContain("list_org_units");
});
it("attaches explicit access metadata to legacy monolithic tools with restricted visibility", () => {
const toolAccess = new Map(
TOOL_DEFINITIONS.map((tool) => [tool.function.name, tool.access]),
);
expect(toolAccess.get("run_report")).toEqual({
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
});
expect(toolAccess.get("simulate_scenario")).toEqual({
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
});
expect(toolAccess.get("detect_anomalies")).toEqual({
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
});
expect(toolAccess.get("get_insights_summary")).toEqual({
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
});
expect(toolAccess.get("lookup_rate")).toEqual({
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
});
expect(toolAccess.get("list_rate_cards")).toEqual({
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
requiresCostView: true,
});
expect(toolAccess.get("resolve_rate")).toEqual({
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
requiresCostView: true,
});
expect(toolAccess.get("import_csv_data")).toEqual({
requiredPermissions: [PermissionKey.IMPORT_DATA],
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
});
});
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");
});
});
File diff suppressed because it is too large Load Diff
@@ -1,129 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { SystemRole } from "@capakraken/shared";
vi.mock("@capakraken/application", async (importOriginal) => {
const actual = await importOriginal<typeof import("@capakraken/application")>();
return {
...actual,
getDashboardBudgetForecast: vi.fn().mockResolvedValue([]),
getDashboardPeakTimes: vi.fn().mockResolvedValue([]),
listAssignmentBookings: vi.fn().mockResolvedValue([]),
};
});
import { executeTool, type ToolContext } from "../router/assistant-tools.js";
function createToolContext(
db: Record<string, unknown>,
userRole: SystemRole = SystemRole.CONTROLLER,
): ToolContext {
return {
db: db as ToolContext["db"],
userId: "user_1",
userRole,
permissions: new Set(),
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 audit tools", () => {
it("lists audit entries through the real audit router path", async () => {
const ctx = createToolContext({
auditLog: {
findMany: vi.fn().mockResolvedValue([
{
id: "audit_1",
entityType: "Project",
entityId: "project_1",
entityName: "Gelddruckmaschine",
action: "UPDATE",
userId: "user_1",
source: "ui",
summary: "Updated project dates",
createdAt: new Date("2026-03-28T10:00:00.000Z"),
user: {
id: "user_1",
name: "Larissa",
email: "larissa@example.com",
},
},
]),
},
});
const result = await executeTool(
"list_audit_log_entries",
JSON.stringify({
entityType: "Project",
search: "Gelddruckmaschine",
limit: 10,
}),
ctx,
);
expect(JSON.parse(result.content)).toEqual({
filters: {
entityType: "Project",
entityId: null,
userId: null,
action: null,
source: null,
startDate: null,
endDate: null,
search: "Gelddruckmaschine",
},
itemCount: 1,
nextCursor: null,
items: [
{
id: "audit_1",
entityType: "Project",
entityId: "project_1",
entityName: "Gelddruckmaschine",
action: "UPDATE",
userId: "user_1",
source: "ui",
summary: "Updated project dates",
createdAt: "2026-03-28T10:00:00.000Z",
user: {
id: "user_1",
name: "Larissa",
email: "larissa@example.com",
},
},
],
});
});
it("enforces controller access for audit tools via the backing router", async () => {
const ctx = createToolContext(
{
auditLog: {
findMany: vi.fn(),
},
},
SystemRole.USER,
);
const result = await executeTool(
"query_change_history",
JSON.stringify({ entityType: "Project" }),
ctx,
);
expect(JSON.parse(result.content)).toEqual(
expect.objectContaining({
error: "You do not have permission to perform this action.",
}),
);
});
});
@@ -1,305 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { SystemRole } from "@capakraken/shared";
import { executeTool, type ToolContext } from "../router/assistant-tools.js";
function createToolContext(
db: Record<string, unknown>,
permissions: string[] = [],
userRole: SystemRole = SystemRole.ADMIN,
): ToolContext {
return {
db: db as ToolContext["db"],
userId: "user_1",
userRole,
permissions: new Set(permissions) as ToolContext["permissions"],
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,
},
});
const result = await executeTool(
"list_countries",
JSON.stringify({ search: "deu" }),
ctx,
);
const parsed = JSON.parse(result.content) as {
count: number;
countries: Array<{
code: string;
isActive: boolean;
metroCities: Array<{ id: string; name: string }>;
cities: string[];
}>;
};
expect(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",
isActive: true,
cities: ["Munich"],
metroCities: [{ id: "city_muc", name: "Munich" }],
});
});
it("gets a country by code and exposes schedule details and resource count", async () => {
const ctx = createToolContext({
country: {
findUnique: vi.fn().mockResolvedValue(null),
findFirst: vi
.fn()
.mockResolvedValueOnce({
id: "country_es",
code: "ES",
name: "Spain",
dailyWorkingHours: 8,
scheduleRules: {
type: "spain",
fridayHours: 6.5,
summerPeriod: { from: "07-01", to: "09-15" },
summerHours: 6.5,
regularHours: 9,
},
isActive: true,
metroCities: [{ id: "city_mad", name: "Madrid" }],
_count: { resources: 4 },
}),
},
});
const result = await executeTool(
"get_country",
JSON.stringify({ identifier: "ES" }),
ctx,
);
const parsed = JSON.parse(result.content) as {
code: string;
resourceCount: number | null;
scheduleRules: { type: string };
metroCities: Array<{ name: string }>;
};
expect(parsed).toMatchObject({
code: "ES",
resourceCount: 4,
scheduleRules: { type: "spain" },
metroCities: [{ name: "Madrid" }],
});
});
it("returns a stable error when a country cannot be resolved", async () => {
const ctx = createToolContext({
country: {
findUnique: vi.fn().mockResolvedValue(null),
findFirst: vi.fn().mockResolvedValue(null),
},
});
const result = await executeTool(
"get_country",
JSON.stringify({ identifier: "Atlantis" }),
ctx,
);
expect(JSON.parse(result.content)).toEqual({
error: "Country not found with the given criteria.",
});
});
it("creates a country for admin users and returns an invalidation action", async () => {
const ctx = createToolContext({
country: {
findUnique: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue({
id: "country_es",
code: "ES",
name: "Spain",
dailyWorkingHours: 8,
scheduleRules: null,
isActive: true,
metroCities: [],
_count: { resources: 0 },
}),
},
});
const result = await executeTool(
"create_country",
JSON.stringify({ code: "ES", name: "Spain", dailyWorkingHours: 8 }),
ctx,
);
expect(result.action).toEqual({
type: "invalidate",
scope: ["country", "resource", "holidayCalendar", "vacation"],
});
expect(result.data).toMatchObject({
success: true,
country: { code: "ES", name: "Spain" },
});
});
it("returns a stable error when creating a country with a duplicate code", async () => {
const ctx = createToolContext({
country: {
findUnique: vi.fn().mockResolvedValue({
id: "country_es_existing",
code: "ES",
name: "Existing Spain",
}),
},
});
const result = await executeTool(
"create_country",
JSON.stringify({ code: "ES", name: "Spain", dailyWorkingHours: 8 }),
ctx,
);
expect(JSON.parse(result.content)).toEqual({
error: "A country with this code already exists.",
});
});
it("returns a stable error when updating a missing country", async () => {
const ctx = createToolContext({
country: {
findUnique: vi.fn().mockResolvedValue(null),
},
});
const result = await executeTool(
"update_country",
JSON.stringify({ id: "country_missing", data: { name: "Atlantis" } }),
ctx,
);
expect(JSON.parse(result.content)).toEqual({
error: "Country not found with the given criteria.",
});
});
it("refuses country mutations for non-admin users", async () => {
const ctx = createToolContext({ country: {} }, [], SystemRole.MANAGER);
const result = await executeTool(
"create_country",
JSON.stringify({ code: "ES", name: "Spain" }),
ctx,
);
expect(JSON.parse(result.content)).toEqual({
error: "Admin role required to perform this action.",
});
});
it("deletes metro cities only when no resources are assigned", async () => {
const ctx = createToolContext({
metroCity: {
findUnique: vi.fn().mockResolvedValue({
id: "city_ham",
name: "Hamburg",
_count: { resources: 0 },
}),
delete: vi.fn().mockResolvedValue(undefined),
},
});
const result = await executeTool(
"delete_metro_city",
JSON.stringify({ id: "city_ham" }),
ctx,
);
expect(result.action).toEqual({
type: "invalidate",
scope: ["country", "resource", "holidayCalendar", "vacation"],
});
expect(result.data).toMatchObject({
success: true,
message: "Deleted metro city: Hamburg",
});
});
it("returns a stable error when updating a missing metro city", async () => {
const ctx = createToolContext({
metroCity: {
findUnique: vi.fn().mockResolvedValue(null),
},
});
const result = await executeTool(
"update_metro_city",
JSON.stringify({ id: "city_missing", data: { name: "Hamburg-Mitte" } }),
ctx,
);
expect(JSON.parse(result.content)).toEqual({
error: "Metro city not found with the given criteria.",
});
});
it("returns a stable error when deleting a metro city that is still assigned", async () => {
const ctx = createToolContext({
metroCity: {
findUnique: vi.fn().mockResolvedValue({
id: "city_ham",
name: "Hamburg",
_count: { resources: 3 },
}),
},
});
const result = await executeTool(
"delete_metro_city",
JSON.stringify({ id: "city_ham" }),
ctx,
);
expect(JSON.parse(result.content)).toEqual({
error: "Metro city cannot be deleted while it is still assigned to resources.",
});
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -6,8 +6,16 @@ export function createToolContext(
db: Record<string, unknown>,
userRole: SystemRole = SystemRole.USER,
): ToolContext {
const dbWithTransaction = "$transaction" in db
? db
: {
...db,
$transaction: async <T>(callback: (tx: ToolContext["db"]) => Promise<T>) =>
callback(db as ToolContext["db"]),
};
return {
db: db as ToolContext["db"],
db: dbWithTransaction as ToolContext["db"],
userId: "user_1",
userRole,
permissions: new Set(),
@@ -96,4 +96,68 @@ describe("assistant project admin create tools - success", () => {
);
expect(auditCreate).toHaveBeenCalledTimes(1);
});
it("applies assistant-side default project fields when optional create inputs are omitted", async () => {
const projectCreate = vi.fn().mockResolvedValue({
id: "project_2",
shortCode: "PROJ-DEFAULTS",
name: "Project Defaults",
status: "DRAFT",
});
const ctx = createToolContext(
{
resource: {
findFirst: vi.fn().mockResolvedValue({
displayName: "Peter Parker",
}),
findMany: vi.fn().mockResolvedValue([]),
},
project: {
findUnique: vi.fn().mockResolvedValue(null),
create: projectCreate,
},
auditLog: {
create: vi.fn().mockResolvedValue({ id: "audit_2" }),
},
},
{
userRole: SystemRole.ADMIN,
permissions: [PermissionKey.MANAGE_PROJECTS],
},
);
const result = await executeTool(
"create_project",
JSON.stringify({
shortCode: "PROJ-DEFAULTS",
name: "Project Defaults",
orderType: "CHARGEABLE",
budgetCents: 250000,
startDate: "2026-07-01",
endDate: "2026-07-31",
responsiblePerson: "Peter Parker",
}),
ctx,
);
expect(JSON.parse(result.content)).toEqual(
expect.objectContaining({
success: true,
projectId: "project_2",
shortCode: "PROJ-DEFAULTS",
}),
);
expect(projectCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
allocationType: "INT",
winProbability: 100,
status: "DRAFT",
staffingReqs: [],
dynamicFields: {},
responsiblePerson: "Peter Parker",
}),
}),
);
});
});
@@ -94,4 +94,89 @@ describe("assistant project admin create tools - validation", () => {
expect(resourceFindFirst).not.toHaveBeenCalled();
expect(projectCreate).not.toHaveBeenCalled();
});
it("returns the responsible person resolution error when no active resource matches", async () => {
const projectCreate = vi.fn();
const ctx = createToolContext(
{
resource: {
findFirst: vi.fn().mockResolvedValue(null),
findMany: vi.fn().mockResolvedValue([]),
},
project: {
findUnique: vi.fn().mockResolvedValue(null),
create: projectCreate,
},
},
{
userRole: SystemRole.ADMIN,
permissions: [PermissionKey.MANAGE_PROJECTS],
},
);
const result = await executeTool(
"create_project",
JSON.stringify({
shortCode: "PROJ-RP-MISSING",
name: "Missing Responsible Match",
orderType: "CHARGEABLE",
budgetCents: 150000,
startDate: "2026-05-01",
endDate: "2026-06-30",
responsiblePerson: "Mary Jane",
}),
ctx,
);
expect(JSON.parse(result.content)).toEqual({
error: 'No active resource found matching "Mary Jane". The responsible person must be an existing resource.',
});
expect(projectCreate).not.toHaveBeenCalled();
});
it("returns a stable assistant error when the referenced client cannot be resolved", async () => {
const projectCreate = vi.fn();
const ctx = createToolContext(
{
resource: {
findFirst: vi.fn().mockResolvedValue({
displayName: "Peter Parker",
}),
findMany: vi.fn().mockResolvedValue([]),
},
project: {
findUnique: vi.fn().mockResolvedValue(null),
create: projectCreate,
},
client: {
findUnique: vi.fn().mockResolvedValue(null),
findFirst: vi.fn().mockResolvedValue(null),
},
},
{
userRole: SystemRole.ADMIN,
permissions: [PermissionKey.MANAGE_PROJECTS],
},
);
const result = await executeTool(
"create_project",
JSON.stringify({
shortCode: "PROJ-NO-CLIENT",
name: "Missing Client",
orderType: "CHARGEABLE",
budgetCents: 150000,
startDate: "2026-05-01",
endDate: "2026-06-30",
responsiblePerson: "Peter Parker",
clientName: "Missing Client",
}),
ctx,
);
expect(JSON.parse(result.content)).toEqual({
error: 'Client not found: "Missing Client"',
});
expect(projectCreate).not.toHaveBeenCalled();
});
});
@@ -116,4 +116,32 @@ describe("assistant project admin delete tools", () => {
error: "Project not found: project_1",
}));
});
it("returns a stable assistant error when the project cannot be resolved before deletion", async () => {
const transaction = vi.fn();
const ctx = createToolContext(
{
project: {
findUnique: vi.fn().mockResolvedValue(null),
findFirst: vi.fn().mockResolvedValue(null),
},
$transaction: transaction,
},
{
userRole: SystemRole.ADMIN,
permissions: [PermissionKey.MANAGE_PROJECTS],
},
);
const result = await executeTool(
"delete_project",
JSON.stringify({ projectId: "missing-project" }),
ctx,
);
expect(JSON.parse(result.content)).toEqual({
error: "Project not found: missing-project",
});
expect(transaction).not.toHaveBeenCalled();
});
});
@@ -108,4 +108,68 @@ describe("assistant project admin update tools", () => {
error: "Project not found with the given criteria.",
});
});
it("returns a stable assistant error when no update fields are provided", async () => {
const projectUpdate = vi.fn();
const ctx = createToolContext(
{
project: {
findUnique: vi.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(createProject()),
findFirst: vi.fn().mockResolvedValue(null),
update: projectUpdate,
},
},
{
userRole: SystemRole.ADMIN,
permissions: [PermissionKey.MANAGE_PROJECTS],
},
);
const result = await executeTool(
"update_project",
JSON.stringify({ id: "PROJ-1" }),
ctx,
);
expect(JSON.parse(result.content)).toEqual({
error: "No fields to update",
});
expect(projectUpdate).not.toHaveBeenCalled();
});
it("returns responsible person resolver errors unchanged during update", async () => {
const projectUpdate = vi.fn();
const ctx = createToolContext(
{
project: {
findUnique: vi.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(createProject()),
findFirst: vi.fn().mockResolvedValue(null),
update: projectUpdate,
},
resource: {
findFirst: vi.fn().mockResolvedValue(null),
findMany: vi.fn().mockResolvedValue([]),
},
},
{
userRole: SystemRole.ADMIN,
permissions: [PermissionKey.MANAGE_PROJECTS],
},
);
const result = await executeTool(
"update_project",
JSON.stringify({ id: "PROJ-1", responsiblePerson: "Mary Jane" }),
ctx,
);
expect(JSON.parse(result.content)).toEqual({
error: 'No active resource found matching "Mary Jane". The responsible person must be an existing resource.',
});
expect(projectUpdate).not.toHaveBeenCalled();
});
});
@@ -118,6 +118,60 @@ describe("notification procedure support", () => {
}
});
it("rewrites notification recipient foreign-key errors to a not found TRPC error", () => {
const error = {
code: "P2003",
meta: { field_name: "Notification_userId_fkey" },
};
try {
rethrowNotificationReferenceError(error);
throw new Error("expected notification reference error");
} catch (caught) {
expect(caught).toBeInstanceOf(TRPCError);
expect(caught).toMatchObject<Partial<TRPCError>>({
code: "NOT_FOUND",
message: "Notification recipient user not found",
});
}
});
it("rewrites task recipient foreign-key errors to a not found TRPC error", () => {
const error = {
code: "P2003",
meta: { field_name: "Notification_userId_fkey" },
};
try {
rethrowNotificationReferenceError(error, "task");
throw new Error("expected notification reference error");
} catch (caught) {
expect(caught).toBeInstanceOf(TRPCError);
expect(caught).toMatchObject<Partial<TRPCError>>({
code: "NOT_FOUND",
message: "Task recipient user not found",
});
}
});
it("rewrites broadcast recipient foreign-key errors to a not found TRPC error", () => {
const error = {
code: "P2003",
meta: { field_name: "Notification_userId_fkey" },
};
try {
rethrowNotificationReferenceError(error, "broadcast");
throw new Error("expected notification reference error");
} catch (caught) {
expect(caught).toBeInstanceOf(TRPCError);
expect(caught).toMatchObject<Partial<TRPCError>>({
code: "NOT_FOUND",
message: "Broadcast recipient user not found",
});
}
});
it("rethrows unrelated errors unchanged", () => {
const error = new Error("boom");
@@ -72,6 +72,21 @@ function createManagerCaller(db: Record<string, unknown>) {
});
}
function createAdminCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "admin@example.com", name: "Admin", image: null },
expires: "2099-01-01T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_admin",
systemRole: SystemRole.ADMIN,
permissionOverrides: null,
},
});
}
// ── Sample data ──────────────────────────────────────────────────────────────
function sampleNotification(overrides: Record<string, unknown> = {}) {
@@ -281,6 +296,45 @@ describe("notification.create", () => {
);
});
it("defaults task-like managed notifications to OPEN when no taskStatus is provided", async () => {
const created = sampleNotification({
userId: "target_user",
category: "TASK",
taskStatus: "OPEN",
});
const db = withUserLookup(
{
notification: {
create: vi.fn().mockResolvedValue(created),
findUnique: vi.fn().mockResolvedValue(created),
},
},
"user_mgr",
);
const caller = createManagerCaller(db);
const result = await caller.create({
userId: "target_user",
type: "TASK_CREATED",
title: "Review proposal",
category: "TASK",
});
expect(db.notification.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
category: "TASK",
taskStatus: "OPEN",
}),
}),
);
expect(result).toMatchObject({
id: "notif_1",
category: "TASK",
taskStatus: "OPEN",
});
});
it("rejects creation by a regular user (FORBIDDEN)", async () => {
const db = withUserLookup({
notification: {
@@ -293,6 +347,36 @@ describe("notification.create", () => {
caller.create({ userId: "target", type: "INFO", title: "Nope" }),
).rejects.toThrow();
});
it("maps missing notification recipients to a not found error", async () => {
const db = withUserLookup(
{
notification: {
create: vi.fn().mockRejectedValue({
code: "P2003",
message: "Foreign key constraint failed",
meta: { field_name: "Notification_userId_fkey" },
}),
findUnique: vi.fn(),
},
},
"user_mgr",
);
const caller = createManagerCaller(db);
await expect(caller.create({
userId: "user_missing",
type: "INFO",
title: "Test notification",
})).rejects.toMatchObject({
code: "NOT_FOUND",
message: "Notification recipient user not found",
});
expect(db.notification.findUnique).not.toHaveBeenCalled();
expect(emitNotificationCreated).not.toHaveBeenCalled();
});
});
// ─── createBroadcast ────────────────────────────────────────────────────────
@@ -590,6 +674,75 @@ describe("notification.createBroadcast", () => {
expect(emitTaskAssigned).not.toHaveBeenCalled();
});
it("maps missing broadcast recipients during fan-out to not found errors", async () => {
resolveRecipientsMock.mockResolvedValue(["user_a", "user_missing"]);
const txCreateBroadcast = vi.fn().mockResolvedValue({
id: "broadcast_tx_missing_recipient",
title: "Ops update",
createdAt: new Date("2026-03-30T10:00:00Z"),
});
const txUpdateBroadcast = vi.fn();
const txCreateNotification = vi.fn()
.mockResolvedValueOnce({ id: "notif_a", userId: "user_a" })
.mockRejectedValueOnce(
Object.assign(new Error("Foreign key constraint failed"), {
code: "P2003",
meta: { field_name: "Notification_userId_fkey" },
}),
);
const tx = {
notificationBroadcast: {
create: txCreateBroadcast,
update: txUpdateBroadcast,
},
notification: {
create: txCreateNotification,
},
};
const outerCreateBroadcast = vi.fn();
const outerUpdateBroadcast = vi.fn();
const outerCreateNotification = vi.fn();
const db = {
$transaction: vi.fn(async (callback: (db: typeof tx) => Promise<unknown>) => callback(tx)),
user: {
findUnique: vi.fn(),
},
notificationBroadcast: {
create: outerCreateBroadcast,
update: outerUpdateBroadcast,
},
notification: {
create: outerCreateNotification,
},
};
const caller = createManagerCaller(db);
await expect(caller.createBroadcast({
title: "Ops update",
body: "Email everyone",
channel: "both",
targetType: "all",
})).rejects.toMatchObject({
code: "NOT_FOUND",
message: "Broadcast recipient user not found",
});
await Promise.resolve();
expect(txCreateBroadcast).toHaveBeenCalledTimes(1);
expect(txCreateNotification).toHaveBeenCalledTimes(2);
expect(txUpdateBroadcast).not.toHaveBeenCalled();
expect(outerCreateBroadcast).not.toHaveBeenCalled();
expect(outerUpdateBroadcast).not.toHaveBeenCalled();
expect(outerCreateNotification).not.toHaveBeenCalled();
expect(db.user.findUnique).not.toHaveBeenCalled();
expect(sendEmailMock).not.toHaveBeenCalled();
expect(emitNotificationCreated).not.toHaveBeenCalled();
expect(emitTaskAssigned).not.toHaveBeenCalled();
});
it("emits recipient SSE only after an immediate broadcast commits", async () => {
resolveRecipientsMock.mockResolvedValue(["user_a", "user_b"]);
@@ -1301,6 +1454,78 @@ describe("notification.updateTaskStatus", () => {
});
describe("notification.assignTask", () => {
it("returns NOT_FOUND when assigning a missing task", async () => {
const update = vi.fn();
const db = {
notification: {
findUnique: vi.fn().mockResolvedValue(null),
update,
},
};
const caller = createManagerCaller(db);
await expect(caller.assignTask({ id: "task_missing", assigneeId: "user_4" })).rejects.toMatchObject({
code: "NOT_FOUND",
message: "Task not found",
});
expect(update).not.toHaveBeenCalled();
expect(emitTaskAssigned).not.toHaveBeenCalled();
});
it("maps missing task recipients to a not found error without side effects", async () => {
const db = {
notification: {
create: vi.fn().mockRejectedValue({
code: "P2003",
message: "Foreign key constraint failed",
meta: { field_name: "Notification_userId_fkey" },
}),
findUnique: vi.fn(),
},
};
const caller = createManagerCaller(db);
await expect(caller.createTask({
userId: "user_missing",
title: "Review proposal",
channel: "in_app",
})).rejects.toMatchObject({
code: "NOT_FOUND",
message: "Task recipient user not found",
});
expect(db.notification.findUnique).not.toHaveBeenCalled();
expect(emitNotificationCreated).not.toHaveBeenCalled();
expect(emitTaskAssigned).not.toHaveBeenCalled();
});
it("rejects assigning non-task notifications", async () => {
const update = vi.fn();
const db = {
notification: {
findUnique: vi.fn().mockResolvedValue({
id: "notif_9",
category: "REMINDER",
assigneeId: null,
}),
update,
},
};
const caller = createManagerCaller(db);
await expect(caller.assignTask({ id: "notif_9", assigneeId: "user_4" })).rejects.toMatchObject({
code: "BAD_REQUEST",
message: "Only tasks and approvals can be assigned",
});
expect(update).not.toHaveBeenCalled();
expect(emitTaskAssigned).not.toHaveBeenCalled();
});
it("reassigns a task and emits the assignment event for the new assignee", async () => {
const findUnique = vi.fn().mockResolvedValue({
id: "task_9",
@@ -1365,6 +1590,103 @@ describe("notification.assignTask", () => {
});
expect(emitTaskAssigned).not.toHaveBeenCalledWith("user_missing", "task_9");
});
it("returns NOT_FOUND when the task disappears before reassignment is persisted", async () => {
const findUnique = vi.fn().mockResolvedValue({
id: "task_9",
category: "TASK",
assigneeId: "user_2",
});
const update = vi.fn().mockRejectedValue(
Object.assign(new Error("Record to update not found"), {
code: "P2025",
}),
);
const db = {
notification: {
findUnique,
update,
},
};
const caller = createManagerCaller(db);
await expect(caller.assignTask({ id: "task_9", assigneeId: "user_4" })).rejects.toMatchObject({
code: "NOT_FOUND",
message: "Task not found",
});
expect(findUnique).toHaveBeenCalledWith({ where: { id: "task_9" } });
expect(update).toHaveBeenCalledWith({
where: { id: "task_9" },
data: { assigneeId: "user_4" },
});
expect(emitTaskAssigned).not.toHaveBeenCalled();
});
});
describe("notification.executeTaskAction", () => {
it("rejects dismissed tasks before executing their domain action", async () => {
const updateAssignment = vi.fn();
const db = withUserLookup({
notification: {
findFirst: vi.fn().mockResolvedValue({
id: "task_1",
userId: "user_1",
assigneeId: null,
taskAction: "confirm_assignment:assign_1",
taskStatus: "DISMISSED",
}),
update: vi.fn(),
},
assignment: {
findUnique: vi.fn(),
update: updateAssignment,
},
});
const caller = createAdminCaller(db);
await expect(caller.executeTaskAction({ id: "task_1" })).rejects.toMatchObject({
code: "PRECONDITION_FAILED",
message: "This task has been dismissed",
});
expect(updateAssignment).not.toHaveBeenCalled();
expect(db.notification.update).not.toHaveBeenCalled();
expect(emitTaskCompleted).not.toHaveBeenCalled();
});
it("rejects task action execution when transactional persistence support is unavailable", async () => {
const updateVacation = vi.fn();
const db = withUserLookup({
notification: {
findFirst: vi.fn().mockResolvedValue({
id: "task_1",
userId: "user_1",
assigneeId: null,
taskAction: "approve_vacation:vac_1",
taskStatus: "OPEN",
}),
update: vi.fn(),
},
vacation: {
findUnique: vi.fn(),
update: updateVacation,
},
});
const caller = createAdminCaller(db);
await expect(caller.executeTaskAction({ id: "task_1" })).rejects.toMatchObject({
code: "INTERNAL_SERVER_ERROR",
message: "Task action execution requires transactional persistence support.",
});
expect(updateVacation).not.toHaveBeenCalled();
expect(db.notification.update).not.toHaveBeenCalled();
expect(emitTaskCompleted).not.toHaveBeenCalled();
});
});
// ─── reminders ──────────────────────────────────────────────────────────────
@@ -1467,6 +1789,28 @@ describe("notification.updateReminder", () => {
},
});
});
it("returns NOT_FOUND when the reminder is missing or belongs to another user", async () => {
const update = vi.fn();
const db = withUserLookup({
notification: {
findFirst: vi.fn().mockResolvedValue(null),
update,
},
});
const caller = createProtectedCaller(db);
await expect(caller.updateReminder({
id: "rem_missing",
title: "Updated reminder",
})).rejects.toMatchObject({
code: "NOT_FOUND",
message: "Reminder not found or you do not have permission",
});
expect(update).not.toHaveBeenCalled();
});
});
describe("notification.deleteReminder", () => {
@@ -1492,6 +1836,25 @@ describe("notification.deleteReminder", () => {
});
expect(deleteFn).toHaveBeenCalledWith({ where: { id: "rem_1" } });
});
it("returns NOT_FOUND when the reminder is missing or belongs to another user", async () => {
const deleteFn = vi.fn();
const db = withUserLookup({
notification: {
findFirst: vi.fn().mockResolvedValue(null),
delete: deleteFn,
},
});
const caller = createProtectedCaller(db);
await expect(caller.deleteReminder({ id: "rem_missing" })).rejects.toMatchObject({
code: "NOT_FOUND",
message: "Reminder not found or you do not have permission",
});
expect(deleteFn).not.toHaveBeenCalled();
});
});
describe("notification.listReminders", () => {
@@ -59,6 +59,67 @@ describe("report router", () => {
]));
});
it("exposes extended resource and project basis columns for report completeness", async () => {
const caller = createControllerCaller({});
const resourceColumns = await caller.getAvailableColumns({ entity: "resource" });
const projectColumns = await caller.getAvailableColumns({ entity: "project" });
expect(resourceColumns).toEqual(expect.arrayContaining([
expect.objectContaining({ key: "enterpriseId", label: "Enterprise ID" }),
expect.objectContaining({ key: "valueScore", label: "Value Score" }),
expect.objectContaining({ key: "blueprint.name", label: "Blueprint" }),
expect.objectContaining({ key: "clientUnit.name", label: "Client Unit" }),
]));
expect(projectColumns).toEqual(expect.arrayContaining([
expect.objectContaining({ key: "shoringThreshold", label: "Shoring Threshold (%)" }),
expect.objectContaining({ key: "onshoreCountryCode", label: "Onshore Country Code" }),
expect.objectContaining({ key: "color", label: "Color" }),
]));
});
it("lists backend-managed report blueprints for resource_month", async () => {
const caller = createControllerCaller({});
const blueprints = await caller.listBlueprints({ entity: "resource_month" });
expect(blueprints).toEqual([
expect.objectContaining({
id: "resource-month-sah-transparency",
entity: "resource_month",
templateName: "Monthly SAH transparency",
config: expect.objectContaining({
entity: "resource_month",
sortBy: "displayName",
sortDir: "asc",
filters: [],
}),
}),
expect.objectContaining({
id: "resource-month-chargeability-audit",
entity: "resource_month",
templateName: "Monthly chargeability audit",
config: expect.objectContaining({
entity: "resource_month",
sortBy: "monthlyActualChargeabilityPct",
sortDir: "desc",
filters: [],
}),
}),
expect.objectContaining({
id: "resource-month-location-comparison",
entity: "resource_month",
templateName: "Monthly holiday comparison by location",
config: expect.objectContaining({
entity: "resource_month",
groupBy: "federalState",
sortBy: "monthlyPublicHolidayHoursDeduction",
sortDir: "desc",
filters: [],
}),
}),
]);
});
it("exports resource month basis and computed columns in CSV", async () => {
const db = {
resource: {
@@ -114,6 +175,42 @@ describe("report router", () => {
expect(result.rowCount).toBe(1);
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");
expect(result.columns).toEqual([
"id",
"displayName",
"countryCode",
"monthlyPublicHolidayCount",
"monthlyPublicHolidayHoursDeduction",
"monthlyAbsenceHoursDeduction",
"monthlySahHours",
"monthlyTargetHours",
"monthlyUnassignedHours",
]);
expect(result.explainability).toEqual({
entity: "resource_month",
periodMonth: "2026-04",
locationContextColumns: ["countryCode"],
holidayMetricColumns: ["monthlyPublicHolidayCount", "monthlyPublicHolidayHoursDeduction"],
absenceMetricColumns: ["monthlyAbsenceHoursDeduction"],
capacityMetricColumns: ["monthlySahHours", "monthlyTargetHours"],
chargeabilityMetricColumns: ["monthlyUnassignedHours"],
missingRecommendedColumns: [
"countryName",
"federalState",
"metroCityName",
"monthlyPublicHolidayWorkdayCount",
"monthlyAbsenceDayEquivalent",
"monthlyBaseWorkingDays",
"monthlyEffectiveWorkingDays",
"monthlyBaseAvailableHours",
"monthlyChargeabilityTargetPct",
],
notes: [
"monthlySahHours already reflects region-specific public holidays from country, federal state, and city context when those attributes exist on the resource.",
"monthlyAbsence* metrics only deduct workdays that are not already counted as public holidays.",
"monthlyBaseAvailableHours shows pre-deduction capacity; compare it with holiday, absence, and SAH columns to explain the final monthly availability.",
],
});
});
it("keeps holiday and absence deductions separate in resource_month rows", async () => {
@@ -178,6 +275,132 @@ describe("report router", () => {
monthlySahHours: 156,
},
]);
expect(result.explainability).toEqual({
entity: "resource_month",
periodMonth: "2026-04",
locationContextColumns: [],
holidayMetricColumns: ["monthlyPublicHolidayCount", "monthlyPublicHolidayHoursDeduction"],
absenceMetricColumns: ["monthlyAbsenceDayEquivalent", "monthlyAbsenceHoursDeduction"],
capacityMetricColumns: ["monthlySahHours"],
chargeabilityMetricColumns: [],
missingRecommendedColumns: [
"countryCode",
"countryName",
"federalState",
"metroCityName",
"monthlyPublicHolidayWorkdayCount",
"monthlyBaseWorkingDays",
"monthlyEffectiveWorkingDays",
"monthlyBaseAvailableHours",
"monthlyChargeabilityTargetPct",
"monthlyTargetHours",
],
notes: [
"monthlySahHours already reflects region-specific public holidays from country, federal state, and city context when those attributes exist on the resource.",
"monthlyAbsence* metrics only deduct workdays that are not already counted as public holidays.",
"monthlyBaseAvailableHours shows pre-deduction capacity; compare it with holiday, absence, and SAH columns to explain the final monthly availability.",
],
});
});
it("flattens extended assignment resource and project context columns", async () => {
const db = {
assignment: {
findMany: vi.fn().mockResolvedValue([
{
id: "asg_1",
hoursPerDay: 6,
resource: {
displayName: "Alice",
resourceType: "EMPLOYEE",
chargeabilityTarget: 85,
orgUnit: { name: "Delivery" },
managementLevelGroup: { name: "Senior IC" },
managementLevel: { name: "Senior Artist" },
},
project: {
name: "Gelddruckmaschine",
orderType: "TIME_AND_MATERIAL",
allocationType: "PROJECT",
blueprint: { name: "Consulting Blueprint" },
utilizationCategory: { name: "Billable" },
},
},
]),
count: vi.fn().mockResolvedValue(1),
},
};
const caller = createControllerCaller(db);
const result = await caller.getReportData({
entity: "assignment",
columns: [
"resource.displayName",
"resource.resourceType",
"resource.chargeabilityTarget",
"resource.orgUnit.name",
"resource.managementLevelGroup.name",
"resource.managementLevel.name",
"project.name",
"project.orderType",
"project.allocationType",
"project.blueprint.name",
"project.utilizationCategory.name",
"hoursPerDay",
],
filters: [],
sortBy: "hoursPerDay",
sortDir: "desc",
limit: 10,
offset: 0,
});
expect(db.assignment.findMany).toHaveBeenCalledWith({
select: {
id: true,
hoursPerDay: true,
resource: {
select: {
displayName: true,
resourceType: true,
chargeabilityTarget: true,
orgUnit: { select: { name: true } },
managementLevelGroup: { select: { name: true } },
managementLevel: { select: { name: true } },
},
},
project: {
select: {
name: true,
orderType: true,
allocationType: true,
blueprint: { select: { name: true } },
utilizationCategory: { select: { name: true } },
},
},
},
where: {},
orderBy: [{ hoursPerDay: "desc" }],
take: 10,
skip: 0,
});
expect(result.rows).toEqual([
{
id: "asg_1",
"resource.displayName": "Alice",
"resource.resourceType": "EMPLOYEE",
"resource.chargeabilityTarget": 85,
"resource.orgUnit.name": "Delivery",
"resource.managementLevelGroup.name": "Senior IC",
"resource.managementLevel.name": "Senior Artist",
"project.name": "Gelddruckmaschine",
"project.orderType": "TIME_AND_MATERIAL",
"project.allocationType": "PROJECT",
"project.blueprint.name": "Consulting Blueprint",
"project.utilizationCategory.name": "Billable",
hoursPerDay: 6,
},
]);
});
it("rejects invalid resource_month period months instead of silently normalizing them", async () => {
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { buildResourceMonthTemplateCompleteness } from "../router/report-blueprints-support.js";
import {
DeleteReportTemplateInputSchema,
deleteReportTemplate,
@@ -21,6 +22,36 @@ function createContext(reportTemplate: Record<string, unknown>) {
}
describe("report template procedure support", () => {
it("reuses the shared resource month completeness basis", () => {
expect(buildResourceMonthTemplateCompleteness([
"monthKey",
"displayName",
"countryName",
"federalState",
"metroCityName",
"monthlyPublicHolidayCount",
"monthlyPublicHolidayHoursDeduction",
"monthlyAbsenceDayEquivalent",
"monthlyAbsenceHoursDeduction",
"monthlySahHours",
"monthlyTargetHours",
"monthlyActualBookedHours",
"monthlyUnassignedHours",
])).toMatchObject({
scope: "resource_month",
isAuditReady: true,
isRecommendedComplete: false,
minimumAuditColumnCount: 13,
selectedMinimumAuditColumnCount: 13,
missingMinimumAuditColumns: [],
missingRecommendedColumns: expect.arrayContaining([
"eid",
"chapter",
"monthlyExpectedBookedHours",
]),
});
});
it("lists shared and owned templates with parsed config and ownership flags", async () => {
const updatedAt = new Date("2026-03-31T10:00:00.000Z");
const ctx = createContext({
@@ -24,7 +24,10 @@ vi.mock("@capakraken/application", async (importOriginal) => {
};
});
import { carveTimelineAllocationRange } from "../router/timeline-allocation-fragment-support.js";
import {
carveTimelineAllocationRange,
extractTimelineAllocationFragment,
} from "../router/timeline-allocation-fragment-support.js";
function createResolvedAssignment() {
return {
@@ -172,4 +175,83 @@ describe("timeline allocation fragment support", () => {
}),
);
});
it("extracts a middle segment into the original assignment and creates siblings", async () => {
loadAllocationEntryMock.mockResolvedValue(createResolvedAssignment());
updateAssignmentMock.mockResolvedValue({ id: "assignment_1" });
createAssignmentMock
.mockResolvedValueOnce({ id: "assignment_left" })
.mockResolvedValueOnce({ id: "assignment_right" });
const db = {
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
};
const result = await extractTimelineAllocationFragment({
db: db as never,
allocationId: "assignment_1",
startDate: new Date("2026-04-09T00:00:00.000Z"),
endDate: new Date("2026-04-10T00:00:00.000Z"),
});
expect(result).toEqual({
action: "extracted",
allocationGroupId: expect.any(String),
extractedAllocationId: "assignment_1",
updatedAllocationIds: ["assignment_1"],
createdAllocationIds: ["assignment_left", "assignment_right"],
projectId: "project_1",
resourceId: "resource_1",
});
expect(updateAssignmentMock.mock.invocationCallOrder[0]).toBeLessThan(
createAssignmentMock.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
);
expect(updateAssignmentMock).toHaveBeenCalledWith(
db,
"assignment_1",
expect.objectContaining({
startDate: new Date("2026-04-09T00:00:00.000Z"),
endDate: new Date("2026-04-10T00:00:00.000Z"),
}),
);
expect(createAssignmentMock).toHaveBeenNthCalledWith(
1,
db,
expect.objectContaining({
startDate: new Date("2026-04-06T00:00:00.000Z"),
endDate: new Date("2026-04-08T00:00:00.000Z"),
}),
);
expect(createAssignmentMock).toHaveBeenNthCalledWith(
2,
db,
expect.objectContaining({
startDate: new Date("2026-04-11T00:00:00.000Z"),
endDate: new Date("2026-04-17T00:00:00.000Z"),
}),
);
});
it("returns unchanged when extracting the full assignment range", async () => {
loadAllocationEntryMock.mockResolvedValue(createResolvedAssignment());
const result = await extractTimelineAllocationFragment({
db: { $transaction: vi.fn() } as never,
allocationId: "assignment_1",
startDate: new Date("2026-04-06T00:00:00.000Z"),
endDate: new Date("2026-04-17T00:00:00.000Z"),
});
expect(result).toEqual({
action: "unchanged",
allocationGroupId: expect.any(String),
extractedAllocationId: "assignment_1",
updatedAllocationIds: [],
createdAllocationIds: [],
projectId: "project_1",
resourceId: "resource_1",
});
expect(updateAssignmentMock).not.toHaveBeenCalled();
expect(createAssignmentMock).not.toHaveBeenCalled();
});
});
@@ -1,6 +1,8 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../sse/event-bus.js", () => ({
emitAllocationCreated: vi.fn(),
emitAllocationDeleted: vi.fn(),
emitAllocationUpdated: vi.fn(),
}));
@@ -13,11 +15,20 @@ vi.mock("../router/timeline-allocation-inline-support.js", () => ({
applyTimelineInlineAllocationUpdate: vi.fn(),
}));
vi.mock("../router/timeline-allocation-fragment-support.js", () => ({
carveTimelineAllocationRange: vi.fn(),
}));
vi.mock("../router/timeline-allocation-procedure-support.js", () => ({
shiftTimelineAllocations: vi.fn(),
}));
import { emitAllocationUpdated } from "../sse/event-bus.js";
import {
emitAllocationCreated,
emitAllocationDeleted,
emitAllocationUpdated,
} from "../sse/event-bus.js";
import { carveTimelineAllocationRange } from "../router/timeline-allocation-fragment-support.js";
import {
createTimelineBatchQuickAssignments,
createTimelineQuickAssignment,
@@ -26,12 +37,16 @@ import { applyTimelineInlineAllocationUpdate } from "../router/timeline-allocati
import { shiftTimelineAllocations } from "../router/timeline-allocation-procedure-support.js";
import {
applyTimelineAllocationBatchShiftMutation,
carveTimelineAllocationRangeMutation,
createTimelineBatchQuickAssignMutation,
createTimelineQuickAssignMutation,
updateTimelineAllocationInlineMutation,
} from "../router/timeline-allocation-router-support.js";
const emitAllocationCreatedMock = vi.mocked(emitAllocationCreated);
const emitAllocationDeletedMock = vi.mocked(emitAllocationDeleted);
const emitAllocationUpdatedMock = vi.mocked(emitAllocationUpdated);
const carveTimelineAllocationRangeMock = vi.mocked(carveTimelineAllocationRange);
const createTimelineBatchQuickAssignmentsMock = vi.mocked(createTimelineBatchQuickAssignments);
const createTimelineQuickAssignmentMock = vi.mocked(createTimelineQuickAssignment);
const applyTimelineInlineAllocationUpdateMock = vi.mocked(applyTimelineInlineAllocationUpdate);
@@ -183,4 +198,59 @@ describe("timeline allocation router support", () => {
mode: "preserve-duration",
});
});
it("carves an allocation range and emits update/create/delete events for every affected fragment", async () => {
const db = {} as never;
const startDate = new Date("2026-04-09T00:00:00.000Z");
const endDate = new Date("2026-04-10T00:00:00.000Z");
carveTimelineAllocationRangeMock.mockResolvedValueOnce({
action: "split",
allocationGroupId: "group_1",
updatedAllocationIds: ["allocation_left"],
createdAllocationIds: ["allocation_right"],
deletedAllocationIds: ["allocation_removed"],
projectId: "project_1",
resourceId: "resource_1",
});
await expect(
carveTimelineAllocationRangeMutation({
db,
allocationId: "allocation_1",
startDate,
endDate,
}),
).resolves.toEqual({
action: "split",
allocationGroupId: "group_1",
updatedAllocationIds: ["allocation_left"],
createdAllocationIds: ["allocation_right"],
deletedAllocationIds: ["allocation_removed"],
projectId: "project_1",
resourceId: "resource_1",
});
expect(carveTimelineAllocationRangeMock).toHaveBeenCalledWith({
db,
allocationId: "allocation_1",
startDate,
endDate,
});
expect(emitAllocationUpdatedMock).toHaveBeenCalledWith({
id: "allocation_left",
projectId: "project_1",
resourceId: "resource_1",
});
expect(emitAllocationCreatedMock).toHaveBeenCalledWith({
id: "allocation_right",
projectId: "project_1",
resourceId: "resource_1",
});
expect(emitAllocationDeletedMock).toHaveBeenCalledWith(
"allocation_removed",
"project_1",
"resource_1",
);
});
});
@@ -222,6 +222,124 @@ describe("timeline allocation entry resolution", () => {
);
});
it("extracts a visible subrange into its own assignment fragment", async () => {
const existingAssignment = {
id: "assignment_1",
demandRequirementId: null,
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-27"),
hoursPerDay: 8,
percentage: 100,
role: "Compositor",
roleId: "role_comp",
dailyCostCents: 40000,
status: AllocationStatus.CONFIRMED,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
resource: {
id: "resource_1",
displayName: "Alice",
eid: "E-001",
lcrCents: 5000,
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
saturday: 0,
sunday: 0,
},
},
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
roleEntity: { id: "role_comp", name: "Compositor", color: "#111111" },
demandRequirement: null,
};
const db = {
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,
},
}),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
demandRequirement: {
findUnique: vi.fn().mockResolvedValue(null),
},
assignment: {
findUnique: vi.fn().mockResolvedValue(existingAssignment),
findMany: vi.fn().mockResolvedValue([]),
update: vi.fn().mockImplementation(async ({ data }: { data: Record<string, unknown> }) => ({
...existingAssignment,
...data,
metadata: data.metadata ?? existingAssignment.metadata,
})),
create: vi.fn()
.mockResolvedValueOnce({
...existingAssignment,
id: "assignment_left",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
})
.mockResolvedValueOnce({
...existingAssignment,
id: "assignment_right",
startDate: new Date("2026-03-26"),
endDate: new Date("2026-03-27"),
}),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
};
const caller = createManagerCaller(db);
const result = await caller.extractAllocationFragment({
allocationId: "assignment_1",
startDate: new Date("2026-03-23"),
endDate: new Date("2026-03-25"),
});
expect(result).toEqual({
action: "extracted",
allocationGroupId: expect.any(String),
extractedAllocationId: "assignment_1",
updatedAllocationIds: ["assignment_1"],
createdAllocationIds: ["assignment_left", "assignment_right"],
projectId: "project_1",
resourceId: "resource_1",
});
expect(db.assignment.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "assignment_1" },
data: expect.objectContaining({
startDate: new Date("2026-03-23"),
endDate: new Date("2026-03-25"),
}),
}),
);
expect(db.assignment.create).toHaveBeenCalledTimes(2);
});
it("falls back to default rules when calculationRule and vacation tables are missing", async () => {
const existingAssignment = {
id: "assignment_legacy_1",
@@ -142,7 +142,7 @@ describe("timeline router detail views", () => {
countryId: "country_de",
federalState: "BY",
metroCityId: "city_munich",
country: { code: "DE" },
country: { code: "DE", name: "Germany" },
metroCity: { name: "Muenchen" },
},
]);
@@ -177,6 +177,10 @@ describe("timeline router detail views", () => {
resourceId: "res_self",
note: "Heilige Drei Könige",
scope: "STATE",
countryCode: "DE",
countryName: "Germany",
federalState: "BY",
metroCityName: "Muenchen",
}),
]);
expect(demandFindMany).not.toHaveBeenCalled();
@@ -365,7 +369,7 @@ describe("timeline router detail views", () => {
countryId: "country_de",
federalState: "BY",
metroCityId: "city_munich",
country: { code: "DE" },
country: { code: "DE", name: "Germany" },
metroCity: { name: "Muenchen" },
},
{
@@ -373,7 +377,7 @@ describe("timeline router detail views", () => {
countryId: "country_de",
federalState: "HH",
metroCityId: "city_hamburg",
country: { code: "DE" },
country: { code: "DE", name: "Germany" },
metroCity: { name: "Hamburg" },
},
]),
@@ -409,6 +413,10 @@ describe("timeline router detail views", () => {
startDate: "2026-01-06",
note: "Heilige Drei Könige",
scope: "STATE",
countryCode: "DE",
countryName: "Germany",
federalState: "BY",
metroCityName: "Muenchen",
}),
]);
});
+2 -1
View File
@@ -1,3 +1,4 @@
import { DEFAULT_OPENAI_MODEL } from "@capakraken/shared";
import OpenAI, { AzureOpenAI } from "openai";
import { logger } from "./lib/logger.js";
import { resolveSystemSettingsRuntime } from "./lib/system-settings-runtime.js";
@@ -123,7 +124,7 @@ export function parseAiError(err: unknown): string {
return "Deployment not found — check the deployment name matches exactly what's configured in Azure.";
}
if (lower.includes("404") || lower.includes("not found")) {
return "Model not found — verify the model name (e.g. gpt-4o-mini) is correct and available on your account.";
return `Model not found — verify the model name (e.g. ${DEFAULT_OPENAI_MODEL}) is correct and available on your account.`;
}
if (lower.includes("429") || lower.includes("rate limit") || lower.includes("ratelimiterror")) {
return "Rate limit exceeded — wait a moment and try again.";
+2 -1
View File
@@ -1,4 +1,5 @@
import { Redis } from "ioredis";
import { logger } from "./logger.js";
const REDIS_URL = process.env["REDIS_URL"] ?? "redis://localhost:6380";
const KEY_PREFIX = "dashboard:";
@@ -15,7 +16,7 @@ function getRedis(): Redis {
commandTimeout: 2000,
});
redis.on("error", (e: unknown) => {
console.error("[Redis cache]", e);
logger.warn({ err: e, redisUrl: REDIS_URL }, "Redis cache connection emitted an error");
});
}
return redis;
@@ -84,7 +84,14 @@ export const allocationAssignmentProcedures = {
}))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const result = await ensureAssignmentRecord(ctx.db, input);
const result = await ensureAssignmentRecord(ctx.db, {
resourceId: input.resourceId,
projectId: input.projectId,
startDate: input.startDate,
endDate: input.endDate,
hoursPerDay: input.hoursPerDay,
...(input.role !== undefined ? { role: input.role } : {}),
});
if (result.action === "reactivated") {
publishAllocationUpdated(ctx.db, {
@@ -1,294 +1,11 @@
import { PermissionKey, SystemRole } from "@capakraken/shared";
import { ADVANCED_ASSISTANT_TOOLS, TOOL_DEFINITIONS } from "./assistant-tools.js";
import { type PermissionKey } from "@capakraken/shared";
import { getAvailableAssistantToolsForContext } from "./assistant-tools.js";
import type { ToolDef } from "./assistant-tools/shared.js";
const TOOL_PERMISSION_MAP: Record<string, string> = {
update_resource: "manageResources",
create_resource: "manageResources",
deactivate_resource: "manageResources",
create_role: PermissionKey.MANAGE_ROLES,
update_role: PermissionKey.MANAGE_ROLES,
delete_role: PermissionKey.MANAGE_ROLES,
update_project: "manageProjects",
create_project: "manageProjects",
delete_project: "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,
create_allocation: "manageAllocations",
cancel_allocation: "manageAllocations",
update_allocation_status: "manageAllocations",
update_timeline_allocation_inline: "manageAllocations",
apply_timeline_project_shift: "manageAllocations",
quick_assign_timeline_resource: "manageAllocations",
batch_quick_assign_timeline_resources: "manageAllocations",
batch_shift_timeline_allocations: "manageAllocations",
create_demand: "manageAllocations",
fill_demand: "manageAllocations",
create_estimate_planning_handoff: "manageAllocations",
execute_task_action: "manageAllocations",
};
const COST_TOOLS = new Set([
"get_budget_status",
"get_chargeability",
"get_chargeability_report",
"get_resource_computation_graph",
"get_project_computation_graph",
"resolve_rate",
"list_rate_cards",
"get_estimate_detail",
"get_estimate_version_snapshot",
"find_best_project_resource",
"get_staffing_suggestions",
]);
const PLANNING_READ_TOOLS = new Set([
"list_allocations",
"list_demands",
"list_blueprints",
"get_blueprint",
"list_clients",
"list_roles",
"list_management_levels",
"list_utilization_categories",
"check_resource_availability",
"get_staffing_suggestions",
"find_capacity",
"find_best_project_resource",
]);
const RESOURCE_OVERVIEW_TOOLS = new Set([
"search_resources",
"get_country",
"list_org_units",
]);
const CONTROLLER_ONLY_TOOLS = new Set([
"search_by_skill",
"search_projects",
"get_project",
"search_estimates",
"get_timeline_entries_view",
"get_timeline_holiday_overlays",
"get_project_timeline_context",
"preview_project_shift",
"get_statistics",
"get_dashboard_detail",
"get_skill_gaps",
"get_project_health",
"get_budget_forecast",
"query_change_history",
"get_entity_timeline",
"export_resources_csv",
"export_projects_csv",
"list_audit_log_entries",
"get_audit_log_entry",
"get_audit_log_timeline",
"get_audit_activity_summary",
"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",
]);
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",
]);
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",
"clear_stored_runtime_secrets",
"get_ai_configured",
"test_ai_connection",
"test_smtp_connection",
"test_gemini_connection",
"list_system_role_configs",
"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",
"update_metro_city",
"delete_metro_city",
"list_holiday_calendars",
"get_holiday_calendar",
"create_holiday_calendar",
"update_holiday_calendar",
"delete_holiday_calendar",
"create_holiday_calendar_entry",
"update_holiday_calendar_entry",
"delete_holiday_calendar_entry",
]);
function hasLegacyToolAccess(
toolName: string,
permissions: Set<PermissionKey>,
userRole: string,
hasResourceOverviewAccess: boolean,
hasControllerAccess: boolean,
hasManagerAccess: boolean,
) {
const requiredPerm = TOOL_PERMISSION_MAP[toolName];
if (requiredPerm && !permissions.has(requiredPerm as PermissionKey)) {
return false;
}
if (ADMIN_ONLY_TOOLS.has(toolName) && userRole !== SystemRole.ADMIN) {
return false;
}
if (MANAGER_ONLY_TOOLS.has(toolName) && !hasManagerAccess) {
return false;
}
if (RESOURCE_OVERVIEW_TOOLS.has(toolName) && !hasResourceOverviewAccess) {
return false;
}
if (CONTROLLER_ONLY_TOOLS.has(toolName) && !hasControllerAccess) {
return false;
}
if (PLANNING_READ_TOOLS.has(toolName) && !permissions.has(PermissionKey.VIEW_PLANNING)) {
return false;
}
if (COST_TOOLS.has(toolName) && !permissions.has(PermissionKey.VIEW_COSTS)) {
return false;
}
if (ADVANCED_ASSISTANT_TOOLS.has(toolName) && !permissions.has(PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS)) {
return false;
}
return true;
}
function hasToolAccess(
tool: ToolDef,
permissions: Set<PermissionKey>,
userRole: string,
hasResourceOverviewAccess: boolean,
): boolean {
if (!tool.access) {
const hasControllerAccess = userRole === SystemRole.ADMIN
|| userRole === SystemRole.MANAGER
|| userRole === SystemRole.CONTROLLER;
const hasManagerAccess = userRole === SystemRole.ADMIN
|| userRole === SystemRole.MANAGER;
return hasLegacyToolAccess(
tool.function.name,
permissions,
userRole,
hasResourceOverviewAccess,
hasControllerAccess,
hasManagerAccess,
);
}
if (tool.access.requiredPermissions?.some((permission) => !permissions.has(permission))) {
return false;
}
if (tool.access.allowedSystemRoles && !tool.access.allowedSystemRoles.includes(userRole as SystemRole)) {
return false;
}
if (tool.access.requiresResourceOverview && !hasResourceOverviewAccess) {
return false;
}
if (tool.access.requiresPlanningRead && !permissions.has(PermissionKey.VIEW_PLANNING)) {
return false;
}
if (tool.access.requiresCostView && !permissions.has(PermissionKey.VIEW_COSTS)) {
return false;
}
if (tool.access.requiresAdvancedAssistant && !permissions.has(PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS)) {
return false;
}
return true;
}
export function getAvailableAssistantTools(
permissions: Set<PermissionKey>,
userRole: string,
): ToolDef[] {
const hasResourceOverviewAccess = permissions.has(PermissionKey.VIEW_ALL_RESOURCES)
|| permissions.has(PermissionKey.MANAGE_RESOURCES);
return TOOL_DEFINITIONS.filter((tool) => (
hasToolAccess(tool, permissions, userRole, hasResourceOverviewAccess)
));
return getAvailableAssistantToolsForContext(permissions, userRole);
}
@@ -23,7 +23,18 @@ 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", "get_my_timeline_holiday_overlays", "list_holiday_calendars", "get_holiday_calendar", "preview_resolved_holiday_calendar"],
exactTools: [
"list_holidays_by_region",
"get_resource_holidays",
"get_vacation_balance",
"get_entitlement_summary",
"list_vacations_upcoming",
"get_team_vacation_overlap",
"get_my_timeline_holiday_overlays",
"list_holiday_calendars",
"get_holiday_calendar",
"preview_resolved_holiday_calendar",
],
},
{
keywords: ["resource", "resources", "ressource", "ressourcen", "employee", "mitarbeiter", "person", "people", "team", "chapter", "skill", "skills"],
@@ -43,7 +54,16 @@ const TOOL_SELECTION_HINTS = [
{
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"],
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"],
+151
View File
@@ -1345,6 +1345,15 @@ function getTrpcValidationIssues(error: TRPCError): Array<{
function toAssistantUserResourceLinkError(
error: unknown,
): AssistantToolErrorResult | null {
if (error instanceof TRPCError && error.code === "CONFLICT") {
if (error.message.includes("already linked")) {
return { error: "Resource is already linked to another user." };
}
if (error.message.includes("changed during update")) {
return { error: "Resource link changed during update. Please retry." };
}
}
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
if (error.message.includes("Resource")) {
return { error: "Resource not found with the given criteria." };
@@ -1478,6 +1487,9 @@ function toAssistantTaskActionError(
if (error.message.includes("already completed")) {
return { error: "Task is already completed." };
}
if (error.message.includes("dismissed")) {
return { error: "Task has been dismissed and cannot be executed." };
}
}
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
@@ -1812,6 +1824,14 @@ function toAssistantNotificationCreationError(
return { error: "No recipients matched the broadcast target." };
}
if (
context === "broadcast"
&& trpcError?.code === "BAD_REQUEST"
&& trpcError.message === "Scheduled broadcasts with task metadata are not supported yet."
) {
return { error: "Scheduled broadcasts with task metadata are not supported yet." };
}
if (trpcError?.code === "NOT_FOUND") {
if (trpcError.message.includes("broadcast")) {
return { error: "Broadcast not found with the given criteria." };
@@ -2048,6 +2068,128 @@ export const TOOL_DEFINITIONS: ToolDef[] = withToolAccess([
...settingsAdminToolDefinitions,
], LEGACY_MONOLITHIC_TOOL_ACCESS);
const TOOL_DEFINITIONS_BY_NAME = new Map(
TOOL_DEFINITIONS.map((tool) => [tool.function.name, tool]),
);
type AssistantToolAccessEvaluationContext = Pick<ToolContext, "permissions" | "userRole">;
type AssistantToolAccessFailure =
| { type: "role" }
| {
type: "permission";
permission?: PermissionKey;
message?: string;
};
function hasAssistantResourceOverviewAccess(
permissions: Set<PermissionKey>,
): boolean {
return permissions.has(PermissionKey.VIEW_ALL_RESOURCES)
|| permissions.has(PermissionKey.MANAGE_RESOURCES);
}
function getAssistantToolAccessRequirements(
tool: ToolDef,
): ToolAccessRequirements | undefined {
return tool.access ?? LEGACY_MONOLITHIC_TOOL_ACCESS[tool.function.name];
}
function getAssistantToolAccessFailure(
tool: ToolDef,
ctx: AssistantToolAccessEvaluationContext,
): AssistantToolAccessFailure | null {
const access = getAssistantToolAccessRequirements(tool);
if (!access) {
return null;
}
if (
access.allowedSystemRoles
&& !access.allowedSystemRoles.includes(ctx.userRole as SystemRole)
) {
return { type: "role" };
}
const missingRequiredPermission = access.requiredPermissions?.find(
(permission) => !ctx.permissions.has(permission),
);
if (missingRequiredPermission) {
return {
type: "permission",
permission: missingRequiredPermission,
};
}
if (access.requiresPlanningRead && !ctx.permissions.has(PermissionKey.VIEW_PLANNING)) {
return {
type: "permission",
permission: PermissionKey.VIEW_PLANNING,
};
}
if (access.requiresCostView && !ctx.permissions.has(PermissionKey.VIEW_COSTS)) {
return {
type: "permission",
permission: PermissionKey.VIEW_COSTS,
};
}
if (
access.requiresAdvancedAssistant
&& !ctx.permissions.has(PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS)
) {
return {
type: "permission",
permission: PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
};
}
if (
access.requiresResourceOverview
&& !hasAssistantResourceOverviewAccess(ctx.permissions)
) {
return {
type: "permission",
message: "Permission denied: you need resource overview access to perform this action.",
};
}
return null;
}
function toAssistantToolAccessError(
failure: AssistantToolAccessFailure,
): AssistantVisibleError {
if (failure.type === "role") {
return new AssistantVisibleError("You do not have permission to perform this action.");
}
if (failure.permission) {
return new AssistantVisibleError(
`Permission denied: you need the "${failure.permission}" permission to perform this action.`,
);
}
return new AssistantVisibleError(
failure.message ?? "You do not have permission to perform this action.",
);
}
export function canAccessAssistantTool(
tool: ToolDef,
ctx: AssistantToolAccessEvaluationContext,
): boolean {
return getAssistantToolAccessFailure(tool, ctx) === null;
}
export function getAvailableAssistantToolsForContext(
permissions: Set<PermissionKey>,
userRole: string,
): ToolDef[] {
return TOOL_DEFINITIONS.filter((tool) => canAccessAssistantTool(tool, { permissions, userRole }));
}
// ─── Helpers ────────────────────────────────────────────────────────────────
/** Resolve a responsible person name against existing resources. Returns the exact displayName or an error object. */
@@ -2285,6 +2427,7 @@ const executors = {
...createUserSelfServiceExecutors({
createUserCaller,
createScopedCallerContext,
toAssistantCurrentUserError: toAssistantUserMutationError,
toAssistantTotpEnableError,
}),
...createNotificationsTasksExecutors({
@@ -2352,6 +2495,14 @@ export async function executeTool(
if (!executor) return { content: JSON.stringify({ error: `Unknown tool: ${name}` }) };
try {
const toolDefinition = TOOL_DEFINITIONS_BY_NAME.get(name);
const accessFailure = toolDefinition
? getAssistantToolAccessFailure(toolDefinition, ctx)
: null;
if (accessFailure) {
throw toAssistantToolAccessError(accessFailure);
}
const params = JSON.parse(args);
// Audit-log all mutation tool executions (EGAI 4.1.3.1 / IAAI 3.6.26)
@@ -0,0 +1,770 @@
import type { TRPCContext } from "../../trpc.js";
import { AllocationStatus, PermissionKey, SystemRole } from "@capakraken/shared";
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
export const advancedTimelineToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
name: "find_best_project_resource",
description: "Advanced assistant tool: find the best already-assigned resource on a project for a given period, ranked by remaining capacity or LCR. Holiday- and vacation-aware. Requires viewCosts and advanced assistant permissions.",
parameters: {
type: "object",
properties: {
projectIdentifier: { type: "string", description: "Project ID, short code, or project name." },
startDate: { type: "string", description: "Optional start date in YYYY-MM-DD. Default: today." },
endDate: { type: "string", description: "Optional end date in YYYY-MM-DD. If omitted, durationDays is used." },
durationDays: { type: "integer", description: "Optional duration in calendar days when endDate is omitted. Default: 21." },
minHoursPerDay: { type: "number", description: "Minimum remaining availability per effective working day. Default: 3." },
rankingMode: { type: "string", description: "Ranking mode: lowest_lcr, highest_remaining_hours_per_day, or highest_remaining_hours. Default: lowest_lcr." },
chapter: { type: "string", description: "Optional chapter filter for candidate resources." },
roleName: { type: "string", description: "Optional role filter for candidate resources." },
},
required: ["projectIdentifier"],
},
},
},
{
type: "function",
function: {
name: "get_timeline_entries_view",
description: "Advanced assistant tool: read-only timeline entries view with the same timeline/disposition readmodel used by the app. Returns allocations, demands, assignments, and matching holiday overlays for a period.",
parameters: {
type: "object",
properties: {
startDate: { type: "string", description: "Optional start date in YYYY-MM-DD. Default: today." },
endDate: { type: "string", description: "Optional end date in YYYY-MM-DD. If omitted, durationDays is used." },
durationDays: { type: "integer", description: "Optional duration in calendar days when endDate is omitted. Default: 21." },
resourceIds: { type: "array", items: { type: "string" }, description: "Optional resource IDs to scope the view." },
projectIds: { type: "array", items: { type: "string" }, description: "Optional project IDs to scope the view." },
clientIds: { type: "array", items: { type: "string" }, description: "Optional client IDs to scope the view." },
chapters: { type: "array", items: { type: "string" }, description: "Optional chapter filters." },
eids: { type: "array", items: { type: "string" }, description: "Optional employee IDs to scope the view." },
countryCodes: { type: "array", items: { type: "string" }, description: "Optional country codes such as DE or ES." },
},
},
},
},
{
type: "function",
function: {
name: "get_timeline_holiday_overlays",
description: "Advanced assistant tool: read-only holiday overlays for the timeline, resolved with the same holiday logic as the app. Useful to explain regional holiday differences for assigned or filtered resources.",
parameters: {
type: "object",
properties: {
startDate: { type: "string", description: "Optional start date in YYYY-MM-DD. Default: today." },
endDate: { type: "string", description: "Optional end date in YYYY-MM-DD. If omitted, durationDays is used." },
durationDays: { type: "integer", description: "Optional duration in calendar days when endDate is omitted. Default: 21." },
resourceIds: { type: "array", items: { type: "string" }, description: "Optional resource IDs to scope the overlays." },
projectIds: { type: "array", items: { type: "string" }, description: "Optional project IDs to scope the overlays via matching assignments." },
clientIds: { type: "array", items: { type: "string" }, description: "Optional client IDs to scope the overlays via matching projects." },
chapters: { type: "array", items: { type: "string" }, description: "Optional chapter filters." },
eids: { type: "array", items: { type: "string" }, description: "Optional employee IDs to scope the overlays." },
countryCodes: { type: "array", items: { type: "string" }, description: "Optional country codes such as DE or ES." },
},
},
},
},
{
type: "function",
function: {
name: "get_project_timeline_context",
description: "Advanced assistant tool: read-only project timeline/disposition context. Reuses the same project context readmodel as the app and adds holiday overlays plus cross-project overlap summaries for assigned resources.",
parameters: {
type: "object",
properties: {
projectIdentifier: { type: "string", description: "Project ID, short code, or project name." },
startDate: { type: "string", description: "Optional holiday/conflict window start date in YYYY-MM-DD. Defaults to the project start date when available." },
endDate: { type: "string", description: "Optional holiday/conflict window end date in YYYY-MM-DD. Defaults to the project end date when available." },
durationDays: { type: "integer", description: "Optional duration in calendar days when endDate is omitted." },
},
required: ["projectIdentifier"],
},
},
},
{
type: "function",
function: {
name: "preview_project_shift",
description: "Advanced assistant tool: read-only preview of the timeline shift validation for a project. Uses the same preview logic as the timeline router and does not write changes.",
parameters: {
type: "object",
properties: {
projectIdentifier: { type: "string", description: "Project ID, short code, or project name." },
newStartDate: { type: "string", description: "New start date in YYYY-MM-DD." },
newEndDate: { type: "string", description: "New end date in YYYY-MM-DD." },
},
required: ["projectIdentifier", "newStartDate", "newEndDate"],
},
},
},
{
type: "function",
function: {
name: "update_timeline_allocation_inline",
description: "Advanced assistant mutation: update a timeline allocation inline with the same manager/admin + manageAllocations validation as the timeline API. Supports hours/day, dates, includeSaturday, and role changes. Requires useAssistantAdvancedTools.",
parameters: {
type: "object",
properties: {
allocationId: { type: "string", description: "Allocation, assignment, or demand row ID to update." },
hoursPerDay: { type: "number", description: "Optional new booked hours per day." },
startDate: { type: "string", description: "Optional new start date in YYYY-MM-DD." },
endDate: { type: "string", description: "Optional new end date in YYYY-MM-DD." },
includeSaturday: { type: "boolean", description: "Optional Saturday-working flag stored in metadata." },
role: { type: "string", description: "Optional new role label." },
},
required: ["allocationId"],
},
},
},
{
type: "function",
function: {
name: "apply_timeline_project_shift",
description: "Advanced assistant mutation: apply the real timeline project shift mutation, including validation, date movement, cost recalculation, audit logging, and SSE. Requires manager/admin, manageAllocations, and useAssistantAdvancedTools.",
parameters: {
type: "object",
properties: {
projectIdentifier: { type: "string", description: "Project ID, short code, or project name." },
newStartDate: { type: "string", description: "New project start date in YYYY-MM-DD." },
newEndDate: { type: "string", description: "New project end date in YYYY-MM-DD." },
},
required: ["projectIdentifier", "newStartDate", "newEndDate"],
},
},
},
{
type: "function",
function: {
name: "quick_assign_timeline_resource",
description: "Advanced assistant mutation: create a timeline quick-assignment with the same manager/admin + manageAllocations rules as the timeline UI. Resolves resource and project identifiers before calling the real mutation. Requires useAssistantAdvancedTools.",
parameters: {
type: "object",
properties: {
resourceIdentifier: { type: "string", description: "Resource ID, eid, or display name." },
projectIdentifier: { type: "string", description: "Project ID, short code, or project name." },
startDate: { type: "string", description: "Start date in YYYY-MM-DD." },
endDate: { type: "string", description: "End date in YYYY-MM-DD." },
hoursPerDay: { type: "number", description: "Hours per day. Default: 8." },
role: { type: "string", description: "Role label. Default: Team Member." },
roleId: { type: "string", description: "Optional concrete role ID." },
status: { type: "string", enum: ["PROPOSED", "CONFIRMED", "ACTIVE", "COMPLETED", "CANCELLED"], description: "Assignment status. Default: PROPOSED." },
},
required: ["resourceIdentifier", "projectIdentifier", "startDate", "endDate"],
},
},
},
{
type: "function",
function: {
name: "batch_quick_assign_timeline_resources",
description: "Advanced assistant mutation: batch-create timeline quick-assignments using the same timeline router logic, permission checks, and audit/SSE side effects as the app. Requires manager/admin, manageAllocations, and useAssistantAdvancedTools.",
parameters: {
type: "object",
properties: {
assignments: {
type: "array",
minItems: 1,
maxItems: 50,
items: {
type: "object",
properties: {
resourceIdentifier: { type: "string", description: "Resource ID, eid, or display name." },
projectIdentifier: { type: "string", description: "Project ID, short code, or project name." },
startDate: { type: "string", description: "Start date in YYYY-MM-DD." },
endDate: { type: "string", description: "End date in YYYY-MM-DD." },
hoursPerDay: { type: "number", description: "Hours per day. Default: 8." },
role: { type: "string", description: "Role label. Default: Team Member." },
status: { type: "string", enum: ["PROPOSED", "CONFIRMED", "ACTIVE", "COMPLETED", "CANCELLED"], description: "Assignment status. Default: PROPOSED." },
},
required: ["resourceIdentifier", "projectIdentifier", "startDate", "endDate"],
},
description: "Assignment rows to create in one batch.",
},
},
required: ["assignments"],
},
},
},
{
type: "function",
function: {
name: "batch_shift_timeline_allocations",
description: "Advanced assistant mutation: shift multiple timeline allocations by a shared day delta using the real timeline batch move/resize mutation. Requires manager/admin, manageAllocations, and useAssistantAdvancedTools.",
parameters: {
type: "object",
properties: {
allocationIds: { type: "array", items: { type: "string" }, description: "Allocation IDs to shift." },
daysDelta: { type: "integer", description: "Signed day delta to apply." },
mode: { type: "string", enum: ["move", "resize-start", "resize-end"], description: "Shift mode. Default: move." },
},
required: ["allocationIds", "daysDelta"],
},
},
},
], {
find_best_project_resource: {
requiresPlanningRead: true,
requiresCostView: true,
requiresAdvancedAssistant: true,
},
get_timeline_entries_view: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
requiresAdvancedAssistant: true,
},
get_timeline_holiday_overlays: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
requiresAdvancedAssistant: true,
},
get_project_timeline_context: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
requiresAdvancedAssistant: true,
},
preview_project_shift: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
requiresAdvancedAssistant: true,
},
update_timeline_allocation_inline: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
requiredPermissions: [PermissionKey.MANAGE_ALLOCATIONS],
requiresAdvancedAssistant: true,
},
apply_timeline_project_shift: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
requiredPermissions: [PermissionKey.MANAGE_ALLOCATIONS],
requiresAdvancedAssistant: true,
},
quick_assign_timeline_resource: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
requiredPermissions: [PermissionKey.MANAGE_ALLOCATIONS],
requiresAdvancedAssistant: true,
},
batch_quick_assign_timeline_resources: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
requiredPermissions: [PermissionKey.MANAGE_ALLOCATIONS],
requiresAdvancedAssistant: true,
},
batch_shift_timeline_allocations: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
requiredPermissions: [PermissionKey.MANAGE_ALLOCATIONS],
requiresAdvancedAssistant: true,
},
});
type AssistantToolErrorResult = { error: string };
type ResolvedProject = {
id: string;
name?: string | null;
shortCode?: string | null;
};
type ResolvedResource = {
id: string;
displayName: string;
};
type TimelineMutationContext = "updateInline" | "applyShift" | "quickAssign" | "batchShift";
type BatchQuickAssignmentInput = {
resourceId: string;
projectId: string;
startDate: Date;
endDate: Date;
hoursPerDay?: number;
role?: string;
status?: AllocationStatus;
};
type AdvancedTimelineDeps = {
assertPermission: (ctx: ToolContext, perm: PermissionKey) => void;
createStaffingCaller: (ctx: TRPCContext) => {
getBestProjectResourceDetail: (params: {
projectId: string;
startDate?: Date;
endDate?: Date;
durationDays?: number;
minHoursPerDay?: number;
rankingMode?: "lowest_lcr" | "highest_remaining_hours_per_day" | "highest_remaining_hours";
chapter?: string;
roleName?: string;
}) => Promise<unknown>;
};
createTimelineCaller: (ctx: TRPCContext) => {
getEntriesDetail: (params: {
startDate?: string;
endDate?: string;
durationDays?: number;
resourceIds?: string[];
projectIds?: string[];
clientIds?: string[];
chapters?: string[];
eids?: string[];
countryCodes?: string[];
}) => Promise<unknown>;
getHolidayOverlayDetail: (params: {
startDate?: string;
endDate?: string;
durationDays?: number;
resourceIds?: string[];
projectIds?: string[];
clientIds?: string[];
chapters?: string[];
eids?: string[];
countryCodes?: string[];
}) => Promise<unknown>;
getProjectContextDetail: (params: {
projectId: string;
startDate?: string;
endDate?: string;
durationDays?: number;
}) => Promise<unknown>;
getShiftPreviewDetail: (params: {
projectId: string;
newStartDate: Date;
newEndDate: Date;
}) => Promise<unknown>;
updateAllocationInline: (params: {
allocationId: string;
hoursPerDay?: number;
startDate?: Date;
endDate?: Date;
includeSaturday?: boolean;
role?: string;
}) => Promise<{
id: string;
projectId: string;
resourceId?: string | null;
startDate: Date;
endDate: Date;
hoursPerDay: number;
role?: string | null;
status: string;
}>;
applyShift: (params: {
projectId: string;
newStartDate: Date;
newEndDate: Date;
}) => Promise<{
project: {
id: string;
startDate: Date;
endDate: Date;
};
validation: unknown;
}>;
quickAssign: (params: {
resourceId: string;
projectId: string;
startDate: Date;
endDate: Date;
hoursPerDay?: number;
role?: string;
roleId?: string;
status?: AllocationStatus;
}) => Promise<{
id: string;
projectId: string;
resourceId?: string | null;
startDate: Date | string;
endDate: Date | string;
hoursPerDay: number;
role?: string | null;
status: string;
}>;
batchQuickAssign: (params: { assignments: BatchQuickAssignmentInput[] }) => Promise<{ count: number }>;
batchShiftAllocations: (params: {
allocationIds: string[];
daysDelta: number;
mode?: "move" | "resize-start" | "resize-end";
}) => Promise<{ count: number }>;
};
createScopedCallerContext: (ctx: ToolContext) => TRPCContext;
resolveProjectIdentifier: (
ctx: ToolContext,
identifier: string,
) => Promise<ResolvedProject | AssistantToolErrorResult>;
resolveResourceIdentifier: (
ctx: ToolContext,
identifier: string,
) => Promise<ResolvedResource | AssistantToolErrorResult>;
parseIsoDate: (value: string, fieldName: string) => Date;
fmtDate: (value: Date | null | undefined) => string | null;
isAssistantToolErrorResult: (value: unknown) => value is AssistantToolErrorResult;
toAssistantIndexedFieldError: (index: number, field: string, message: string) => unknown;
toAssistantTimelineMutationError: (error: unknown, context: TimelineMutationContext) => unknown;
};
function toDate(value: Date | string): Date {
return value instanceof Date ? value : new Date(value);
}
export function createAdvancedTimelineExecutors(
deps: AdvancedTimelineDeps,
): Record<string, ToolExecutor> {
return {
async find_best_project_resource(params: {
projectIdentifier: string;
startDate?: string;
endDate?: string;
durationDays?: number;
minHoursPerDay?: number;
rankingMode?: "lowest_lcr" | "highest_remaining_hours_per_day" | "highest_remaining_hours";
chapter?: string;
roleName?: string;
}, ctx: ToolContext) {
deps.assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS);
deps.assertPermission(ctx, PermissionKey.VIEW_COSTS);
const project = await deps.resolveProjectIdentifier(ctx, params.projectIdentifier);
if ("error" in project) {
return project;
}
const caller = deps.createStaffingCaller(deps.createScopedCallerContext(ctx));
return caller.getBestProjectResourceDetail({
projectId: project.id,
...(params.startDate ? { startDate: deps.parseIsoDate(params.startDate, "startDate") } : {}),
...(params.endDate ? { endDate: deps.parseIsoDate(params.endDate, "endDate") } : {}),
...(params.durationDays !== undefined ? { durationDays: params.durationDays } : {}),
...(params.minHoursPerDay !== undefined ? { minHoursPerDay: params.minHoursPerDay } : {}),
...(params.rankingMode ? { rankingMode: params.rankingMode } : {}),
...(params.chapter ? { chapter: params.chapter } : {}),
...(params.roleName ? { roleName: params.roleName } : {}),
});
},
async get_timeline_entries_view(params: {
startDate?: string;
endDate?: string;
durationDays?: number;
resourceIds?: string[];
projectIds?: string[];
clientIds?: string[];
chapters?: string[];
eids?: string[];
countryCodes?: string[];
}, ctx: ToolContext) {
deps.assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS);
const caller = deps.createTimelineCaller(deps.createScopedCallerContext(ctx));
return caller.getEntriesDetail({ ...params });
},
async get_timeline_holiday_overlays(params: {
startDate?: string;
endDate?: string;
durationDays?: number;
resourceIds?: string[];
projectIds?: string[];
clientIds?: string[];
chapters?: string[];
eids?: string[];
countryCodes?: string[];
}, ctx: ToolContext) {
deps.assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS);
const caller = deps.createTimelineCaller(deps.createScopedCallerContext(ctx));
return caller.getHolidayOverlayDetail({ ...params });
},
async get_project_timeline_context(params: {
projectIdentifier: string;
startDate?: string;
endDate?: string;
durationDays?: number;
}, ctx: ToolContext) {
deps.assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS);
const project = await deps.resolveProjectIdentifier(ctx, params.projectIdentifier);
if ("error" in project) {
return project;
}
const caller = deps.createTimelineCaller(deps.createScopedCallerContext(ctx));
return caller.getProjectContextDetail({
projectId: project.id,
...(params.startDate ? { startDate: params.startDate } : {}),
...(params.endDate ? { endDate: params.endDate } : {}),
...(params.durationDays !== undefined ? { durationDays: params.durationDays } : {}),
});
},
async preview_project_shift(params: {
projectIdentifier: string;
newStartDate: string;
newEndDate: string;
}, ctx: ToolContext) {
deps.assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS);
const project = await deps.resolveProjectIdentifier(ctx, params.projectIdentifier);
if ("error" in project) {
return project;
}
const caller = deps.createTimelineCaller(deps.createScopedCallerContext(ctx));
return caller.getShiftPreviewDetail({
projectId: project.id,
newStartDate: deps.parseIsoDate(params.newStartDate, "newStartDate"),
newEndDate: deps.parseIsoDate(params.newEndDate, "newEndDate"),
});
},
async update_timeline_allocation_inline(params: {
allocationId: string;
hoursPerDay?: number;
startDate?: string;
endDate?: string;
includeSaturday?: boolean;
role?: string;
}, ctx: ToolContext) {
deps.assertPermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
deps.assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS);
const caller = deps.createTimelineCaller(deps.createScopedCallerContext(ctx));
let updated;
try {
updated = await caller.updateAllocationInline({
allocationId: params.allocationId,
...(params.hoursPerDay !== undefined ? { hoursPerDay: params.hoursPerDay } : {}),
...(params.startDate ? { startDate: deps.parseIsoDate(params.startDate, "startDate") } : {}),
...(params.endDate ? { endDate: deps.parseIsoDate(params.endDate, "endDate") } : {}),
...(params.includeSaturday !== undefined ? { includeSaturday: params.includeSaturday } : {}),
...(params.role !== undefined ? { role: params.role } : {}),
});
} catch (error) {
const mapped = deps.toAssistantTimelineMutationError(error, "updateInline");
if (mapped) {
return mapped;
}
throw error;
}
return {
__action: "invalidate",
scope: ["allocation", "timeline", "project"],
success: true,
message: `Updated timeline allocation ${updated.id}.`,
allocation: {
id: updated.id,
projectId: updated.projectId,
resourceId: updated.resourceId ?? null,
startDate: deps.fmtDate(updated.startDate),
endDate: deps.fmtDate(updated.endDate),
hoursPerDay: updated.hoursPerDay,
role: updated.role ?? null,
status: updated.status,
},
};
},
async apply_timeline_project_shift(params: {
projectIdentifier: string;
newStartDate: string;
newEndDate: string;
}, ctx: ToolContext) {
deps.assertPermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
deps.assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS);
const project = await deps.resolveProjectIdentifier(ctx, params.projectIdentifier);
if ("error" in project) {
return project;
}
const newStartDate = deps.parseIsoDate(params.newStartDate, "newStartDate");
const newEndDate = deps.parseIsoDate(params.newEndDate, "newEndDate");
const caller = deps.createTimelineCaller(deps.createScopedCallerContext(ctx));
let result;
try {
result = await caller.applyShift({
projectId: project.id,
newStartDate,
newEndDate,
});
} catch (error) {
const mapped = deps.toAssistantTimelineMutationError(error, "applyShift");
if (mapped) {
return mapped;
}
throw error;
}
return {
__action: "invalidate",
scope: ["allocation", "timeline", "project"],
success: true,
message: `Shifted project ${project.shortCode ?? project.name ?? project.id} to ${deps.fmtDate(newStartDate)} - ${deps.fmtDate(newEndDate)}.`,
project: {
id: result.project.id,
startDate: deps.fmtDate(result.project.startDate),
endDate: deps.fmtDate(result.project.endDate),
},
validation: result.validation,
};
},
async quick_assign_timeline_resource(params: {
resourceIdentifier: string;
projectIdentifier: string;
startDate: string;
endDate: string;
hoursPerDay?: number;
role?: string;
roleId?: string;
status?: AllocationStatus;
}, ctx: ToolContext) {
deps.assertPermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
deps.assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS);
const [resource, project] = await Promise.all([
deps.resolveResourceIdentifier(ctx, params.resourceIdentifier),
deps.resolveProjectIdentifier(ctx, params.projectIdentifier),
]);
if ("error" in resource) {
return resource;
}
if ("error" in project) {
return project;
}
const caller = deps.createTimelineCaller(deps.createScopedCallerContext(ctx));
let allocation;
try {
allocation = await caller.quickAssign({
resourceId: resource.id,
projectId: project.id,
startDate: deps.parseIsoDate(params.startDate, "startDate"),
endDate: deps.parseIsoDate(params.endDate, "endDate"),
...(params.hoursPerDay !== undefined ? { hoursPerDay: params.hoursPerDay } : {}),
...(params.role !== undefined ? { role: params.role } : {}),
...(params.roleId !== undefined ? { roleId: params.roleId } : {}),
...(params.status !== undefined ? { status: params.status } : {}),
});
} catch (error) {
const mapped = deps.toAssistantTimelineMutationError(error, "quickAssign");
if (mapped) {
return mapped;
}
throw error;
}
return {
__action: "invalidate",
scope: ["allocation", "timeline", "project"],
success: true,
message: `Quick-assigned ${resource.displayName} to ${project.name} (${project.shortCode ?? project.id}).`,
allocation: {
id: allocation.id,
projectId: allocation.projectId,
resourceId: allocation.resourceId ?? null,
startDate: deps.fmtDate(toDate(allocation.startDate)),
endDate: deps.fmtDate(toDate(allocation.endDate)),
hoursPerDay: allocation.hoursPerDay,
role: allocation.role ?? null,
status: allocation.status,
},
};
},
async batch_quick_assign_timeline_resources(params: {
assignments: Array<{
resourceIdentifier: string;
projectIdentifier: string;
startDate: string;
endDate: string;
hoursPerDay?: number;
role?: string;
status?: AllocationStatus;
}>;
}, ctx: ToolContext) {
deps.assertPermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
deps.assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS);
const resolvedAssignments = await Promise.all(params.assignments.map(async (assignment, index) => {
const [resource, project] = await Promise.all([
deps.resolveResourceIdentifier(ctx, assignment.resourceIdentifier),
deps.resolveProjectIdentifier(ctx, assignment.projectIdentifier),
]);
if ("error" in resource) {
return deps.toAssistantIndexedFieldError(index, "resourceIdentifier", resource.error);
}
if ("error" in project) {
return deps.toAssistantIndexedFieldError(index, "projectIdentifier", project.error);
}
return {
resourceId: resource.id,
projectId: project.id,
startDate: deps.parseIsoDate(assignment.startDate, `assignments[${index}].startDate`),
endDate: deps.parseIsoDate(assignment.endDate, `assignments[${index}].endDate`),
...(assignment.hoursPerDay !== undefined ? { hoursPerDay: assignment.hoursPerDay } : {}),
...(assignment.role !== undefined ? { role: assignment.role } : {}),
...(assignment.status !== undefined ? { status: assignment.status } : {}),
};
}));
const resolutionError = resolvedAssignments.find(deps.isAssistantToolErrorResult);
if (resolutionError) {
return resolutionError;
}
const validAssignments = resolvedAssignments.filter(
(assignment): assignment is BatchQuickAssignmentInput => !deps.isAssistantToolErrorResult(assignment),
);
const caller = deps.createTimelineCaller(deps.createScopedCallerContext(ctx));
let result;
try {
result = await caller.batchQuickAssign({
assignments: validAssignments,
});
} catch (error) {
const mapped = deps.toAssistantTimelineMutationError(error, "quickAssign");
if (mapped) {
return mapped;
}
throw error;
}
return {
__action: "invalidate",
scope: ["allocation", "timeline", "project"],
success: true,
message: `Created ${result.count} timeline quick-assignment(s).`,
count: result.count,
};
},
async batch_shift_timeline_allocations(params: {
allocationIds: string[];
daysDelta: number;
mode?: "move" | "resize-start" | "resize-end";
}, ctx: ToolContext) {
deps.assertPermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
deps.assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS);
const caller = deps.createTimelineCaller(deps.createScopedCallerContext(ctx));
let result;
try {
result = await caller.batchShiftAllocations({
allocationIds: params.allocationIds,
daysDelta: params.daysDelta,
...(params.mode !== undefined ? { mode: params.mode } : {}),
});
} catch (error) {
const mapped = deps.toAssistantTimelineMutationError(error, "batchShift");
if (mapped) {
return mapped;
}
throw error;
}
return {
__action: "invalidate",
scope: ["allocation", "timeline", "project"],
success: true,
message: `Shifted ${result.count} allocation(s) by ${params.daysDelta} day(s).`,
count: result.count,
};
},
};
}
@@ -0,0 +1,497 @@
import type { TRPCContext } from "../../trpc.js";
import { AllocationStatus, PermissionKey, UpdateAssignmentSchema } from "@capakraken/shared";
import { SystemRole } from "@capakraken/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { fmtEur } from "../../lib/format-utils.js";
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
type AssistantToolErrorResult = { error: string };
type ResolvedProject = {
id: string;
name: string;
shortCode: string;
};
type ResolvedResource = {
id: string;
displayName: string;
};
type AllocationPlanningDeps = {
assertPermission: (ctx: ToolContext, perm: PermissionKey) => void;
createAllocationCaller: (ctx: TRPCContext) => {
listView: (params: {
resourceId?: string;
projectId?: string;
status?: AllocationStatus;
}) => Promise<{
assignments: Array<{
id: string;
status: string;
hoursPerDay: number;
dailyCostCents?: number | null;
startDate: Date | string;
endDate: Date | string;
role?: string | null;
roleEntity?: { name?: string | null } | null;
resource?: { displayName?: string | null; eid?: string | null } | null;
project?: { name?: string | null; shortCode?: string | null } | null;
}>;
}>;
ensureAssignment: (params: {
resourceId: string;
projectId: string;
startDate: Date;
endDate: Date;
hoursPerDay: number;
role?: string;
}) => Promise<{
action: "created" | "reactivated";
assignment: {
id: string;
status: string;
};
}>;
resolveAssignment: (params: {
assignmentId?: string;
resourceId?: string;
projectId?: string;
startDate?: Date;
endDate?: Date;
selectionMode: "WINDOW" | "EXACT_START";
excludeCancelled?: boolean;
}) => Promise<{
id: string;
status: string;
startDate: Date;
endDate: Date;
resource: { displayName: string };
project: { name: string; shortCode: string };
}>;
updateAssignment: (params: {
id: string;
data: z.input<typeof UpdateAssignmentSchema>;
}) => Promise<unknown>;
};
createTimelineCaller: (ctx: TRPCContext) => {
getBudgetStatus: (params: { projectId: string }) => Promise<{
projectName: string;
projectCode: string;
budgetCents: number;
confirmedCents: number;
proposedCents: number;
allocatedCents: number;
remainingCents: number;
utilizationPercent: number;
winProbabilityWeightedCents: number;
totalAllocations: number;
}>;
};
createScopedCallerContext: (ctx: ToolContext) => TRPCContext;
resolveProjectIdentifier: (
ctx: ToolContext,
identifier: string,
) => Promise<ResolvedProject | AssistantToolErrorResult>;
resolveResourceIdentifier: (
ctx: ToolContext,
identifier: string,
) => Promise<ResolvedResource | AssistantToolErrorResult>;
parseIsoDate: (value: string, fieldName: string) => Date;
parseOptionalIsoDate: (value: string | undefined, fieldName: string) => Date | undefined;
fmtDate: (value: Date | null | undefined) => string | null;
toAssistantAllocationNotFoundError: (error: unknown) => unknown;
};
export const allocationPlanningReadToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
name: "list_allocations",
description: "List assignments/allocations, optionally filtered by resource or project. Shows who is assigned where, hours/day, dates, and cost.",
parameters: {
type: "object",
properties: {
resourceId: { type: "string", description: "Filter by resource ID" },
projectId: { type: "string", description: "Filter by project ID" },
resourceName: { type: "string", description: "Filter by resource name (partial match)" },
projectCode: { type: "string", description: "Filter by project short code (partial match)" },
status: { type: "string", description: "Filter by status: PROPOSED, CONFIRMED, ACTIVE, COMPLETED, CANCELLED" },
limit: { type: "integer", description: "Max results. Default: 30" },
},
},
},
},
{
type: "function",
function: {
name: "get_budget_status",
description: "Get the budget status of a project: total budget, confirmed/proposed costs, remaining, utilization percentage.",
parameters: {
type: "object",
properties: {
projectId: { type: "string", description: "Project ID or short code" },
},
required: ["projectId"],
},
},
},
], {
list_allocations: {
requiresPlanningRead: true,
},
get_budget_status: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
requiresCostView: true,
},
});
export const allocationPlanningMutationToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
name: "create_allocation",
description: "Create a new allocation/booking for a resource on a project. Requires manageAllocations permission. Always confirm with the user before calling this. Created with PROPOSED status.",
parameters: {
type: "object",
properties: {
resourceId: { type: "string", description: "Resource ID" },
projectId: { type: "string", description: "Project ID" },
startDate: { type: "string", description: "Start date YYYY-MM-DD" },
endDate: { type: "string", description: "End date YYYY-MM-DD" },
hoursPerDay: { type: "number", description: "Hours per day (e.g. 8)" },
role: { type: "string", description: "Optional role name" },
},
required: ["resourceId", "projectId", "startDate", "endDate", "hoursPerDay"],
},
},
},
{
type: "function",
function: {
name: "cancel_allocation",
description: "Cancel an existing allocation. Can find by allocation ID, or by resource name + project code + date range. Requires manageAllocations permission. Always confirm with the user before calling this.",
parameters: {
type: "object",
properties: {
allocationId: { type: "string", description: "Allocation ID (if known)" },
resourceName: { type: "string", description: "Resource name (partial match)" },
projectCode: { type: "string", description: "Project short code (partial match)" },
startDate: { type: "string", description: "Filter by start date YYYY-MM-DD" },
endDate: { type: "string", description: "Filter by end date YYYY-MM-DD" },
},
},
},
},
{
type: "function",
function: {
name: "update_allocation_status",
description: "Change the status of an existing allocation. Can reactivate cancelled allocations, confirm proposed ones, etc. Requires manageAllocations permission. Always confirm with the user before calling.",
parameters: {
type: "object",
properties: {
allocationId: { type: "string", description: "Allocation ID" },
resourceName: { type: "string", description: "Resource name (partial match, used if no allocationId)" },
projectCode: { type: "string", description: "Project short code (partial match, used if no allocationId)" },
startDate: { type: "string", description: "Start date filter YYYY-MM-DD (used if no allocationId)" },
newStatus: { type: "string", description: "New status: PROPOSED, CONFIRMED, ACTIVE, COMPLETED, CANCELLED" },
},
required: ["newStatus"],
},
},
},
], {
create_allocation: {
requiredPermissions: [PermissionKey.MANAGE_ALLOCATIONS],
},
cancel_allocation: {
requiredPermissions: [PermissionKey.MANAGE_ALLOCATIONS],
},
update_allocation_status: {
requiredPermissions: [PermissionKey.MANAGE_ALLOCATIONS],
},
});
export function createAllocationPlanningExecutors(
deps: AllocationPlanningDeps,
): Record<string, ToolExecutor> {
return {
async list_allocations(params: {
resourceId?: string;
projectId?: string;
resourceName?: string;
projectCode?: string;
status?: string;
limit?: number;
}, ctx: ToolContext) {
const caller = deps.createAllocationCaller(deps.createScopedCallerContext(ctx));
const status = params.status && Object.values(AllocationStatus).includes(params.status as AllocationStatus)
? params.status as AllocationStatus
: undefined;
const readModel = await caller.listView({
...(params.resourceId ? { resourceId: params.resourceId } : {}),
...(params.projectId ? { projectId: params.projectId } : {}),
...(status ? { status } : {}),
});
const resourceNameQuery = params.resourceName?.trim().toLowerCase();
const projectCodeQuery = params.projectCode?.trim().toLowerCase();
const limit = Math.min(params.limit ?? 30, 50);
return readModel.assignments
.filter((assignment) => {
if (
resourceNameQuery
&& !assignment.resource?.displayName?.toLowerCase().includes(resourceNameQuery)
) {
return false;
}
if (
projectCodeQuery
&& !assignment.project?.shortCode?.toLowerCase().includes(projectCodeQuery)
) {
return false;
}
return true;
})
.slice(0, limit)
.map((assignment) => ({
id: assignment.id,
resource: assignment.resource?.displayName ?? "Unknown",
resourceEid: assignment.resource?.eid ?? null,
project: assignment.project?.name ?? "Unknown",
projectCode: assignment.project?.shortCode ?? null,
role: assignment.role ?? assignment.roleEntity?.name ?? null,
status: assignment.status,
hoursPerDay: assignment.hoursPerDay,
dailyCost: assignment.dailyCostCents == null ? null : fmtEur(assignment.dailyCostCents),
start: deps.fmtDate(new Date(assignment.startDate)),
end: deps.fmtDate(new Date(assignment.endDate)),
}));
},
async get_budget_status(params: { projectId: string }, ctx: ToolContext) {
const project = await deps.resolveProjectIdentifier(ctx, params.projectId);
if ("error" in project) {
return project;
}
const caller = deps.createTimelineCaller(deps.createScopedCallerContext(ctx));
const budgetStatus = await caller.getBudgetStatus({ projectId: project.id });
if (budgetStatus.budgetCents <= 0) {
return {
project: budgetStatus.projectName,
code: budgetStatus.projectCode,
budget: "Not set",
note: "No budget defined for this project",
totalAllocations: budgetStatus.totalAllocations,
};
}
return {
project: budgetStatus.projectName,
code: budgetStatus.projectCode,
budget: fmtEur(budgetStatus.budgetCents),
confirmed: fmtEur(budgetStatus.confirmedCents),
proposed: fmtEur(budgetStatus.proposedCents),
allocated: fmtEur(budgetStatus.allocatedCents),
remaining: fmtEur(budgetStatus.remainingCents),
utilization: `${budgetStatus.utilizationPercent.toFixed(1)}%`,
winWeighted: fmtEur(budgetStatus.winProbabilityWeightedCents),
};
},
async create_allocation(params: {
resourceId: string;
projectId: string;
startDate: string;
endDate: string;
hoursPerDay: number;
role?: string;
}, ctx: ToolContext) {
deps.assertPermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const [resource, project] = await Promise.all([
deps.resolveResourceIdentifier(ctx, params.resourceId),
deps.resolveProjectIdentifier(ctx, params.projectId),
]);
if ("error" in resource) {
return resource;
}
if ("error" in project) {
return project;
}
const caller = deps.createAllocationCaller(deps.createScopedCallerContext(ctx));
try {
const result = await caller.ensureAssignment({
resourceId: resource.id,
projectId: project.id,
startDate: deps.parseIsoDate(params.startDate, "startDate"),
endDate: deps.parseIsoDate(params.endDate, "endDate"),
hoursPerDay: params.hoursPerDay,
...(params.role ? { role: params.role } : {}),
});
return {
__action: "invalidate",
scope: ["allocation", "timeline"],
success: true,
message: `${result.action === "reactivated" ? "Reactivated" : "Created"} allocation: ${resource.displayName}${project.name} (${project.shortCode}), ${params.hoursPerDay}h/day, ${params.startDate} to ${params.endDate}`,
allocationId: result.assignment.id,
status: result.assignment.status,
};
} catch (error) {
if (error instanceof TRPCError && error.code === "CONFLICT") {
return { error: "Allocation already exists for this resource/project/dates. No new allocation created." };
}
throw error;
}
},
async cancel_allocation(params: {
allocationId?: string;
resourceName?: string;
projectCode?: string;
startDate?: string;
endDate?: string;
}, ctx: ToolContext) {
deps.assertPermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const caller = deps.createAllocationCaller(deps.createScopedCallerContext(ctx));
let resourceId: string | undefined;
let projectId: string | undefined;
if (!params.allocationId && params.resourceName && params.projectCode) {
const [resource, project] = await Promise.all([
deps.resolveResourceIdentifier(ctx, params.resourceName),
deps.resolveProjectIdentifier(ctx, params.projectCode),
]);
if ("error" in resource) {
return resource;
}
if ("error" in project) {
return project;
}
resourceId = resource.id;
projectId = project.id;
}
const startDate = deps.parseOptionalIsoDate(params.startDate, "startDate");
const endDate = deps.parseOptionalIsoDate(params.endDate, "endDate");
let assignment;
try {
assignment = await caller.resolveAssignment({
...(params.allocationId ? { assignmentId: params.allocationId } : {}),
...(resourceId ? { resourceId } : {}),
...(projectId ? { projectId } : {}),
...(startDate ? { startDate } : {}),
...(endDate ? { endDate } : {}),
selectionMode: "WINDOW",
excludeCancelled: true,
});
} catch (error) {
const mapped = deps.toAssistantAllocationNotFoundError(error);
if (mapped) {
return mapped;
}
throw error;
}
try {
await caller.updateAssignment({
id: assignment.id,
data: UpdateAssignmentSchema.parse({ status: AllocationStatus.CANCELLED }),
});
} catch (error) {
const mapped = deps.toAssistantAllocationNotFoundError(error);
if (mapped) {
return mapped;
}
throw error;
}
return {
__action: "invalidate",
scope: ["allocation", "timeline"],
success: true,
message: `Cancelled allocation: ${assignment.resource.displayName}${assignment.project.name} (${assignment.project.shortCode}), ${deps.fmtDate(assignment.startDate)} to ${deps.fmtDate(assignment.endDate)}`,
};
},
async update_allocation_status(params: {
allocationId?: string;
resourceName?: string;
projectCode?: string;
startDate?: string;
newStatus: string;
}, ctx: ToolContext) {
deps.assertPermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const validStatuses = ["PROPOSED", "CONFIRMED", "ACTIVE", "COMPLETED", "CANCELLED"];
if (!validStatuses.includes(params.newStatus)) {
return { error: `Invalid status: ${params.newStatus}. Valid: ${validStatuses.join(", ")}` };
}
const caller = deps.createAllocationCaller(deps.createScopedCallerContext(ctx));
let resourceId: string | undefined;
let projectId: string | undefined;
if (!params.allocationId && params.resourceName && params.projectCode) {
const [resource, project] = await Promise.all([
deps.resolveResourceIdentifier(ctx, params.resourceName),
deps.resolveProjectIdentifier(ctx, params.projectCode),
]);
if ("error" in resource) {
return resource;
}
if ("error" in project) {
return project;
}
resourceId = resource.id;
projectId = project.id;
}
const startDate = deps.parseOptionalIsoDate(params.startDate, "startDate");
let assignment;
try {
assignment = await caller.resolveAssignment({
...(params.allocationId ? { assignmentId: params.allocationId } : {}),
...(resourceId ? { resourceId } : {}),
...(projectId ? { projectId } : {}),
...(startDate ? { startDate } : {}),
selectionMode: "EXACT_START",
});
} catch (error) {
const mapped = deps.toAssistantAllocationNotFoundError(error);
if (mapped) {
return mapped;
}
throw error;
}
const oldStatus = assignment.status;
try {
await caller.updateAssignment({
id: assignment.id,
data: UpdateAssignmentSchema.parse({
status: params.newStatus as AllocationStatus,
}),
});
} catch (error) {
const mapped = deps.toAssistantAllocationNotFoundError(error);
if (mapped) {
return mapped;
}
throw error;
}
return {
__action: "invalidate",
scope: ["allocation", "timeline"],
success: true,
message: `Updated allocation status: ${assignment.resource.displayName}${assignment.project.name} (${assignment.project.shortCode}), ${deps.fmtDate(assignment.startDate)} to ${deps.fmtDate(assignment.endDate)}: ${oldStatus}${params.newStatus}`,
};
},
};
}
@@ -1,6 +1,6 @@
import type { TRPCContext } from "../../trpc.js";
import { PermissionKey } from "@capakraken/shared";
import type { ToolContext, ToolDef, ToolExecutor } from "./shared.js";
import { PermissionKey, SystemRole } from "@capakraken/shared";
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
type AssistantToolErrorResult = { error: string };
@@ -67,7 +67,7 @@ export type ChargeabilityComputationDeps = {
) => Promise<ResolvedProject | AssistantToolErrorResult>;
};
export const chargeabilityComputationReadToolDefinitions: ToolDef[] = [
export const chargeabilityComputationReadToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
@@ -122,7 +122,23 @@ export const chargeabilityComputationReadToolDefinitions: ToolDef[] = [
},
},
},
];
], {
get_chargeability_report: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
requiredPermissions: [PermissionKey.VIEW_COSTS],
requiresAdvancedAssistant: true,
},
get_resource_computation_graph: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
requiredPermissions: [PermissionKey.VIEW_COSTS],
requiresAdvancedAssistant: true,
},
get_project_computation_graph: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
requiredPermissions: [PermissionKey.VIEW_COSTS],
requiresAdvancedAssistant: true,
},
});
export function createChargeabilityComputationExecutors(
deps: ChargeabilityComputationDeps,
@@ -2,11 +2,12 @@ import type { TRPCContext } from "../../trpc.js";
import {
CreateClientSchema,
CreateOrgUnitSchema,
SystemRole,
UpdateClientSchema,
UpdateOrgUnitSchema,
} from "@capakraken/shared";
import { z } from "zod";
import type { ToolContext, ToolDef, ToolExecutor } from "./shared.js";
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
type AssistantToolErrorResult = { error: string };
@@ -49,7 +50,7 @@ type ClientsOrgUnitsDeps = {
) => AssistantToolErrorResult | null;
};
export const clientMutationToolDefinitions: ToolDef[] = [
export const clientMutationToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
@@ -102,9 +103,19 @@ export const clientMutationToolDefinitions: ToolDef[] = [
},
},
},
];
], {
create_client: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
},
update_client: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
},
delete_client: {
allowedSystemRoles: [SystemRole.ADMIN],
},
});
export const orgUnitMutationToolDefinitions: ToolDef[] = [
export const orgUnitMutationToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
@@ -142,7 +153,14 @@ export const orgUnitMutationToolDefinitions: ToolDef[] = [
},
},
},
];
], {
create_org_unit: {
allowedSystemRoles: [SystemRole.ADMIN],
},
update_org_unit: {
allowedSystemRoles: [SystemRole.ADMIN],
},
});
export function createClientsOrgUnitsExecutors(
deps: ClientsOrgUnitsDeps,
@@ -1,5 +1,6 @@
import { SystemRole } from "@capakraken/shared";
import type { TRPCContext } from "../../trpc.js";
import type { ToolContext, ToolDef, ToolExecutor } from "./shared.js";
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
type ConfigReadmodelsDeps = {
createManagementLevelCaller: (ctx: TRPCContext) => {
@@ -77,7 +78,7 @@ type ConfigReadmodelsDeps = {
createScopedCallerContext: (ctx: ToolContext) => TRPCContext;
};
export const configReadmodelToolDefinitions: ToolDef[] = [
export const configReadmodelToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
@@ -118,7 +119,23 @@ export const configReadmodelToolDefinitions: ToolDef[] = [
parameters: { type: "object", properties: {} },
},
},
];
], {
list_management_levels: {
requiresPlanningRead: true,
},
list_utilization_categories: {
requiresPlanningRead: true,
},
list_calculation_rules: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
list_effort_rules: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
list_experience_multipliers: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
});
export function createConfigReadmodelExecutors(
deps: ConfigReadmodelsDeps,
@@ -1,4 +1,5 @@
import type { Prisma } from "@capakraken/db";
import { SystemRole } from "@capakraken/shared";
import type { TRPCContext } from "../../trpc.js";
import {
CreateCountrySchema,
@@ -7,7 +8,7 @@ import {
UpdateMetroCitySchema,
} from "@capakraken/shared";
import { z } from "zod";
import type { ToolContext, ToolDef, ToolExecutor } from "./shared.js";
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
type AssistantToolErrorResult = { error: string };
@@ -52,7 +53,7 @@ type CountryMetroAdminDeps = {
) => AssistantToolErrorResult | null;
};
export const countryMetroAdminToolDefinitions: ToolDef[] = [
export const countryMetroAdminToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
@@ -146,7 +147,23 @@ export const countryMetroAdminToolDefinitions: ToolDef[] = [
},
},
},
];
], {
create_country: {
allowedSystemRoles: [SystemRole.ADMIN],
},
update_country: {
allowedSystemRoles: [SystemRole.ADMIN],
},
create_metro_city: {
allowedSystemRoles: [SystemRole.ADMIN],
},
update_metro_city: {
allowedSystemRoles: [SystemRole.ADMIN],
},
delete_metro_city: {
allowedSystemRoles: [SystemRole.ADMIN],
},
});
export function createCountryMetroAdminExecutors(
deps: CountryMetroAdminDeps,
@@ -2,11 +2,16 @@ import type {
CreateEstimateInput,
EstimateExportFormat,
EstimateStatus,
PermissionKey,
UpdateEstimateDraftInput,
} from "@capakraken/shared";
import { PermissionKey, SystemRole } from "@capakraken/shared";
import type { TRPCContext } from "../../trpc.js";
import type { ToolContext, ToolDef, ToolExecutor } from "./shared.js";
import {
withToolAccess,
type ToolContext,
type ToolDef,
type ToolExecutor,
} from "./shared.js";
type AssistantToolErrorResult = { error: string };
@@ -155,7 +160,7 @@ async function resolveEstimateProjectId(
return project.id;
}
export const estimateReadToolDefinitions: ToolDef[] = [
export const estimateReadToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
@@ -228,9 +233,27 @@ export const estimateReadToolDefinitions: ToolDef[] = [
},
},
},
];
], {
get_estimate_detail: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
requiresCostView: true,
},
list_estimate_versions: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
get_estimate_version_snapshot: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
requiresCostView: true,
},
get_estimate_weekly_phasing: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
get_estimate_commercial_terms: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
});
export const estimateMutationToolDefinitions: ToolDef[] = [
export const estimateMutationToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
@@ -412,7 +435,48 @@ export const estimateMutationToolDefinitions: ToolDef[] = [
},
},
},
];
], {
create_estimate: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
requiredPermissions: [PermissionKey.MANAGE_PROJECTS],
},
clone_estimate: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
requiredPermissions: [PermissionKey.MANAGE_PROJECTS],
},
update_estimate_draft: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
requiredPermissions: [PermissionKey.MANAGE_PROJECTS],
},
submit_estimate_version: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
requiredPermissions: [PermissionKey.MANAGE_PROJECTS],
},
approve_estimate_version: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
requiredPermissions: [PermissionKey.MANAGE_PROJECTS],
},
create_estimate_revision: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
requiredPermissions: [PermissionKey.MANAGE_PROJECTS],
},
create_estimate_export: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
requiredPermissions: [PermissionKey.MANAGE_PROJECTS],
},
create_estimate_planning_handoff: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
requiredPermissions: [PermissionKey.MANAGE_ALLOCATIONS],
},
generate_estimate_weekly_phasing: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
requiredPermissions: [PermissionKey.MANAGE_PROJECTS],
},
update_estimate_commercial_terms: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
requiredPermissions: [PermissionKey.MANAGE_PROJECTS],
},
});
export function createEstimateExecutors(
deps: EstimateToolsDeps,
@@ -1,5 +1,6 @@
import { SystemRole } from "@capakraken/shared";
import type { TRPCContext } from "../../trpc.js";
import type { ToolContext, ToolDef, ToolExecutor } from "./shared.js";
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
type AssistantToolErrorResult = { error: string };
@@ -143,7 +144,7 @@ type NotificationsTasksDeps = {
) => AssistantToolErrorResult | null;
};
export const notificationInboxToolDefinitions: ToolDef[] = [
export const notificationInboxToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
@@ -207,9 +208,13 @@ export const notificationInboxToolDefinitions: ToolDef[] = [
},
},
},
];
], {
create_notification: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
},
});
export const notificationTaskToolDefinitions: ToolDef[] = [
export const notificationTaskToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
@@ -444,7 +449,23 @@ export const notificationTaskToolDefinitions: ToolDef[] = [
},
},
},
];
], {
create_task_for_user: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
},
assign_task: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
},
send_broadcast: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
},
list_broadcasts: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
},
get_broadcast_detail: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
},
});
export function createNotificationsTasksExecutors(
deps: NotificationsTasksDeps,
@@ -25,9 +25,6 @@ type ProjectRecord = ProjectSummaryRecord & {
status?: string;
};
type ParsedCreateProjectInput = ReturnType<typeof CreateProjectSchema.parse>;
type ParsedUpdateProjectInput = ReturnType<typeof UpdateProjectSchema.parse>;
type ResponsiblePersonResolution =
| {
status: "resolved";
@@ -41,17 +38,10 @@ type ResponsiblePersonResolution =
type ProjectToolsDeps = {
assertPermission: (ctx: ToolContext, perm: PermissionKey) => void;
createProjectCaller: (ctx: TRPCContext) => {
searchSummariesDetail: (params: {
search?: string | undefined;
status?: ProjectStatus | undefined;
limit: number;
}) => Promise<unknown>;
searchSummariesDetail: (params: any) => Promise<unknown>;
getByIdentifierDetail: (params: { identifier: string }) => Promise<unknown>;
update: (params: {
id: string;
data: ParsedUpdateProjectInput;
}) => Promise<ProjectSummaryRecord>;
create: (params: ParsedCreateProjectInput) => Promise<ProjectRecord>;
update: (params: any) => Promise<ProjectSummaryRecord>;
create: (params: any) => Promise<ProjectRecord>;
delete: (params: { id: string }) => Promise<unknown>;
generateCover: (params: {
projectId: string;
@@ -1,7 +1,7 @@
import type { TRPCContext } from "../../trpc.js";
import { CreateRoleSchema, UpdateRoleSchema } from "@capakraken/shared";
import { CreateRoleSchema, PermissionKey, SystemRole, UpdateRoleSchema } from "@capakraken/shared";
import { z } from "zod";
import type { ToolContext, ToolDef, ToolExecutor } from "./shared.js";
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
type AssistantToolErrorResult = { error: string };
@@ -63,7 +63,7 @@ type RolesAnalyticsDeps = {
) => AssistantToolErrorResult | null;
};
export const rolesAnalyticsReadToolDefinitions: ToolDef[] = [
export const rolesAnalyticsReadToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
@@ -109,9 +109,22 @@ export const rolesAnalyticsReadToolDefinitions: ToolDef[] = [
},
},
},
];
], {
list_roles: {
requiresPlanningRead: true,
},
search_by_skill: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
get_statistics: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
get_chargeability: {
requiresCostView: true,
},
});
export const rolesAnalyticsMutationToolDefinitions: ToolDef[] = [
export const rolesAnalyticsMutationToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
@@ -160,7 +173,20 @@ export const rolesAnalyticsMutationToolDefinitions: ToolDef[] = [
},
},
},
];
], {
create_role: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
requiredPermissions: [PermissionKey.MANAGE_ROLES],
},
update_role: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
requiredPermissions: [PermissionKey.MANAGE_ROLES],
},
delete_role: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
requiredPermissions: [PermissionKey.MANAGE_ROLES],
},
});
export function createRolesAnalyticsExecutors(
deps: RolesAnalyticsDeps,
@@ -142,6 +142,7 @@ export const scenarioRateAnalysisToolDefinitions: ToolDef[] = withToolAccess([
], {
lookup_rate: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
requiredPermissions: [PermissionKey.VIEW_COSTS],
},
simulate_scenario: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
@@ -0,0 +1,809 @@
import { isAiConfigured } from "../../ai-client.js";
import { resolveSystemSettingsRuntime } from "../../lib/system-settings-runtime.js";
import type { TRPCContext } from "../../trpc.js";
import { SystemRole } from "@capakraken/shared";
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
export const settingsAdminToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
name: "get_system_settings",
description: "Get sanitized system settings through the real settings router. Admin role required.",
parameters: {
type: "object",
properties: {},
},
},
},
{
type: "function",
function: {
name: "update_system_settings",
description: "Update non-secret system settings through the real settings router. Runtime secrets must be provisioned via deployment environment or secret manager. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
aiProvider: { type: "string", enum: ["openai", "azure"] },
azureOpenAiEndpoint: { type: "string" },
azureOpenAiDeployment: { type: "string" },
azureApiVersion: { type: "string" },
aiMaxCompletionTokens: { type: "integer" },
aiTemperature: { type: "number" },
aiSummaryPrompt: { type: "string" },
scoreWeights: { type: "object" },
scoreVisibleRoles: { type: "array", items: { type: "string" } },
smtpHost: { type: "string" },
smtpPort: { type: "integer" },
smtpUser: { type: "string" },
smtpFrom: { type: "string" },
smtpTls: { type: "boolean" },
anonymizationEnabled: { type: "boolean" },
anonymizationDomain: { type: "string" },
anonymizationMode: { type: "string", enum: ["global"] },
azureDalleDeployment: { type: "string" },
azureDalleEndpoint: { type: "string" },
geminiModel: { type: "string" },
imageProvider: { type: "string", enum: ["dalle", "gemini"] },
vacationDefaultDays: { type: "integer" },
timelineUndoMaxSteps: { type: "integer" },
},
},
},
},
{
type: "function",
function: {
name: "clear_stored_runtime_secrets",
description: "Clear legacy database-stored runtime secrets after they have been migrated to deployment secret management. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {},
},
},
},
{
type: "function",
function: {
name: "test_ai_connection",
description: "Run the real AI connection test from system settings. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {},
},
},
},
{
type: "function",
function: {
name: "test_smtp_connection",
description: "Run the real SMTP connection test from system settings. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {},
},
},
},
{
type: "function",
function: {
name: "test_gemini_connection",
description: "Run the real Gemini connection test from system settings. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {},
},
},
},
{
type: "function",
function: {
name: "get_ai_configured",
description: "Get whether AI is configured for the current system via the real settings router. Admin role required.",
parameters: {
type: "object",
properties: {},
},
},
},
{
type: "function",
function: {
name: "list_system_role_configs",
description: "List system role configuration defaults via the real system-role-config router. Admin role required.",
parameters: {
type: "object",
properties: {},
},
},
},
{
type: "function",
function: {
name: "update_system_role_config",
description: "Update one system role configuration via the real system-role-config router. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
role: { type: "string", description: "System role key." },
label: { type: "string", description: "Optional role label." },
description: { type: "string", description: "Optional role description." },
color: { type: "string", description: "Optional role color." },
defaultPermissions: { type: "array", items: { type: "string" }, description: "Optional default permission set." },
},
required: ["role"],
},
},
},
{
type: "function",
function: {
name: "list_webhooks",
description: "List webhooks via the real webhook router. Secrets are masked in assistant responses. Admin role required.",
parameters: {
type: "object",
properties: {},
},
},
},
{
type: "function",
function: {
name: "get_webhook",
description: "Get one webhook via the real webhook router. Secrets are masked in assistant responses. Admin role required.",
parameters: {
type: "object",
properties: {
id: { type: "string", description: "Webhook ID." },
},
required: ["id"],
},
},
},
{
type: "function",
function: {
name: "create_webhook",
description: "Create a webhook via the real webhook router. Secrets are masked in assistant responses. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
name: { type: "string", description: "Webhook name." },
url: { type: "string", description: "Webhook target URL." },
secret: { type: "string", description: "Optional webhook signing secret." },
events: { type: "array", items: { type: "string" }, description: "Subscribed webhook events." },
isActive: { type: "boolean", description: "Whether the webhook is active. Default: true." },
},
required: ["name", "url", "events"],
},
},
},
{
type: "function",
function: {
name: "update_webhook",
description: "Update a webhook via the real webhook router. Secrets are masked in assistant responses. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
id: { type: "string", description: "Webhook ID." },
data: {
type: "object",
properties: {
name: { type: "string" },
url: { type: "string" },
secret: { type: "string" },
events: { type: "array", items: { type: "string" } },
isActive: { type: "boolean" },
},
},
},
required: ["id", "data"],
},
},
},
{
type: "function",
function: {
name: "delete_webhook",
description: "Delete a webhook via the real webhook router. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
id: { type: "string", description: "Webhook ID." },
},
required: ["id"],
},
},
},
{
type: "function",
function: {
name: "test_webhook",
description: "Send a real test payload to a webhook via the real webhook router. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
id: { type: "string", description: "Webhook ID." },
},
required: ["id"],
},
},
},
{
type: "function",
function: {
name: "list_audit_log_entries",
description: "List audit log entries with full audit-router filters and cursor pagination. Controller/manager/admin roles only.",
parameters: {
type: "object",
properties: {
entityType: { type: "string", description: "Optional entity type filter." },
entityId: { type: "string", description: "Optional entity ID filter." },
userId: { type: "string", description: "Optional user ID filter." },
action: { type: "string", description: "Optional action filter such as CREATE, UPDATE, DELETE, SHIFT, IMPORT." },
source: { type: "string", description: "Optional source filter such as ui or assistant." },
startDate: { type: "string", description: "Optional start date in YYYY-MM-DD." },
endDate: { type: "string", description: "Optional end date in YYYY-MM-DD." },
search: { type: "string", description: "Optional case-insensitive search across entity name, summary, and entity type." },
limit: { type: "integer", description: "Max results. Default: 50, max: 100." },
cursor: { type: "string", description: "Optional pagination cursor (last seen audit entry ID)." },
},
},
},
},
{
type: "function",
function: {
name: "get_audit_log_entry",
description: "Get one audit log entry including the full changes payload. Controller/manager/admin roles only.",
parameters: {
type: "object",
properties: {
id: { type: "string", description: "Audit log entry ID." },
},
required: ["id"],
},
},
},
{
type: "function",
function: {
name: "get_audit_log_timeline",
description: "Get audit log entries grouped by day for a time window. Controller/manager/admin roles only.",
parameters: {
type: "object",
properties: {
startDate: { type: "string", description: "Optional start date in YYYY-MM-DD." },
endDate: { type: "string", description: "Optional end date in YYYY-MM-DD." },
limit: { type: "integer", description: "Max entries. Default: 200, max: 500." },
},
},
},
},
{
type: "function",
function: {
name: "get_audit_activity_summary",
description: "Get audit activity totals by entity type, action, and user for a date range. Controller/manager/admin roles only.",
parameters: {
type: "object",
properties: {
startDate: { type: "string", description: "Optional start date in YYYY-MM-DD." },
endDate: { type: "string", description: "Optional end date in YYYY-MM-DD." },
},
},
},
},
{
type: "function",
function: {
name: "get_shoring_ratio",
description: "Get the onshore/offshore staffing ratio for a project. Higher offshore is better (cost-efficient). The threshold is the MINIMUM offshore target. Shows country breakdown and whether the target is met.",
parameters: {
type: "object",
properties: {
projectId: { type: "string", description: "Project ID or short code" },
},
required: ["projectId"],
},
},
},
], {
get_system_settings: {
allowedSystemRoles: [SystemRole.ADMIN],
},
update_system_settings: {
allowedSystemRoles: [SystemRole.ADMIN],
},
clear_stored_runtime_secrets: {
allowedSystemRoles: [SystemRole.ADMIN],
},
test_ai_connection: {
allowedSystemRoles: [SystemRole.ADMIN],
},
test_smtp_connection: {
allowedSystemRoles: [SystemRole.ADMIN],
},
test_gemini_connection: {
allowedSystemRoles: [SystemRole.ADMIN],
},
get_ai_configured: {
allowedSystemRoles: [SystemRole.ADMIN],
},
list_system_role_configs: {
allowedSystemRoles: [SystemRole.ADMIN],
},
update_system_role_config: {
allowedSystemRoles: [SystemRole.ADMIN],
},
list_webhooks: {
allowedSystemRoles: [SystemRole.ADMIN],
},
get_webhook: {
allowedSystemRoles: [SystemRole.ADMIN],
},
create_webhook: {
allowedSystemRoles: [SystemRole.ADMIN],
},
update_webhook: {
allowedSystemRoles: [SystemRole.ADMIN],
},
delete_webhook: {
allowedSystemRoles: [SystemRole.ADMIN],
},
test_webhook: {
allowedSystemRoles: [SystemRole.ADMIN],
},
list_audit_log_entries: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
get_audit_log_entry: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
get_audit_log_timeline: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
get_audit_activity_summary: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
get_shoring_ratio: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
});
type SettingsAdminDeps = {
createSettingsCaller: (ctx: TRPCContext) => {
getSystemSettings: () => Promise<unknown>;
updateSystemSettings: (params: {
aiProvider?: "openai" | "azure";
azureOpenAiEndpoint?: string;
azureOpenAiDeployment?: string;
azureApiVersion?: string;
aiMaxCompletionTokens?: number;
aiTemperature?: number;
aiSummaryPrompt?: string;
scoreWeights?: {
skillDepth: number;
skillBreadth: number;
costEfficiency: number;
chargeability: number;
experience: number;
};
scoreVisibleRoles?: SystemRole[];
smtpHost?: string;
smtpPort?: number;
smtpUser?: string;
smtpFrom?: string;
smtpTls?: boolean;
anonymizationEnabled?: boolean;
anonymizationDomain?: string;
anonymizationMode?: "global";
azureDalleDeployment?: string;
azureDalleEndpoint?: string;
geminiModel?: string;
imageProvider?: "dalle" | "gemini";
vacationDefaultDays?: number;
timelineUndoMaxSteps?: number;
}) => Promise<unknown>;
clearStoredRuntimeSecrets: () => Promise<unknown>;
testAiConnection: () => Promise<unknown>;
testSmtpConnection: () => Promise<unknown>;
testGeminiConnection: () => Promise<unknown>;
};
createSystemRoleConfigCaller: (ctx: TRPCContext) => {
list: () => Promise<unknown>;
update: (params: {
role: string;
label?: string;
description?: string | null;
color?: string | null;
defaultPermissions?: string[];
}) => Promise<unknown>;
};
createWebhookCaller: (ctx: TRPCContext) => {
list: () => Promise<Array<{ secret?: string | null }>>;
getById: (params: { id: string }) => Promise<{ secret?: string | null }>;
create: (params: {
name: string;
url: string;
secret?: string;
events: [string, ...string[]];
isActive?: boolean;
}) => Promise<{ secret?: string | null }>;
update: (params: {
id: string;
data: {
name?: string;
url?: string;
secret?: string | null;
events?: [string, ...string[]];
isActive?: boolean;
};
}) => Promise<{ secret?: string | null }>;
delete: (params: { id: string }) => Promise<unknown>;
test: (params: { id: string }) => Promise<unknown>;
};
createAuditLogCaller: (ctx: TRPCContext) => {
listDetail: (params: {
entityType?: string;
entityId?: string;
userId?: string;
action?: string;
source?: string;
startDate?: Date;
endDate?: Date;
search?: string;
limit?: number;
cursor?: string;
}) => Promise<{ items: unknown[]; nextCursor?: string | null }>;
getByIdDetail: (params: { id: string }) => Promise<unknown>;
getTimelineDetail: (params: {
startDate?: Date;
endDate?: Date;
limit?: number;
}) => Promise<unknown>;
getActivitySummary: (params: {
startDate?: Date;
endDate?: Date;
}) => Promise<unknown>;
};
createProjectCaller: (ctx: TRPCContext) => {
getShoringRatio: (params: { projectId: string }) => Promise<{
totalHours: number;
byCountry: Record<string, { pct: number; resourceCount: number }>;
offshoreRatio: number;
threshold: number;
onshoreRatio: number;
onshoreCountryCode: string;
unknownCount: number;
}>;
};
createScopedCallerContext: (ctx: ToolContext) => TRPCContext;
parseIsoDate: (value: string, fieldName: string) => Date;
resolveProjectIdentifier: (
ctx: ToolContext,
identifier: string,
) => Promise<{ id: string; name: string; shortCode: string } | { error: string }>;
sanitizeWebhook: <T extends { secret?: string | null }>(webhook: T) => Omit<T, "secret"> & { hasSecret: boolean };
sanitizeWebhookList: <T extends { secret?: string | null }>(webhooks: T[]) => Array<Omit<T, "secret"> & { hasSecret: boolean }>;
toAssistantWebhookNotFoundError: (error: unknown) => unknown;
toAssistantWebhookMutationError: (error: unknown, action?: "create" | "update") => unknown;
toAssistantAuditLogEntryNotFoundError: (error: unknown) => unknown;
};
export function createSettingsAdminExecutors(deps: SettingsAdminDeps): Record<string, ToolExecutor> {
return {
async get_system_settings(_params: Record<string, never>, ctx: ToolContext) {
const caller = deps.createSettingsCaller(deps.createScopedCallerContext(ctx));
return caller.getSystemSettings();
},
async update_system_settings(params: {
aiProvider?: "openai" | "azure";
azureOpenAiEndpoint?: string;
azureOpenAiDeployment?: string;
azureApiVersion?: string;
aiMaxCompletionTokens?: number;
aiTemperature?: number;
aiSummaryPrompt?: string;
scoreWeights?: {
skillDepth: number;
skillBreadth: number;
costEfficiency: number;
chargeability: number;
experience: number;
};
scoreVisibleRoles?: SystemRole[];
smtpHost?: string;
smtpPort?: number;
smtpUser?: string;
smtpFrom?: string;
smtpTls?: boolean;
anonymizationEnabled?: boolean;
anonymizationDomain?: string;
anonymizationMode?: "global";
azureDalleDeployment?: string;
azureDalleEndpoint?: string;
geminiModel?: string;
imageProvider?: "dalle" | "gemini";
vacationDefaultDays?: number;
timelineUndoMaxSteps?: number;
}, ctx: ToolContext) {
const caller = deps.createSettingsCaller(deps.createScopedCallerContext(ctx));
return caller.updateSystemSettings(params);
},
async clear_stored_runtime_secrets(_params: Record<string, never>, ctx: ToolContext) {
const caller = deps.createSettingsCaller(deps.createScopedCallerContext(ctx));
return caller.clearStoredRuntimeSecrets();
},
async test_ai_connection(_params: Record<string, never>, ctx: ToolContext) {
const caller = deps.createSettingsCaller(deps.createScopedCallerContext(ctx));
return caller.testAiConnection();
},
async test_smtp_connection(_params: Record<string, never>, ctx: ToolContext) {
const caller = deps.createSettingsCaller(deps.createScopedCallerContext(ctx));
return caller.testSmtpConnection();
},
async test_gemini_connection(_params: Record<string, never>, ctx: ToolContext) {
const caller = deps.createSettingsCaller(deps.createScopedCallerContext(ctx));
return caller.testGeminiConnection();
},
async get_ai_configured(_params: Record<string, never>, ctx: ToolContext) {
const settings = resolveSystemSettingsRuntime(await ctx.db.systemSettings.findUnique({
where: { id: "singleton" },
select: {
aiProvider: true,
azureOpenAiEndpoint: true,
azureOpenAiDeployment: true,
azureOpenAiApiKey: true,
},
}));
return { configured: isAiConfigured(settings) };
},
async list_system_role_configs(_params: Record<string, never>, ctx: ToolContext) {
const caller = deps.createSystemRoleConfigCaller(deps.createScopedCallerContext(ctx));
return caller.list();
},
async update_system_role_config(params: {
role: string;
label?: string;
description?: string | null;
color?: string | null;
defaultPermissions?: string[];
}, ctx: ToolContext) {
const caller = deps.createSystemRoleConfigCaller(deps.createScopedCallerContext(ctx));
return caller.update(params);
},
async list_webhooks(_params: Record<string, never>, ctx: ToolContext) {
const caller = deps.createWebhookCaller(deps.createScopedCallerContext(ctx));
const webhooks = await caller.list();
return deps.sanitizeWebhookList(webhooks);
},
async get_webhook(params: {
id: string;
}, ctx: ToolContext) {
const caller = deps.createWebhookCaller(deps.createScopedCallerContext(ctx));
let webhook;
try {
webhook = await caller.getById({ id: params.id });
} catch (error) {
const mapped = deps.toAssistantWebhookNotFoundError(error);
if (mapped) {
return mapped;
}
throw error;
}
return deps.sanitizeWebhook(webhook);
},
async create_webhook(params: {
name: string;
url: string;
secret?: string;
events: string[];
isActive?: boolean;
}, ctx: ToolContext) {
const caller = deps.createWebhookCaller(deps.createScopedCallerContext(ctx));
let webhook;
try {
webhook = await caller.create({
name: params.name,
url: params.url,
events: params.events as [string, ...string[]],
...(params.secret !== undefined ? { secret: params.secret } : {}),
...(params.isActive !== undefined ? { isActive: params.isActive } : {}),
});
} catch (error) {
const mapped = deps.toAssistantWebhookMutationError(error, "create");
if (mapped) {
return mapped;
}
throw error;
}
return deps.sanitizeWebhook(webhook);
},
async update_webhook(params: {
id: string;
data: {
name?: string;
url?: string;
secret?: string | null;
events?: string[];
isActive?: boolean;
};
}, ctx: ToolContext) {
const caller = deps.createWebhookCaller(deps.createScopedCallerContext(ctx));
let webhook;
try {
webhook = await caller.update({
id: params.id,
data: {
...(params.data.name !== undefined ? { name: params.data.name } : {}),
...(params.data.url !== undefined ? { url: params.data.url } : {}),
...(params.data.secret !== undefined ? { secret: params.data.secret } : {}),
...(params.data.events !== undefined ? { events: params.data.events as [string, ...string[]] } : {}),
...(params.data.isActive !== undefined ? { isActive: params.data.isActive } : {}),
},
});
} catch (error) {
const mapped = deps.toAssistantWebhookMutationError(error, "update");
if (mapped) {
return mapped;
}
throw error;
}
return deps.sanitizeWebhook(webhook);
},
async delete_webhook(params: {
id: string;
}, ctx: ToolContext) {
const caller = deps.createWebhookCaller(deps.createScopedCallerContext(ctx));
try {
await caller.delete({ id: params.id });
} catch (error) {
const mapped = deps.toAssistantWebhookNotFoundError(error);
if (mapped) {
return mapped;
}
throw error;
}
return { ok: true, id: params.id };
},
async test_webhook(params: {
id: string;
}, ctx: ToolContext) {
const caller = deps.createWebhookCaller(deps.createScopedCallerContext(ctx));
try {
return await caller.test({ id: params.id });
} catch (error) {
const mapped = deps.toAssistantWebhookNotFoundError(error);
if (mapped) {
return mapped;
}
throw error;
}
},
async list_audit_log_entries(params: {
entityType?: string;
entityId?: string;
userId?: string;
action?: string;
source?: string;
startDate?: string;
endDate?: string;
search?: string;
limit?: number;
cursor?: string;
}, ctx: ToolContext) {
const caller = deps.createAuditLogCaller(deps.createScopedCallerContext(ctx));
const result = await caller.listDetail({
...(params.entityType ? { entityType: params.entityType } : {}),
...(params.entityId ? { entityId: params.entityId } : {}),
...(params.userId ? { userId: params.userId } : {}),
...(params.action ? { action: params.action } : {}),
...(params.source ? { source: params.source } : {}),
...(params.startDate ? { startDate: deps.parseIsoDate(params.startDate, "startDate") } : {}),
...(params.endDate ? { endDate: deps.parseIsoDate(params.endDate, "endDate") } : {}),
...(params.search ? { search: params.search } : {}),
...(params.cursor ? { cursor: params.cursor } : {}),
...(params.limit !== undefined ? { limit: Math.min(Math.max(params.limit, 1), 100) } : {}),
});
return {
filters: {
entityType: params.entityType ?? null,
entityId: params.entityId ?? null,
userId: params.userId ?? null,
action: params.action ?? null,
source: params.source ?? null,
startDate: params.startDate ?? null,
endDate: params.endDate ?? null,
search: params.search ?? null,
},
itemCount: result.items.length,
nextCursor: result.nextCursor ?? null,
items: result.items,
};
},
async get_audit_log_entry(params: {
id: string;
}, ctx: ToolContext) {
const caller = deps.createAuditLogCaller(deps.createScopedCallerContext(ctx));
try {
return await caller.getByIdDetail({ id: params.id });
} catch (error) {
const mapped = deps.toAssistantAuditLogEntryNotFoundError(error);
if (mapped) {
return mapped;
}
throw error;
}
},
async get_audit_log_timeline(params: {
startDate?: string;
endDate?: string;
limit?: number;
}, ctx: ToolContext) {
const caller = deps.createAuditLogCaller(deps.createScopedCallerContext(ctx));
return caller.getTimelineDetail({
...(params.startDate ? { startDate: deps.parseIsoDate(params.startDate, "startDate") } : {}),
...(params.endDate ? { endDate: deps.parseIsoDate(params.endDate, "endDate") } : {}),
...(params.limit !== undefined ? { limit: Math.min(Math.max(params.limit, 1), 500) } : {}),
});
},
async get_audit_activity_summary(params: {
startDate?: string;
endDate?: string;
}, ctx: ToolContext) {
const caller = deps.createAuditLogCaller(deps.createScopedCallerContext(ctx));
return caller.getActivitySummary({
...(params.startDate ? { startDate: deps.parseIsoDate(params.startDate, "startDate") } : {}),
...(params.endDate ? { endDate: deps.parseIsoDate(params.endDate, "endDate") } : {}),
});
},
async get_shoring_ratio(params: { projectId: string }, ctx: ToolContext) {
const project = await deps.resolveProjectIdentifier(ctx, params.projectId);
if ("error" in project) {
return project;
}
const caller = deps.createProjectCaller(deps.createScopedCallerContext(ctx));
const result = await caller.getShoringRatio({ projectId: project.id });
if (result.totalHours <= 0) {
return `Project "${project.name}" (${project.shortCode}): No active assignments — shoring ratio not available.`;
}
const countryParts = Object.entries(result.byCountry)
.sort((a, b) => b[1].pct - a[1].pct)
.map(([code, info]) => `${code} ${info.pct}% (${info.resourceCount} people)`)
.join(", ");
const status = result.offshoreRatio >= result.threshold
? `Target met (>=${result.threshold}% offshore)`
: result.offshoreRatio >= result.threshold - 10
? `Close to target (${result.threshold}% offshore needed)`
: `Below target — only ${result.offshoreRatio}% offshore, need ${result.threshold}%`;
return `Project "${project.name}" (${project.shortCode}): ${result.onshoreRatio}% onshore (${result.onshoreCountryCode}), ${result.offshoreRatio}% offshore. ${status}. Breakdown: ${countryParts}.${result.unknownCount > 0 ? ` (${result.unknownCount} resource(s) without country)` : ""}`;
},
};
}
@@ -0,0 +1,47 @@
import type { prisma } from "@capakraken/db";
import type { PermissionKey, SystemRole } from "@capakraken/shared";
import type { TRPCContext } from "../../trpc.js";
export type ToolContext = {
db: typeof prisma;
userId: string;
userRole: string;
permissions: Set<PermissionKey>;
session?: TRPCContext["session"];
dbUser?: TRPCContext["dbUser"];
roleDefaults?: TRPCContext["roleDefaults"];
};
export interface ToolAccessRequirements {
requiredPermissions?: PermissionKey[];
allowedSystemRoles?: SystemRole[];
requiresPlanningRead?: boolean;
requiresCostView?: boolean;
requiresAdvancedAssistant?: boolean;
requiresResourceOverview?: boolean;
}
export interface ToolDef {
type: "function";
function: {
name: string;
description: string;
parameters: Record<string, unknown>;
};
access?: ToolAccessRequirements;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type ToolExecutor = (params: any, ctx: ToolContext) => Promise<unknown>;
export function withToolAccess(
tools: ToolDef[],
accessByName: Partial<Record<string, ToolAccessRequirements>>,
): ToolDef[] {
return tools.map((tool) => ({
...tool,
...(accessByName[tool.function.name]
? { access: accessByName[tool.function.name] }
: {}),
}));
}
@@ -1,6 +1,6 @@
import { SystemRole } from "@capakraken/shared";
import type { TRPCContext } from "../../trpc.js";
import type { ToolContext, ToolDef, ToolExecutor } from "./shared.js";
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
type AssistantToolErrorResult = { error: string };
@@ -48,7 +48,7 @@ type UserAdminDeps = {
) => AssistantToolErrorResult | null;
};
export const userAdminToolDefinitions: ToolDef[] = [
export const userAdminToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
@@ -212,7 +212,41 @@ export const userAdminToolDefinitions: ToolDef[] = [
},
},
},
];
], {
list_users: {
allowedSystemRoles: [SystemRole.ADMIN],
},
create_user: {
allowedSystemRoles: [SystemRole.ADMIN],
},
set_user_password: {
allowedSystemRoles: [SystemRole.ADMIN],
},
update_user_role: {
allowedSystemRoles: [SystemRole.ADMIN],
},
update_user_name: {
allowedSystemRoles: [SystemRole.ADMIN],
},
link_user_resource: {
allowedSystemRoles: [SystemRole.ADMIN],
},
auto_link_users_by_email: {
allowedSystemRoles: [SystemRole.ADMIN],
},
set_user_permissions: {
allowedSystemRoles: [SystemRole.ADMIN],
},
reset_user_permissions: {
allowedSystemRoles: [SystemRole.ADMIN],
},
get_effective_user_permissions: {
allowedSystemRoles: [SystemRole.ADMIN],
},
disable_user_totp: {
allowedSystemRoles: [SystemRole.ADMIN],
},
});
export function createUserAdminExecutors(
deps: UserAdminDeps,
@@ -1,5 +1,6 @@
import { SystemRole } from "@capakraken/shared";
import type { TRPCContext } from "../../trpc.js";
import type { ToolContext, ToolDef, ToolExecutor } from "./shared.js";
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
type AssistantToolErrorResult = { error: string };
@@ -35,12 +36,15 @@ type UserSelfServiceDeps = {
activeCount: () => Promise<unknown>;
};
createScopedCallerContext: (ctx: ToolContext) => TRPCContext;
toAssistantCurrentUserError: (
error: unknown,
) => AssistantToolErrorResult | null;
toAssistantTotpEnableError: (
error: unknown,
) => AssistantToolErrorResult | null;
};
export const userSelfServiceToolDefinitions: ToolDef[] = [
export const userSelfServiceToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
@@ -187,11 +191,30 @@ export const userSelfServiceToolDefinitions: ToolDef[] = [
parameters: { type: "object", properties: {} },
},
},
];
], {
list_assignable_users: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
},
get_active_user_count: {
allowedSystemRoles: [SystemRole.ADMIN],
},
});
export function createUserSelfServiceExecutors(
deps: UserSelfServiceDeps,
): Record<string, ToolExecutor> {
async function withCurrentUserErrorMapping<T>(run: () => Promise<T>) {
try {
return await run();
} catch (error) {
const mapped = deps.toAssistantCurrentUserError(error);
if (mapped) {
return mapped;
}
throw error;
}
}
return {
async list_assignable_users(_params: Record<string, never>, ctx: ToolContext) {
const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx));
@@ -200,17 +223,22 @@ export function createUserSelfServiceExecutors(
async get_current_user(_params: Record<string, never>, ctx: ToolContext) {
const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx));
return caller.me();
return withCurrentUserErrorMapping(() => caller.me());
},
async get_dashboard_layout(_params: Record<string, never>, ctx: ToolContext) {
const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx));
return caller.getDashboardLayout();
return withCurrentUserErrorMapping(() => caller.getDashboardLayout());
},
async save_dashboard_layout(params: { layout: unknown[] }, ctx: ToolContext) {
const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx));
const result = await caller.saveDashboardLayout({ layout: params.layout });
const result = await withCurrentUserErrorMapping(
() => caller.saveDashboardLayout({ layout: params.layout }),
);
if ("error" in result) {
return result;
}
return {
__action: "invalidate" as const,
scope: ["dashboard"],
@@ -222,12 +250,17 @@ export function createUserSelfServiceExecutors(
async get_favorite_project_ids(_params: Record<string, never>, ctx: ToolContext) {
const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx));
return caller.getFavoriteProjectIds();
return withCurrentUserErrorMapping(() => caller.getFavoriteProjectIds());
},
async toggle_favorite_project(params: { projectId: string }, ctx: ToolContext) {
const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx));
const result = await caller.toggleFavoriteProject({ projectId: params.projectId });
const result = await withCurrentUserErrorMapping(
() => caller.toggleFavoriteProject({ projectId: params.projectId }),
);
if ("error" in result) {
return result;
}
return {
__action: "invalidate" as const,
scope: ["project"],
@@ -239,7 +272,7 @@ export function createUserSelfServiceExecutors(
async get_column_preferences(_params: Record<string, never>, ctx: ToolContext) {
const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx));
return caller.getColumnPreferences();
return withCurrentUserErrorMapping(() => caller.getColumnPreferences());
},
async set_column_preferences(params: {
@@ -249,12 +282,15 @@ export function createUserSelfServiceExecutors(
rowOrder?: string[] | null;
}, ctx: ToolContext) {
const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx));
const result = await caller.setColumnPreferences({
const result = await withCurrentUserErrorMapping(() => caller.setColumnPreferences({
view: params.view,
...(params.visible !== undefined ? { visible: params.visible } : {}),
...(params.sort !== undefined ? { sort: params.sort } : {}),
...(params.rowOrder !== undefined ? { rowOrder: params.rowOrder } : {}),
});
}));
if ("error" in result) {
return result;
}
return {
__action: "invalidate" as const,
scope: ["user"],
@@ -266,7 +302,10 @@ export function createUserSelfServiceExecutors(
async generate_totp_secret(_params: Record<string, never>, ctx: ToolContext) {
const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx));
const result = await caller.generateTotpSecret();
const result = await withCurrentUserErrorMapping(() => caller.generateTotpSecret());
if ("error" in result) {
return result;
}
return {
__action: "invalidate" as const,
scope: ["user"],
@@ -299,7 +338,7 @@ export function createUserSelfServiceExecutors(
async get_mfa_status(_params: Record<string, never>, ctx: ToolContext) {
const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx));
return caller.getMfaStatus();
return withCurrentUserErrorMapping(() => caller.getMfaStatus());
},
async get_active_user_count(_params: Record<string, never>, ctx: ToolContext) {
@@ -47,7 +47,10 @@ function requireImmediateBroadcastTransaction(
function buildBroadcastCreateData(
senderId: string,
input: z.infer<typeof CreateBroadcastInputSchema>,
recipientCount?: number,
options: {
includeScheduledAt?: boolean;
recipientCount?: number;
} = {},
) {
return {
senderId,
@@ -59,8 +62,10 @@ function buildBroadcastCreateData(
channel: input.channel,
targetType: input.targetType,
...(input.targetValue !== undefined ? { targetValue: input.targetValue } : {}),
...(input.scheduledAt !== undefined ? { scheduledAt: input.scheduledAt } : {}),
...(recipientCount !== undefined ? { recipientCount } : {}),
...(options.includeScheduledAt && input.scheduledAt !== undefined
? { scheduledAt: input.scheduledAt }
: {}),
...(options.recipientCount !== undefined ? { recipientCount: options.recipientCount } : {}),
};
}
@@ -93,7 +98,10 @@ async function createScheduledBroadcastRecord(
recipientIds: string[],
) {
return db.notificationBroadcast.create({
data: buildBroadcastCreateData(senderId, input, recipientIds.length),
data: buildBroadcastCreateData(senderId, input, {
includeScheduledAt: true,
recipientCount: recipientIds.length,
}),
});
}
@@ -183,7 +191,7 @@ export async function createBroadcast(
try {
return await createScheduledBroadcastRecord(ctx.db, senderId, input, recipientIds);
} catch (error) {
rethrowNotificationReferenceError(error);
rethrowNotificationReferenceError(error, "broadcast");
}
}
@@ -198,7 +206,7 @@ export async function createBroadcast(
persistedBroadcast = transactionResult.broadcast;
notificationIds = transactionResult.notificationIds;
} catch (error) {
rethrowNotificationReferenceError(error);
rethrowNotificationReferenceError(error, "broadcast");
}
emitImmediateBroadcastSideEffects(ctx.db, input, notificationIds);
@@ -84,7 +84,10 @@ export function getNotificationErrorCandidates(error: unknown): Array<{
return candidates;
}
export function rethrowNotificationReferenceError(error: unknown): never {
export function rethrowNotificationReferenceError(
error: unknown,
recipientContext: "notification" | "task" | "broadcast" = "notification",
): never {
for (const candidate of getNotificationErrorCandidates(error)) {
const fieldName = typeof candidate.meta?.field_name === "string"
? candidate.meta.field_name.toLowerCase()
@@ -122,9 +125,14 @@ export function rethrowNotificationReferenceError(error: unknown): never {
&& (candidate.code === "P2003" || candidate.code === "P2025")
&& fieldName.includes("userid")
) {
const message = recipientContext === "broadcast"
? "Broadcast recipient user not found"
: recipientContext === "task"
? "Task recipient user not found"
: "Notification recipient user not found";
throw new TRPCError({
code: "NOT_FOUND",
message: "Broadcast recipient user not found",
message,
cause: error,
});
}
@@ -347,25 +355,32 @@ export async function createManagedNotification(
input: z.infer<typeof CreateManagedNotificationInputSchema>,
) {
const currentUserId = requireNotificationDbUser(ctx).id;
const isTaskLikeCategory = input.category === "TASK" || input.category === "APPROVAL";
const taskStatus = input.taskStatus ?? (isTaskLikeCategory ? "OPEN" : undefined);
const notificationId = await createNotification({
db: ctx.db,
userId: input.userId,
type: input.type,
title: input.title,
body: input.body,
entityId: input.entityId,
entityType: input.entityType,
category: input.category,
priority: input.priority,
link: input.link,
taskStatus: input.taskStatus,
taskAction: input.taskAction,
assigneeId: input.assigneeId,
dueDate: input.dueDate,
channel: input.channel,
senderId: input.senderId ?? currentUserId,
});
let notificationId: string;
try {
notificationId = await createNotification({
db: ctx.db,
userId: input.userId,
type: input.type,
title: input.title,
body: input.body,
entityId: input.entityId,
entityType: input.entityType,
category: input.category,
priority: input.priority,
link: input.link,
taskStatus,
taskAction: input.taskAction,
assigneeId: input.assigneeId,
dueDate: input.dueDate,
channel: input.channel,
senderId: input.senderId ?? currentUserId,
});
} catch (error) {
rethrowNotificationReferenceError(error, "notification");
}
if (input.category === "TASK" || input.category === "APPROVAL") {
emitTaskAssigned(input.userId, notificationId);
@@ -12,6 +12,7 @@ import {
import {
AssignTaskInputSchema,
CreateTaskInputSchema,
getNotificationErrorCandidates,
ListNotificationTasksInputSchema,
NotificationIdInputSchema,
type NotificationProcedureContext,
@@ -22,6 +23,19 @@ import {
UpdateNotificationTaskStatusInputSchema,
} from "./notification-procedure-base.js";
function requireTaskActionTransaction(
db: NotificationProcedureContext["db"],
): NonNullable<NotificationProcedureContext["db"]["$transaction"]> {
if (typeof db.$transaction !== "function") {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Task action execution requires transactional persistence support.",
});
}
return db.$transaction.bind(db);
}
export async function listNotificationTasks(
ctx: NotificationProcedureContext,
input: z.infer<typeof ListNotificationTasksInputSchema>,
@@ -207,6 +221,12 @@ export async function executeNotificationTaskAction(
message: "This task is already completed",
});
}
if (task.taskStatus === "DISMISSED") {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "This task has been dismissed",
});
}
const parsed = parseTaskAction(task.taskAction);
if (!parsed) {
@@ -237,21 +257,29 @@ export async function executeNotificationTaskAction(
});
}
const actionResult = await handler.execute(parsed.entityId, ctx.db, userId);
if (!actionResult.success) {
throw new TRPCError({
code: "BAD_REQUEST",
message: actionResult.message,
});
}
const transaction = requireTaskActionTransaction(ctx.db);
const { completedTask, actionResult } = await transaction(async (tx) => {
const actionResult = await handler.execute(parsed.entityId, tx as typeof 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,
},
const completedTask = await tx.notification.update({
where: { id: input.id },
data: {
taskStatus: "DONE",
completedAt: new Date(),
completedBy: userId,
},
});
return {
completedTask,
actionResult,
};
});
emitTaskCompleted(task.userId, task.id);
@@ -270,23 +298,28 @@ export async function createTask(
input: z.infer<typeof CreateTaskInputSchema>,
) {
const senderId = requireNotificationDbUser(ctx).id;
const notificationId = await createNotification({
db: ctx.db,
userId: input.userId,
type: "TASK_CREATED",
category: "TASK",
taskStatus: "OPEN",
title: input.title,
priority: input.priority,
senderId,
channel: input.channel,
body: input.body,
dueDate: input.dueDate,
taskAction: input.taskAction,
entityId: input.entityId,
entityType: input.entityType,
link: input.link,
});
let notificationId: string;
try {
notificationId = await createNotification({
db: ctx.db,
userId: input.userId,
type: "TASK_CREATED",
category: "TASK",
taskStatus: "OPEN",
title: input.title,
priority: input.priority,
senderId,
channel: input.channel,
body: input.body,
dueDate: input.dueDate,
taskAction: input.taskAction,
entityId: input.entityId,
entityType: input.entityType,
link: input.link,
});
} catch (error) {
rethrowNotificationReferenceError(error, "task");
}
emitTaskAssigned(input.userId, notificationId);
@@ -320,7 +353,16 @@ export async function assignTask(
data: { assigneeId: input.assigneeId },
});
} catch (error) {
rethrowNotificationReferenceError(error);
for (const candidate of getNotificationErrorCandidates(error)) {
if (candidate.code === "P2025") {
throw new TRPCError({
code: "NOT_FOUND",
message: "Task not found",
cause: error,
});
}
}
rethrowNotificationReferenceError(error, "task");
}
emitTaskAssigned(input.assigneeId, updated.id);
@@ -0,0 +1,134 @@
import { z } from "zod";
import {
reportEntitySchema,
ReportTemplateConfigSchema,
type EntityKey,
} from "./report-query-config.js";
export const ReportBlueprintSummarySchema = z.object({
id: z.string().min(1),
label: z.string().min(1),
description: z.string().min(1),
entity: reportEntitySchema,
templateName: z.string().min(1),
config: ReportTemplateConfigSchema,
});
export const ReportBlueprintCatalogSchema = z.array(ReportBlueprintSummarySchema);
export type ReportBlueprintSummary = z.infer<typeof ReportBlueprintSummarySchema>;
const REPORT_BLUEPRINTS = ReportBlueprintCatalogSchema.parse([
{
id: "resource-month-sah-transparency",
label: "SAH transparency",
description: "Explains how monthly SAH is reduced by holidays and absences per person.",
entity: "resource_month",
templateName: "Monthly SAH transparency",
config: {
entity: "resource_month",
columns: [
"monthKey",
"displayName",
"eid",
"chapter",
"countryName",
"federalState",
"metroCityName",
"orgUnitName",
"managementLevelGroupName",
"monthlyBaseWorkingDays",
"monthlyEffectiveWorkingDays",
"monthlyBaseAvailableHours",
"monthlyPublicHolidayCount",
"monthlyPublicHolidayWorkdayCount",
"monthlyPublicHolidayHoursDeduction",
"monthlyAbsenceDayEquivalent",
"monthlyAbsenceHoursDeduction",
"monthlySahHours",
"monthlyChargeabilityTargetPct",
"monthlyTargetHours",
],
filters: [],
sortBy: "displayName",
sortDir: "asc",
},
},
{
id: "resource-month-chargeability-audit",
label: "Chargeability audit",
description: "Shows the full path from monthly SAH to booked, target and unassigned hours.",
entity: "resource_month",
templateName: "Monthly chargeability audit",
config: {
entity: "resource_month",
columns: [
"monthKey",
"displayName",
"eid",
"chapter",
"countryName",
"federalState",
"metroCityName",
"orgUnitName",
"managementLevelGroupName",
"monthlyBaseAvailableHours",
"monthlyPublicHolidayCount",
"monthlyPublicHolidayHoursDeduction",
"monthlyAbsenceDayEquivalent",
"monthlyAbsenceHoursDeduction",
"monthlySahHours",
"monthlyChargeabilityTargetPct",
"monthlyTargetHours",
"monthlyActualBookedHours",
"monthlyExpectedBookedHours",
"monthlyActualChargeabilityPct",
"monthlyExpectedChargeabilityPct",
"monthlyUnassignedHours",
"lcrCents",
"currency",
],
filters: [],
sortBy: "monthlyActualChargeabilityPct",
sortDir: "desc",
},
},
{
id: "resource-month-location-comparison",
label: "Location comparison",
description: "Compares holiday impact across country, state and city contexts for the same month.",
entity: "resource_month",
templateName: "Monthly holiday comparison by location",
config: {
entity: "resource_month",
columns: [
"monthKey",
"displayName",
"chapter",
"countryName",
"federalState",
"metroCityName",
"orgUnitName",
"monthlyBaseWorkingDays",
"monthlyBaseAvailableHours",
"monthlyPublicHolidayCount",
"monthlyPublicHolidayWorkdayCount",
"monthlyPublicHolidayHoursDeduction",
"monthlyAbsenceDayEquivalent",
"monthlyAbsenceHoursDeduction",
"monthlySahHours",
"monthlyActualChargeabilityPct",
],
filters: [],
groupBy: "federalState",
sortBy: "monthlyPublicHolidayHoursDeduction",
sortDir: "desc",
},
},
]);
export function listReportBlueprints(entity?: EntityKey): ReportBlueprintSummary[] {
return entity
? REPORT_BLUEPRINTS.filter((blueprint) => blueprint.entity === entity)
: REPORT_BLUEPRINTS;
}
@@ -0,0 +1,214 @@
import { z } from "zod";
import {
reportEntitySchema,
ReportTemplateConfigSchema,
type EntityKey,
} from "./report-query-config.js";
export const RESOURCE_MONTH_RECOMMENDED_COLUMNS = [
"monthKey",
"displayName",
"eid",
"chapter",
"countryCode",
"countryName",
"federalState",
"metroCityName",
"orgUnitName",
"managementLevelGroupName",
"monthlyBaseWorkingDays",
"monthlyEffectiveWorkingDays",
"monthlyBaseAvailableHours",
"monthlyPublicHolidayCount",
"monthlyPublicHolidayWorkdayCount",
"monthlyPublicHolidayHoursDeduction",
"monthlyAbsenceDayEquivalent",
"monthlyAbsenceHoursDeduction",
"monthlySahHours",
"monthlyChargeabilityTargetPct",
"monthlyTargetHours",
"monthlyActualBookedHours",
"monthlyExpectedBookedHours",
"monthlyActualChargeabilityPct",
"monthlyExpectedChargeabilityPct",
"monthlyUnassignedHours",
] as const;
export const RESOURCE_MONTH_MINIMUM_AUDIT_COLUMNS = [
"monthKey",
"displayName",
"countryName",
"federalState",
"metroCityName",
"monthlyPublicHolidayCount",
"monthlyPublicHolidayHoursDeduction",
"monthlyAbsenceDayEquivalent",
"monthlyAbsenceHoursDeduction",
"monthlySahHours",
"monthlyTargetHours",
"monthlyActualBookedHours",
"monthlyUnassignedHours",
] as const;
export const ReportBlueprintSummarySchema = z.object({
id: z.string().min(1),
label: z.string().min(1),
description: z.string().min(1),
entity: reportEntitySchema,
templateName: z.string().min(1),
config: ReportTemplateConfigSchema,
});
export const ReportBlueprintCatalogSchema = z.array(ReportBlueprintSummarySchema);
export type ReportBlueprintSummary = z.infer<typeof ReportBlueprintSummarySchema>;
export type ResourceMonthTemplateCompleteness = {
scope: "resource_month";
isAuditReady: boolean;
isRecommendedComplete: boolean;
recommendedColumnCount: number;
selectedRecommendedColumnCount: number;
minimumAuditColumnCount: number;
selectedMinimumAuditColumnCount: number;
missingRecommendedColumns: string[];
missingMinimumAuditColumns: string[];
};
const REPORT_BLUEPRINTS = ReportBlueprintCatalogSchema.parse([
{
id: "resource-month-sah-transparency",
label: "SAH transparency",
description: "Explains how monthly SAH is reduced by holidays and absences per person.",
entity: "resource_month",
templateName: "Monthly SAH transparency",
config: {
entity: "resource_month",
columns: [
"monthKey",
"displayName",
"eid",
"chapter",
"countryName",
"federalState",
"metroCityName",
"orgUnitName",
"managementLevelGroupName",
"monthlyBaseWorkingDays",
"monthlyEffectiveWorkingDays",
"monthlyBaseAvailableHours",
"monthlyPublicHolidayCount",
"monthlyPublicHolidayWorkdayCount",
"monthlyPublicHolidayHoursDeduction",
"monthlyAbsenceDayEquivalent",
"monthlyAbsenceHoursDeduction",
"monthlySahHours",
"monthlyChargeabilityTargetPct",
"monthlyTargetHours",
],
filters: [],
sortBy: "displayName",
sortDir: "asc",
},
},
{
id: "resource-month-chargeability-audit",
label: "Chargeability audit",
description: "Shows the full path from monthly SAH to booked, target and unassigned hours.",
entity: "resource_month",
templateName: "Monthly chargeability audit",
config: {
entity: "resource_month",
columns: [
"monthKey",
"displayName",
"eid",
"chapter",
"countryName",
"federalState",
"metroCityName",
"orgUnitName",
"managementLevelGroupName",
"monthlyBaseAvailableHours",
"monthlyPublicHolidayCount",
"monthlyPublicHolidayHoursDeduction",
"monthlyAbsenceDayEquivalent",
"monthlyAbsenceHoursDeduction",
"monthlySahHours",
"monthlyChargeabilityTargetPct",
"monthlyTargetHours",
"monthlyActualBookedHours",
"monthlyExpectedBookedHours",
"monthlyActualChargeabilityPct",
"monthlyExpectedChargeabilityPct",
"monthlyUnassignedHours",
"lcrCents",
"currency",
],
filters: [],
sortBy: "monthlyActualChargeabilityPct",
sortDir: "desc",
},
},
{
id: "resource-month-location-comparison",
label: "Location comparison",
description: "Compares holiday impact across country, state and city contexts for the same month.",
entity: "resource_month",
templateName: "Monthly holiday comparison by location",
config: {
entity: "resource_month",
columns: [
"monthKey",
"displayName",
"chapter",
"countryName",
"federalState",
"metroCityName",
"orgUnitName",
"monthlyBaseWorkingDays",
"monthlyBaseAvailableHours",
"monthlyPublicHolidayCount",
"monthlyPublicHolidayWorkdayCount",
"monthlyPublicHolidayHoursDeduction",
"monthlyAbsenceDayEquivalent",
"monthlyAbsenceHoursDeduction",
"monthlySahHours",
"monthlyActualChargeabilityPct",
],
filters: [],
groupBy: "federalState",
sortBy: "monthlyPublicHolidayHoursDeduction",
sortDir: "desc",
},
},
]);
export function listReportBlueprints(entity?: EntityKey): ReportBlueprintSummary[] {
return entity
? REPORT_BLUEPRINTS.filter((blueprint) => blueprint.entity === entity)
: REPORT_BLUEPRINTS;
}
export function buildResourceMonthTemplateCompleteness(
columns: Iterable<string>,
): ResourceMonthTemplateCompleteness {
const selectedColumns = new Set(columns);
const missingRecommendedColumns = RESOURCE_MONTH_RECOMMENDED_COLUMNS
.filter((column) => !selectedColumns.has(column));
const missingMinimumAuditColumns = RESOURCE_MONTH_MINIMUM_AUDIT_COLUMNS
.filter((column) => !selectedColumns.has(column));
return {
scope: "resource_month",
isAuditReady: missingMinimumAuditColumns.length === 0,
isRecommendedComplete: missingRecommendedColumns.length === 0,
recommendedColumnCount: RESOURCE_MONTH_RECOMMENDED_COLUMNS.length,
selectedRecommendedColumnCount: RESOURCE_MONTH_RECOMMENDED_COLUMNS.length - missingRecommendedColumns.length,
minimumAuditColumnCount: RESOURCE_MONTH_MINIMUM_AUDIT_COLUMNS.length,
selectedMinimumAuditColumnCount:
RESOURCE_MONTH_MINIMUM_AUDIT_COLUMNS.length - missingMinimumAuditColumns.length,
missingRecommendedColumns,
missingMinimumAuditColumns,
};
}
+18
View File
@@ -17,15 +17,21 @@ const RESOURCE_COLUMNS: ColumnDef[] = [
{ key: "currency", label: "Currency", dataType: "string" },
{ key: "chargeabilityTarget", label: "Chargeability Target (%)", dataType: "number" },
{ key: "fte", label: "FTE", dataType: "number" },
{ key: "enterpriseId", label: "Enterprise ID", dataType: "string" },
{ key: "portfolioUrl", label: "Portfolio URL", dataType: "string" },
{ key: "valueScore", label: "Value Score", dataType: "number" },
{ key: "valueScoreUpdatedAt", label: "Value Score Updated At", dataType: "date" },
{ key: "isActive", label: "Active", dataType: "boolean" },
{ key: "chgResponsibility", label: "Chg Responsibility", dataType: "boolean" },
{ key: "rolledOff", label: "Rolled Off", dataType: "boolean" },
{ key: "departed", label: "Departed", dataType: "boolean" },
{ key: "postalCode", label: "Postal Code", dataType: "string" },
{ key: "federalState", label: "Federal State", dataType: "string" },
{ key: "blueprint.name", label: "Blueprint", dataType: "string", prismaPath: "blueprint" },
{ key: "country.code", label: "Country Code", dataType: "string", prismaPath: "country" },
{ key: "country.name", label: "Country", dataType: "string", prismaPath: "country" },
{ key: "metroCity.name", label: "Metro City", dataType: "string", prismaPath: "metroCity" },
{ key: "clientUnit.name", label: "Client Unit", dataType: "string", prismaPath: "clientUnit" },
{ key: "orgUnit.name", label: "Org Unit", dataType: "string", prismaPath: "orgUnit" },
{ key: "managementLevelGroup.name", label: "Mgmt Level Group", dataType: "string", prismaPath: "managementLevelGroup" },
{ key: "managementLevel.name", label: "Mgmt Level", dataType: "string", prismaPath: "managementLevel" },
@@ -43,6 +49,9 @@ const PROJECT_COLUMNS: ColumnDef[] = [
{ key: "status", label: "Status", dataType: "string" },
{ key: "winProbability", label: "Win Probability (%)", dataType: "number" },
{ key: "budgetCents", label: "Budget (cents)", dataType: "number" },
{ key: "shoringThreshold", label: "Shoring Threshold (%)", dataType: "number" },
{ key: "onshoreCountryCode", label: "Onshore Country Code", dataType: "string" },
{ key: "color", label: "Color", dataType: "string" },
{ key: "clientId", label: "Client ID", dataType: "string" },
{ key: "startDate", label: "Start Date", dataType: "date" },
{ key: "endDate", label: "End Date", dataType: "date" },
@@ -61,14 +70,23 @@ const ASSIGNMENT_COLUMNS: ColumnDef[] = [
{ key: "resource.displayName", label: "Resource", dataType: "string", prismaPath: "resource" },
{ key: "resource.eid", label: "Resource EID", dataType: "string", prismaPath: "resource" },
{ key: "resource.chapter", label: "Resource Chapter", dataType: "string", prismaPath: "resource" },
{ key: "resource.resourceType", label: "Resource Type", dataType: "string", prismaPath: "resource" },
{ key: "resource.chargeabilityTarget", label: "Resource Chargeability Target (%)", dataType: "number", prismaPath: "resource" },
{ key: "resource.country.code", label: "Resource Country Code", dataType: "string", prismaPath: "resource" },
{ key: "resource.federalState", label: "Resource State", dataType: "string", prismaPath: "resource" },
{ key: "resource.country.name", label: "Resource Country", dataType: "string", prismaPath: "resource" },
{ key: "resource.metroCity.name", label: "Resource City", dataType: "string", prismaPath: "resource" },
{ key: "resource.orgUnit.name", label: "Resource Org Unit", dataType: "string", prismaPath: "resource" },
{ key: "resource.managementLevelGroup.name", label: "Resource Mgmt Level Group", dataType: "string", prismaPath: "resource" },
{ key: "resource.managementLevel.name", label: "Resource Mgmt Level", dataType: "string", prismaPath: "resource" },
{ key: "project.name", label: "Project", dataType: "string", prismaPath: "project" },
{ key: "project.shortCode", label: "Project Code", dataType: "string", prismaPath: "project" },
{ key: "project.status", label: "Project Status", dataType: "string", prismaPath: "project" },
{ key: "project.orderType", label: "Project Order Type", dataType: "string", prismaPath: "project" },
{ key: "project.allocationType", label: "Project Allocation Type", dataType: "string", prismaPath: "project" },
{ key: "project.blueprint.name", label: "Project Blueprint", dataType: "string", prismaPath: "project" },
{ key: "project.client.name", label: "Project Client", dataType: "string", prismaPath: "project" },
{ key: "project.utilizationCategory.name", label: "Project Util. Category", dataType: "string", prismaPath: "project" },
{ key: "startDate", label: "Start Date", dataType: "date" },
{ key: "endDate", label: "End Date", dataType: "date" },
{ key: "hoursPerDay", label: "Hours/Day", dataType: "number" },
@@ -0,0 +1,87 @@
export type ResourceMonthReportExplainability = {
entity: "resource_month";
periodMonth: string | null;
locationContextColumns: string[];
holidayMetricColumns: string[];
absenceMetricColumns: string[];
capacityMetricColumns: string[];
chargeabilityMetricColumns: string[];
missingRecommendedColumns: string[];
notes: string[];
};
export type ReportExplainability = ResourceMonthReportExplainability;
const RESOURCE_MONTH_LOCATION_COLUMNS = [
"countryCode",
"countryName",
"federalState",
"metroCityName",
] as const;
const RESOURCE_MONTH_HOLIDAY_COLUMNS = [
"monthlyPublicHolidayCount",
"monthlyPublicHolidayWorkdayCount",
"monthlyPublicHolidayHoursDeduction",
] as const;
const RESOURCE_MONTH_ABSENCE_COLUMNS = [
"monthlyAbsenceDayEquivalent",
"monthlyAbsenceHoursDeduction",
] as const;
const RESOURCE_MONTH_CAPACITY_COLUMNS = [
"monthlyBaseWorkingDays",
"monthlyEffectiveWorkingDays",
"monthlyBaseAvailableHours",
"monthlySahHours",
"monthlyChargeabilityTargetPct",
"monthlyTargetHours",
] as const;
const RESOURCE_MONTH_CHARGEABILITY_COLUMNS = [
"monthlyActualBookedHours",
"monthlyExpectedBookedHours",
"monthlyActualChargeabilityPct",
"monthlyExpectedChargeabilityPct",
"monthlyUnassignedHours",
] as const;
const RESOURCE_MONTH_RECOMMENDED_COLUMNS = [
...RESOURCE_MONTH_LOCATION_COLUMNS,
...RESOURCE_MONTH_HOLIDAY_COLUMNS,
...RESOURCE_MONTH_ABSENCE_COLUMNS,
...RESOURCE_MONTH_CAPACITY_COLUMNS,
] as const;
function pickIncludedColumns(
requestedColumns: string[],
columnGroup: readonly string[],
): string[] {
return columnGroup.filter((column) => requestedColumns.includes(column));
}
export function buildResourceMonthReportExplainability(
requestedColumns: string[],
periodMonth?: string,
): ResourceMonthReportExplainability {
const missingRecommendedColumns = RESOURCE_MONTH_RECOMMENDED_COLUMNS.filter(
(column) => !requestedColumns.includes(column),
);
return {
entity: "resource_month",
periodMonth: periodMonth ?? null,
locationContextColumns: pickIncludedColumns(requestedColumns, RESOURCE_MONTH_LOCATION_COLUMNS),
holidayMetricColumns: pickIncludedColumns(requestedColumns, RESOURCE_MONTH_HOLIDAY_COLUMNS),
absenceMetricColumns: pickIncludedColumns(requestedColumns, RESOURCE_MONTH_ABSENCE_COLUMNS),
capacityMetricColumns: pickIncludedColumns(requestedColumns, RESOURCE_MONTH_CAPACITY_COLUMNS),
chargeabilityMetricColumns: pickIncludedColumns(requestedColumns, RESOURCE_MONTH_CHARGEABILITY_COLUMNS),
missingRecommendedColumns,
notes: [
"monthlySahHours already reflects region-specific public holidays from country, federal state, and city context when those attributes exist on the resource.",
"monthlyAbsence* metrics only deduct workdays that are not already counted as public holidays.",
"monthlyBaseAvailableHours shows pre-deduction capacity; compare it with holiday, absence, and SAH columns to explain the final monthly availability.",
],
};
}
@@ -1,6 +1,7 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { COLUMN_MAP, type ColumnDef, RESOURCE_MONTH_COLUMNS } from "./report-columns.js";
import type { ReportExplainability } from "./report-explainability.js";
export const ENTITY_MAP = {
resource: "resource",
@@ -17,12 +18,14 @@ const ALLOWED_SCALAR_FIELDS: Record<EntityKey, Set<string>> = {
resource: new Set([
"id", "eid", "displayName", "email", "chapter", "resourceType",
"lcrCents", "ucrCents", "currency", "chargeabilityTarget", "fte",
"enterpriseId", "portfolioUrl", "valueScore", "valueScoreUpdatedAt",
"isActive", "chgResponsibility", "rolledOff", "departed",
"postalCode", "federalState", "createdAt", "updatedAt",
]),
project: new Set([
"id", "shortCode", "name", "orderType", "allocationType", "status",
"winProbability", "budgetCents", "startDate", "endDate",
"winProbability", "budgetCents", "shoringThreshold", "onshoreCountryCode", "color",
"startDate", "endDate",
"responsiblePerson", "createdAt", "updatedAt",
]),
assignment: new Set([
@@ -179,6 +182,7 @@ export interface ReportQueryResult {
columns: string[];
totalCount: number;
groups: ReportGroupSummary[];
explainability?: ReportExplainability;
}
export function validateReportInput(input: ReportInput | z.infer<typeof ReportTemplateConfigSchema>): void {
+14 -2
View File
@@ -15,6 +15,7 @@ import {
validateReportInput,
} from "./report-query-config.js";
import { COLUMN_MAP } from "./report-columns.js";
import { buildResourceMonthReportExplainability } from "./report-explainability.js";
import { buildReportGroups, pickColumns, sortInMemoryRows } from "./report-query-utils.js";
import { executeResourceMonthReport } from "./report-resource-month-query.js";
@@ -65,7 +66,14 @@ export const reportQueryProcedures = {
csvLines.push(outputColumns.map((column) => csvEscape(row[column])).join(","));
});
return { csv: csvLines.join("\n"), rowCount: result.rows.length };
return {
csv: csvLines.join("\n"),
rowCount: result.rows.length,
rows: result.rows,
columns: result.columns,
groups: result.groups,
...(result.explainability ? { explainability: result.explainability } : {}),
};
}),
};
@@ -76,7 +84,11 @@ async function executeReportQuery(
validateReportInput(input);
if (input.entity === "resource_month") {
return executeResourceMonthReport(db, input);
const result = await executeResourceMonthReport(db, input);
return {
...result,
explainability: buildResourceMonthReportExplainability(input.columns, input.periodMonth),
};
}
const { entity, columns, filters, sortBy, sortDir, limit, offset } = input;
@@ -2,6 +2,10 @@ import { Prisma } from "@capakraken/db";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import type { TRPCContext } from "../trpc.js";
import {
buildResourceMonthTemplateCompleteness,
type ResourceMonthTemplateCompleteness,
} from "./report-blueprints-support.js";
import {
type EntityKey,
ReportTemplateConfigSchema,
@@ -28,63 +32,6 @@ type ReportTemplateRecord = {
updatedAt: Date;
};
const RESOURCE_MONTH_RECOMMENDED_COLUMNS = [
"monthKey",
"displayName",
"eid",
"chapter",
"countryCode",
"countryName",
"federalState",
"metroCityName",
"orgUnitName",
"managementLevelGroupName",
"monthlyBaseWorkingDays",
"monthlyEffectiveWorkingDays",
"monthlyBaseAvailableHours",
"monthlyPublicHolidayCount",
"monthlyPublicHolidayWorkdayCount",
"monthlyPublicHolidayHoursDeduction",
"monthlyAbsenceDayEquivalent",
"monthlyAbsenceHoursDeduction",
"monthlySahHours",
"monthlyChargeabilityTargetPct",
"monthlyTargetHours",
"monthlyActualBookedHours",
"monthlyExpectedBookedHours",
"monthlyActualChargeabilityPct",
"monthlyExpectedChargeabilityPct",
"monthlyUnassignedHours",
] as const;
const RESOURCE_MONTH_MINIMUM_AUDIT_COLUMNS = [
"monthKey",
"displayName",
"countryName",
"federalState",
"metroCityName",
"monthlyPublicHolidayCount",
"monthlyPublicHolidayHoursDeduction",
"monthlyAbsenceDayEquivalent",
"monthlyAbsenceHoursDeduction",
"monthlySahHours",
"monthlyTargetHours",
"monthlyActualBookedHours",
"monthlyUnassignedHours",
] as const;
type ResourceMonthTemplateCompleteness = {
scope: "resource_month";
isAuditReady: boolean;
isRecommendedComplete: boolean;
recommendedColumnCount: number;
selectedRecommendedColumnCount: number;
minimumAuditColumnCount: number;
selectedMinimumAuditColumnCount: number;
missingRecommendedColumns: string[];
missingMinimumAuditColumns: string[];
};
type ReportTemplateContext = Pick<TRPCContext, "db" | "dbUser">;
export const SaveReportTemplateInputSchema = z.object({
@@ -303,23 +250,7 @@ function getTemplateCompleteness(
return null;
}
const selectedColumns = new Set(config.columns);
const missingRecommendedColumns = RESOURCE_MONTH_RECOMMENDED_COLUMNS
.filter((column) => !selectedColumns.has(column));
const missingMinimumAuditColumns = RESOURCE_MONTH_MINIMUM_AUDIT_COLUMNS
.filter((column) => !selectedColumns.has(column));
return {
scope: "resource_month",
isAuditReady: missingMinimumAuditColumns.length === 0,
isRecommendedComplete: missingRecommendedColumns.length === 0,
recommendedColumnCount: RESOURCE_MONTH_RECOMMENDED_COLUMNS.length,
selectedRecommendedColumnCount: RESOURCE_MONTH_RECOMMENDED_COLUMNS.length - missingRecommendedColumns.length,
minimumAuditColumnCount: RESOURCE_MONTH_MINIMUM_AUDIT_COLUMNS.length,
selectedMinimumAuditColumnCount: RESOURCE_MONTH_MINIMUM_AUDIT_COLUMNS.length - missingMinimumAuditColumns.length,
missingRecommendedColumns,
missingMinimumAuditColumns,
};
return buildResourceMonthTemplateCompleteness(config.columns);
}
function toTemplateEntity(entity: EntityKey): ReportTemplateEntity {
+8
View File
@@ -1,5 +1,8 @@
import { z } from "zod";
import { controllerProcedure, createTRPCRouter } from "../trpc.js";
import { listReportBlueprints, ReportBlueprintCatalogSchema } from "./report-blueprints-support.js";
import { reportQueryProcedures } from "./report-query-engine.js";
import { reportEntitySchema } from "./report-query-config.js";
import {
DeleteReportTemplateInputSchema,
deleteReportTemplate,
@@ -11,6 +14,11 @@ import {
export const reportRouter = createTRPCRouter({
...reportQueryProcedures,
listBlueprints: controllerProcedure
.input(z.object({ entity: reportEntitySchema.optional() }).default({}))
.output(ReportBlueprintCatalogSchema)
.query(({ input }) => listReportBlueprints(input.entity)),
listTemplates: controllerProcedure.query(({ ctx }) => listReportTemplates(ctx)),
saveTemplate: controllerProcedure
@@ -77,6 +77,16 @@ export interface TimelineAllocationCarveResult {
resourceId: string | null;
}
export interface TimelineAllocationExtractResult {
action: "unchanged" | "extracted";
allocationGroupId: string;
extractedAllocationId: string;
updatedAllocationIds: string[];
createdAllocationIds: string[];
projectId: string;
resourceId: string | null;
}
export async function carveTimelineAllocationRange(input: {
db: PrismaClient;
allocationId: string;
@@ -183,3 +193,119 @@ export async function carveTimelineAllocationRange(input: {
};
});
}
export async function extractTimelineAllocationFragment(input: {
db: PrismaClient;
allocationId: string;
startDate: Date;
endDate: Date;
}): Promise<TimelineAllocationExtractResult> {
const resolved = await loadAllocationEntry(input.db, input.allocationId);
if (resolved.kind !== "assignment") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Only staffed assignments can currently be extracted into fragments.",
});
}
const extractStart = toUtcCalendarDate(input.startDate);
const extractEnd = toUtcCalendarDate(input.endDate);
if (extractEnd < extractStart) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Extract end date must be on or after the extract start date.",
});
}
const assignment = resolved.assignment;
const assignmentStart = toUtcCalendarDate(assignment.startDate);
const assignmentEnd = toUtcCalendarDate(assignment.endDate);
if (extractStart < assignmentStart || extractEnd > assignmentEnd) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "The requested extract range must be fully inside the existing allocation.",
});
}
const { groupId, metadata } = readFragmentMetadata(assignment.metadata, assignment.id);
const hasLeftFragment = extractStart > assignmentStart;
const hasRightFragment = extractEnd < assignmentEnd;
if (!hasLeftFragment && !hasRightFragment) {
return {
action: "unchanged",
allocationGroupId: groupId,
extractedAllocationId: assignment.id,
updatedAllocationIds: [],
createdAllocationIds: [],
projectId: assignment.projectId,
resourceId: assignment.resourceId,
};
}
return input.db.$transaction(async (tx) => {
const createdAllocationIds: string[] = [];
const updated = await updateAssignment(
tx as unknown as Parameters<typeof updateAssignment>[0],
assignment.id,
{
startDate: extractStart,
endDate: extractEnd,
metadata,
},
);
if (hasLeftFragment) {
const createdLeft = await createAssignment(
tx as unknown as Parameters<typeof createAssignment>[0],
{
demandRequirementId: assignment.demandRequirementId ?? undefined,
resourceId: assignment.resourceId,
projectId: assignment.projectId,
startDate: assignmentStart,
endDate: addDays(extractStart, -1),
hoursPerDay: assignment.hoursPerDay,
percentage: assignment.percentage,
role: assignment.role ?? undefined,
roleId: assignment.roleId ?? undefined,
dailyCostCents: assignment.dailyCostCents,
status: toSharedAllocationStatus(assignment.status),
metadata,
},
);
createdAllocationIds.push(createdLeft.id);
}
if (hasRightFragment) {
const createdRight = await createAssignment(
tx as unknown as Parameters<typeof createAssignment>[0],
{
demandRequirementId: assignment.demandRequirementId ?? undefined,
resourceId: assignment.resourceId,
projectId: assignment.projectId,
startDate: addDays(extractEnd, 1),
endDate: assignmentEnd,
hoursPerDay: assignment.hoursPerDay,
percentage: assignment.percentage,
role: assignment.role ?? undefined,
roleId: assignment.roleId ?? undefined,
dailyCostCents: assignment.dailyCostCents,
status: toSharedAllocationStatus(assignment.status),
metadata,
},
);
createdAllocationIds.push(createdRight.id);
}
return {
action: "extracted" as const,
allocationGroupId: groupId,
extractedAllocationId: updated.id,
updatedAllocationIds: [updated.id],
createdAllocationIds,
projectId: assignment.projectId,
resourceId: assignment.resourceId,
};
});
}
@@ -36,3 +36,15 @@ export const timelineBatchShiftAllocationsInputSchema = z.object({
daysDelta: z.number().int().min(-3650).max(3650),
mode: z.enum(["move", "resize-start", "resize-end"]).default("move"),
});
export const timelineCarveAllocationRangeInputSchema = z.object({
allocationId: z.string(),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
});
export const timelineExtractAllocationFragmentInputSchema = z.object({
allocationId: z.string(),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
});
@@ -3,14 +3,18 @@ import { PermissionKey } from "@capakraken/shared";
import { managerProcedure, requirePermission } from "../trpc.js";
import {
UpdateAllocationHoursSchema,
timelineCarveAllocationRangeInputSchema,
timelineBatchQuickAssignInputSchema,
timelineBatchShiftAllocationsInputSchema,
timelineExtractAllocationFragmentInputSchema,
timelineQuickAssignInputSchema,
} from "./timeline-allocation-mutation-schema-support.js";
import {
applyTimelineAllocationBatchShiftMutation,
carveTimelineAllocationRangeMutation,
createTimelineBatchQuickAssignMutation,
createTimelineQuickAssignMutation,
extractTimelineAllocationFragmentMutation,
updateTimelineAllocationInlineMutation,
} from "./timeline-allocation-router-support.js";
@@ -50,4 +54,28 @@ export const timelineAllocationMutationProcedures = {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
return applyTimelineAllocationBatchShiftMutation(ctx.db as PrismaClient, input);
}),
carveAllocationRange: managerProcedure
.input(timelineCarveAllocationRangeInputSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
return carveTimelineAllocationRangeMutation({
db: ctx.db as PrismaClient,
allocationId: input.allocationId,
startDate: input.startDate,
endDate: input.endDate,
});
}),
extractAllocationFragment: managerProcedure
.input(timelineExtractAllocationFragmentInputSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
return extractTimelineAllocationFragmentMutation({
db: ctx.db as PrismaClient,
allocationId: input.allocationId,
startDate: input.startDate,
endDate: input.endDate,
});
}),
};
@@ -1,9 +1,17 @@
import type { PrismaClient } from "@capakraken/db";
import { emitAllocationUpdated } from "../sse/event-bus.js";
import {
emitAllocationCreated,
emitAllocationDeleted,
emitAllocationUpdated,
} from "../sse/event-bus.js";
import {
createTimelineBatchQuickAssignments,
createTimelineQuickAssignment,
} from "./timeline-allocation-assignment-procedure-support.js";
import {
carveTimelineAllocationRange,
extractTimelineAllocationFragment,
} from "./timeline-allocation-fragment-support.js";
import { applyTimelineInlineAllocationUpdate } from "./timeline-allocation-inline-support.js";
import { shiftTimelineAllocations } from "./timeline-allocation-procedure-support.js";
@@ -67,3 +75,68 @@ export async function applyTimelineAllocationBatchShiftMutation(
) {
return shiftTimelineAllocations(db, input);
}
export async function carveTimelineAllocationRangeMutation(input: {
db: PrismaClient;
allocationId: string;
startDate: Date;
endDate: Date;
}) {
const result = await carveTimelineAllocationRange({
db: input.db,
allocationId: input.allocationId,
startDate: input.startDate,
endDate: input.endDate,
});
for (const allocationId of result.updatedAllocationIds) {
emitAllocationUpdated({
id: allocationId,
projectId: result.projectId,
resourceId: result.resourceId,
});
}
for (const allocationId of result.createdAllocationIds) {
emitAllocationCreated({
id: allocationId,
projectId: result.projectId,
resourceId: result.resourceId,
});
}
for (const allocationId of result.deletedAllocationIds) {
emitAllocationDeleted(allocationId, result.projectId, result.resourceId);
}
return result;
}
export async function extractTimelineAllocationFragmentMutation(input: {
db: PrismaClient;
allocationId: string;
startDate: Date;
endDate: Date;
}) {
const result = await extractTimelineAllocationFragment({
db: input.db,
allocationId: input.allocationId,
startDate: input.startDate,
endDate: input.endDate,
});
for (const allocationId of result.updatedAllocationIds) {
emitAllocationUpdated({
id: allocationId,
projectId: result.projectId,
resourceId: result.resourceId,
});
}
for (const allocationId of result.createdAllocationIds) {
emitAllocationCreated({
id: allocationId,
projectId: result.projectId,
resourceId: result.resourceId,
});
}
return result;
}
@@ -36,14 +36,49 @@ export interface ProjectHealthRow {
assignmentCount: number;
spentCents: number;
}>;
derivation?: {
periodStart: string;
periodEnd: string;
calendarContextCount: number;
holidayAwareAssignmentCount: number;
fallbackAssignmentCount: number;
baseSpentCents: number;
adjustedSpentCents: number;
publicHolidayDayEquivalent: number;
publicHolidayCostDeductionCents: number;
absenceDayEquivalent: number;
absenceCostDeductionCents: number;
};
}
const DAY_KEYS: (keyof WeekdayAvailability)[] = [
"sunday",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
];
function hasAvailability<T extends { availability?: unknown }>(
resource: T | null | undefined,
): resource is T & { availability: WeekdayAvailability } {
return resource !== null && resource !== undefined && resource.availability !== null && resource.availability !== undefined;
}
function toIsoDate(value: Date): string {
return value.toISOString().slice(0, 10);
}
function getDailyAvailabilityHours(
availability: WeekdayAvailability,
date: Date,
): number {
const dayKey = DAY_KEYS[date.getUTCDay()];
return dayKey ? (availability[dayKey] ?? 0) : 0;
}
function toUtcDayStart(value: Date): Date {
return new Date(Date.UTC(
value.getUTCFullYear(),
@@ -66,6 +101,75 @@ function buildLocationKey(input: {
});
}
function summarizeSpentDerivation(input: {
availability: WeekdayAvailability;
startDate: Date;
endDate: Date;
dailyCostCents: number;
context: Awaited<ReturnType<typeof loadDailyAvailabilityContexts>> extends Map<string, infer TValue>
? TValue | undefined
: never;
}) {
const baseSpentCents = calculateEffectiveAllocationCostCents({
availability: input.availability,
startDate: input.startDate,
endDate: input.endDate,
dailyCostCents: input.dailyCostCents,
periodStart: input.startDate,
periodEnd: input.endDate,
context: undefined,
});
const adjustedSpentCents = calculateEffectiveAllocationCostCents({
availability: input.availability,
startDate: input.startDate,
endDate: input.endDate,
dailyCostCents: input.dailyCostCents,
periodStart: input.startDate,
periodEnd: input.endDate,
context: input.context,
});
let publicHolidayDayEquivalent = 0;
let publicHolidayCostDeductionCents = 0;
let absenceDayEquivalent = 0;
let absenceCostDeductionCents = 0;
const cursor = new Date(input.startDate);
cursor.setUTCHours(0, 0, 0, 0);
const end = new Date(input.endDate);
end.setUTCHours(0, 0, 0, 0);
while (cursor <= end) {
const baseHours = getDailyAvailabilityHours(input.availability, cursor);
if (baseHours > 0) {
const isoDate = toIsoDate(cursor);
if (input.context?.holidayDates.has(isoDate)) {
publicHolidayDayEquivalent += 1;
publicHolidayCostDeductionCents += input.dailyCostCents;
} else {
const vacationFraction = Math.min(
1,
Math.max(0, input.context?.vacationFractionsByDate.get(isoDate) ?? 0),
);
if (vacationFraction > 0) {
absenceDayEquivalent += vacationFraction;
absenceCostDeductionCents += input.dailyCostCents * vacationFraction;
}
}
}
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
return {
baseSpentCents,
adjustedSpentCents,
publicHolidayDayEquivalent,
publicHolidayCostDeductionCents: Math.round(publicHolidayCostDeductionCents),
absenceDayEquivalent,
absenceCostDeductionCents: Math.round(absenceCostDeductionCents),
};
}
export async function getDashboardProjectHealth(
db: PrismaClient,
): Promise<ProjectHealthRow[]> {
@@ -147,20 +251,67 @@ export async function getDashboardProjectHealth(
contextEnd,
);
const spentByProject = new Map<string, number>();
const derivationByProject = new Map<string, {
periodStart: string;
periodEnd: string;
calendarContextCount: number;
holidayAwareAssignmentCount: number;
fallbackAssignmentCount: number;
baseSpentCents: number;
adjustedSpentCents: number;
publicHolidayDayEquivalent: number;
publicHolidayCostDeductionCents: number;
absenceDayEquivalent: number;
absenceCostDeductionCents: number;
}>();
const calendarLocationsByProject = new Map<string, Map<string, NonNullable<ProjectHealthRow["calendarLocations"]>[number]>>();
for (const a of assignments) {
const cost = hasAvailability(a.resource)
? calculateEffectiveAllocationCostCents({
const derivation = hasAvailability(a.resource)
? summarizeSpentDerivation({
availability: a.resource.availability as unknown as WeekdayAvailability,
startDate: a.startDate,
endDate: a.endDate,
dailyCostCents: a.dailyCostCents ?? 0,
periodStart: a.startDate,
periodEnd: a.endDate,
context: contexts.get(a.resource.id),
})
: (a.dailyCostCents ?? 0) * calculateInclusiveDays(a.startDate, a.endDate);
: null;
const cost = derivation?.adjustedSpentCents
?? (a.dailyCostCents ?? 0) * calculateInclusiveDays(a.startDate, a.endDate);
spentByProject.set(a.projectId, (spentByProject.get(a.projectId) ?? 0) + cost);
const existingDerivation = derivationByProject.get(a.projectId) ?? {
periodStart: toIsoDate(a.startDate),
periodEnd: toIsoDate(a.endDate),
calendarContextCount: 0,
holidayAwareAssignmentCount: 0,
fallbackAssignmentCount: 0,
baseSpentCents: 0,
adjustedSpentCents: 0,
publicHolidayDayEquivalent: 0,
publicHolidayCostDeductionCents: 0,
absenceDayEquivalent: 0,
absenceCostDeductionCents: 0,
};
if (a.startDate < new Date(existingDerivation.periodStart)) {
existingDerivation.periodStart = toIsoDate(a.startDate);
}
if (a.endDate > new Date(existingDerivation.periodEnd)) {
existingDerivation.periodEnd = toIsoDate(a.endDate);
}
if (derivation) {
existingDerivation.calendarContextCount += 1;
existingDerivation.holidayAwareAssignmentCount += 1;
existingDerivation.baseSpentCents += derivation.baseSpentCents;
existingDerivation.adjustedSpentCents += derivation.adjustedSpentCents;
existingDerivation.publicHolidayDayEquivalent += derivation.publicHolidayDayEquivalent;
existingDerivation.publicHolidayCostDeductionCents += derivation.publicHolidayCostDeductionCents;
existingDerivation.absenceDayEquivalent += derivation.absenceDayEquivalent;
existingDerivation.absenceCostDeductionCents += derivation.absenceCostDeductionCents;
} else {
existingDerivation.fallbackAssignmentCount += 1;
existingDerivation.baseSpentCents += cost;
existingDerivation.adjustedSpentCents += cost;
}
derivationByProject.set(a.projectId, existingDerivation);
if (a.resource) {
const projectLocations = calendarLocationsByProject.get(a.projectId) ?? new Map();
const locationKey = buildLocationKey({
@@ -229,6 +380,7 @@ export async function getDashboardProjectHealth(
(budgetHealth + staffingHealth + timelineHealth) / 3,
);
const remainingBudgetCents = p.budgetCents == null ? null : p.budgetCents - spentCents;
const derivation = derivationByProject.get(p.id);
return {
id: p.id,
@@ -254,6 +406,7 @@ export async function getDashboardProjectHealth(
timelineStatus,
calendarLocations: Array.from(calendarLocationsByProject.get(p.id)?.values() ?? [])
.sort((left, right) => right.spentCents - left.spentCents),
...(derivation ? { derivation } : {}),
};
});
@@ -371,7 +371,7 @@ export async function createEstimateExport(
? (targetVersion.projectSnapshot as Record<string, unknown>)
: null;
const payload = serializeEstimateExport(
const payload = await serializeEstimateExport(
{
estimate,
version: targetVersion,
+8 -8
View File
@@ -13,14 +13,14 @@
"db:migrate": "node ../../scripts/prisma-with-env.mjs migrate dev --schema ./prisma/schema.prisma",
"db:migrate:deploy": "node ../../scripts/prisma-with-env.mjs migrate deploy --schema ./prisma/schema.prisma",
"db:validate": "node ../../scripts/prisma-with-env.mjs validate --schema ./prisma/schema.prisma",
"db:seed": "node ../../scripts/with-env.mjs tsx src/seed.ts",
"db:seed:holiday-demo-resources": "node ../../scripts/with-env.mjs tsx src/seed-holiday-demo-resources.ts",
"db:seed:holidays": "node ../../scripts/with-env.mjs tsx src/seed-holiday-calendars.ts",
"db:seed:dispo-v2": "node ../../scripts/with-env.mjs tsx src/seed-dispo-v2.ts",
"db:seed:vacations": "node ../../scripts/with-env.mjs tsx src/seed-vacations.ts",
"db:reset:dispo": "node ../../scripts/with-env.mjs tsx src/reset-dispo-import.ts",
"db:import:dispo": "node ../../scripts/with-env.mjs tsx src/import-dispo-batch.ts",
"db:excel": "node ../../scripts/with-env.mjs tsx src/generate-excel.ts",
"db:seed": "node ../../scripts/with-env.mjs tsx packages/db/src/seed.ts",
"db:seed:holiday-demo-resources": "node ../../scripts/with-env.mjs tsx packages/db/src/seed-holiday-demo-resources.ts",
"db:seed:holidays": "node ../../scripts/with-env.mjs tsx packages/db/src/seed-holiday-calendars.ts",
"db:seed:dispo-v2": "node ../../scripts/with-env.mjs tsx packages/db/src/seed-dispo-v2.ts",
"db:seed:vacations": "node ../../scripts/with-env.mjs tsx packages/db/src/seed-vacations.ts",
"db:reset:dispo": "node ../../scripts/with-env.mjs tsx packages/db/src/reset-dispo-import.ts",
"db:import:dispo": "node ../../scripts/with-env.mjs tsx packages/db/src/import-dispo-batch.ts",
"db:excel": "node ../../scripts/with-env.mjs tsx packages/db/src/generate-excel.ts",
"db:studio": "node ../../scripts/prisma-with-env.mjs studio --schema ./prisma/schema.prisma",
"db:generate": "node ../../scripts/prisma-with-env.mjs generate --schema ./prisma/schema.prisma",
"test:unit": "tsx --test src/*.test.ts",
@@ -0,0 +1,8 @@
ALTER TABLE "vacations"
ADD COLUMN "deductedDays" DOUBLE PRECISION,
ADD COLUMN "holidayCountryCode" TEXT,
ADD COLUMN "holidayCountryName" TEXT,
ADD COLUMN "holidayFederalState" TEXT,
ADD COLUMN "holidayMetroCityName" TEXT,
ADD COLUMN "holidayCalendarDates" JSONB,
ADD COLUMN "holidayLegacyPublicHolidayDates" JSONB;
+7
View File
@@ -1363,6 +1363,13 @@ model Vacation {
rejectionReason String?
isHalfDay Boolean @default(false)
halfDayPart String? // "MORNING" | "AFTERNOON"
deductedDays Float?
holidayCountryCode String?
holidayCountryName String?
holidayFederalState String?
holidayMetroCityName String?
holidayCalendarDates Json? @db.JsonB
holidayLegacyPublicHolidayDates Json? @db.JsonB
requestedById String
approvedById String?
approvedAt DateTime?
@@ -1,6 +1,7 @@
import { PrismaClient, type HolidayCalendarEntry } from "@prisma/client";
import { buildHolidayCalendarSeedDefinitions } from "./holiday-calendar-seed-data.js";
import { loadWorkspaceEnv } from "./load-workspace-env.js";
import { assertCapaKrakenDbTarget } from "./safe-destructive-env.js";
loadWorkspaceEnv();
@@ -49,6 +50,7 @@ async function findScopedCalendar(input: {
}
async function main() {
assertCapaKrakenDbTarget("db:seed:holidays");
console.log("Seeding holiday calendars for 2026-2027...");
const countries = await prisma.country.findMany({
@@ -1,6 +1,7 @@
import { PrismaClient, type Prisma } from "@prisma/client";
import { getHolidayDemoCityNamesByCountry, getHolidayDemoProfileForIndex } from "./holiday-demo-profiles.js";
import { loadWorkspaceEnv } from "./load-workspace-env.js";
import { assertCapaKrakenDbTarget } from "./safe-destructive-env.js";
loadWorkspaceEnv();
@@ -52,6 +53,7 @@ async function ensureCities(countryByCode: Map<string, CountryRecord>) {
}
async function main() {
assertCapaKrakenDbTarget("db:seed:holiday-demo-resources");
console.log("Normalizing active resources for holiday demo profiles...");
const countrySeeds = [
+5
View File
@@ -1,5 +1,9 @@
import { BlueprintTarget, FieldType } from "@capakraken/shared";
import { PrismaClient } from "@prisma/client";
import { loadWorkspaceEnv } from "./load-workspace-env.js";
import { assertCapaKrakenDbTarget } from "./safe-destructive-env.js";
loadWorkspaceEnv();
const prisma = new PrismaClient();
@@ -1174,6 +1178,7 @@ const rolePresetsMotion = [
// ─── Main ───────────────────────────────────────────────────────────────────
async function main() {
assertCapaKrakenDbTarget("db:update:blueprints");
console.log("Starting blueprint update...\n");
// Blueprints to update in-place (by name — preserves PKs and FKs)
@@ -96,4 +96,24 @@ describe("checkDuplicateAssignment", () => {
const result = checkDuplicateAssignment("r1", "p1", "2026-04-01", "2026-05-01", [proposed]);
expect(result.isDuplicate).toBe(true);
});
it("formats conflict dates from local-midnight Date objects without shifting the calendar day", () => {
const localDateAssignment: ExistingAssignment = {
...base,
startDate: new Date(2026, 3, 6),
endDate: new Date(2026, 3, 17),
};
const result = checkDuplicateAssignment(
"r1",
"p1",
new Date(2026, 3, 9),
new Date(2026, 3, 10),
[localDateAssignment],
);
expect(result.isDuplicate).toBe(true);
expect(result.message).toContain("2026-04-06");
expect(result.message).toContain("2026-04-17");
});
});
@@ -20,10 +20,28 @@ export interface DuplicateCheckResult {
const ACTIVE_STATUSES = new Set(["CONFIRMED", "ACTIVE", "PROPOSED"]);
function toTime(d: Date | string): number {
const dt = typeof d === "string" ? new Date(d) : d;
dt.setHours(0, 0, 0, 0);
return dt.getTime();
function toCalendarParts(value: Date | string): [year: number, month: number, day: number] {
if (typeof value === "string") {
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})/u);
if (match) {
return [Number(match[1]), Number(match[2]), Number(match[3])];
}
const date = new Date(value);
return [date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate()];
}
return [value.getFullYear(), value.getMonth() + 1, value.getDate()];
}
function toCalendarTime(value: Date | string): number {
const [year, month, day] = toCalendarParts(value);
return Date.UTC(year, month - 1, day);
}
function formatCalendarDate(value: Date | string): string {
const [year, month, day] = toCalendarParts(value);
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
/**
@@ -45,8 +63,8 @@ export function checkDuplicateAssignment(
existingAssignments: ExistingAssignment[],
excludeAssignmentId?: string,
): DuplicateCheckResult {
const newStart = toTime(startDate);
const newEnd = toTime(endDate);
const newStart = toCalendarTime(startDate);
const newEnd = toCalendarTime(endDate);
for (const existing of existingAssignments) {
// Skip self (for updates)
@@ -60,12 +78,12 @@ export function checkDuplicateAssignment(
if (!ACTIVE_STATUSES.has(existing.status)) continue;
// Check date overlap: existingStart <= newEnd && existingEnd >= newStart
const existStart = toTime(existing.startDate);
const existEnd = toTime(existing.endDate);
const existStart = toCalendarTime(existing.startDate);
const existEnd = toCalendarTime(existing.endDate);
if (existStart <= newEnd && existEnd >= newStart) {
const startStr = new Date(existing.startDate).toISOString().slice(0, 10);
const endStr = new Date(existing.endDate).toISOString().slice(0, 10);
const startStr = formatCalendarDate(existing.startDate);
const endStr = formatCalendarDate(existing.endDate);
return {
isDuplicate: true,
conflictingAssignment: existing,
+2 -1
View File
@@ -3,6 +3,7 @@ export * from "./publicHolidays.js";
export * from "./columns.js";
export * from "./dispo-import.js";
export * from "./data-classification.js";
export * from "./comment-entities.js";
export const BUDGET_WARNING_THRESHOLDS = {
INFO: 70,
@@ -11,6 +12,7 @@ export const BUDGET_WARNING_THRESHOLDS = {
} as const;
export const DEFAULT_WORKING_HOURS_PER_DAY = 8;
export const DEFAULT_OPENAI_MODEL = "gpt-5.4";
export const DEFAULT_AVAILABILITY = {
monday: 8,
@@ -58,7 +60,6 @@ export const SSE_EVENT_TYPES = {
TASK_COMPLETED: "task.completed",
TASK_STATUS_CHANGED: "task.status_changed",
REMINDER_DUE: "reminder.due",
BROADCAST_SENT: "broadcast.sent",
PING: "ping",
} as const;