feat(platform): checkpoint current implementation state
This commit is contained in:
@@ -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",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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.";
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user