feat(platform): checkpoint current implementation state

This commit is contained in:
2026-04-01 07:42:03 +02:00
parent 3e53471f05
commit 8c5be51251
125 changed files with 10269 additions and 17808 deletions
@@ -84,7 +84,14 @@ export const allocationAssignmentProcedures = {
}))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const result = await ensureAssignmentRecord(ctx.db, input);
const result = await ensureAssignmentRecord(ctx.db, {
resourceId: input.resourceId,
projectId: input.projectId,
startDate: input.startDate,
endDate: input.endDate,
hoursPerDay: input.hoursPerDay,
...(input.role !== undefined ? { role: input.role } : {}),
});
if (result.action === "reactivated") {
publishAllocationUpdated(ctx.db, {
@@ -1,294 +1,11 @@
import { PermissionKey, SystemRole } from "@capakraken/shared";
import { ADVANCED_ASSISTANT_TOOLS, TOOL_DEFINITIONS } from "./assistant-tools.js";
import { type PermissionKey } from "@capakraken/shared";
import { getAvailableAssistantToolsForContext } from "./assistant-tools.js";
import type { ToolDef } from "./assistant-tools/shared.js";
const TOOL_PERMISSION_MAP: Record<string, string> = {
update_resource: "manageResources",
create_resource: "manageResources",
deactivate_resource: "manageResources",
create_role: PermissionKey.MANAGE_ROLES,
update_role: PermissionKey.MANAGE_ROLES,
delete_role: PermissionKey.MANAGE_ROLES,
update_project: "manageProjects",
create_project: "manageProjects",
delete_project: "manageProjects",
create_estimate: "manageProjects",
clone_estimate: "manageProjects",
update_estimate_draft: "manageProjects",
submit_estimate_version: "manageProjects",
approve_estimate_version: "manageProjects",
create_estimate_revision: "manageProjects",
create_estimate_export: "manageProjects",
generate_estimate_weekly_phasing: "manageProjects",
update_estimate_commercial_terms: "manageProjects",
generate_project_cover: "manageProjects",
remove_project_cover: "manageProjects",
import_csv_data: PermissionKey.IMPORT_DATA,
create_allocation: "manageAllocations",
cancel_allocation: "manageAllocations",
update_allocation_status: "manageAllocations",
update_timeline_allocation_inline: "manageAllocations",
apply_timeline_project_shift: "manageAllocations",
quick_assign_timeline_resource: "manageAllocations",
batch_quick_assign_timeline_resources: "manageAllocations",
batch_shift_timeline_allocations: "manageAllocations",
create_demand: "manageAllocations",
fill_demand: "manageAllocations",
create_estimate_planning_handoff: "manageAllocations",
execute_task_action: "manageAllocations",
};
const COST_TOOLS = new Set([
"get_budget_status",
"get_chargeability",
"get_chargeability_report",
"get_resource_computation_graph",
"get_project_computation_graph",
"resolve_rate",
"list_rate_cards",
"get_estimate_detail",
"get_estimate_version_snapshot",
"find_best_project_resource",
"get_staffing_suggestions",
]);
const PLANNING_READ_TOOLS = new Set([
"list_allocations",
"list_demands",
"list_blueprints",
"get_blueprint",
"list_clients",
"list_roles",
"list_management_levels",
"list_utilization_categories",
"check_resource_availability",
"get_staffing_suggestions",
"find_capacity",
"find_best_project_resource",
]);
const RESOURCE_OVERVIEW_TOOLS = new Set([
"search_resources",
"get_country",
"list_org_units",
]);
const CONTROLLER_ONLY_TOOLS = new Set([
"search_by_skill",
"search_projects",
"get_project",
"search_estimates",
"get_timeline_entries_view",
"get_timeline_holiday_overlays",
"get_project_timeline_context",
"preview_project_shift",
"get_statistics",
"get_dashboard_detail",
"get_skill_gaps",
"get_project_health",
"get_budget_forecast",
"query_change_history",
"get_entity_timeline",
"export_resources_csv",
"export_projects_csv",
"list_audit_log_entries",
"get_audit_log_entry",
"get_audit_log_timeline",
"get_audit_activity_summary",
"get_chargeability_report",
"get_resource_computation_graph",
"get_project_computation_graph",
"get_estimate_detail",
"list_estimate_versions",
"get_estimate_version_snapshot",
"get_estimate_weekly_phasing",
"get_estimate_commercial_terms",
]);
const MANAGER_ONLY_TOOLS = new Set([
"import_csv_data",
"list_assignable_users",
"create_notification",
"update_timeline_allocation_inline",
"apply_timeline_project_shift",
"quick_assign_timeline_resource",
"batch_quick_assign_timeline_resources",
"batch_shift_timeline_allocations",
"create_estimate",
"clone_estimate",
"update_estimate_draft",
"submit_estimate_version",
"approve_estimate_version",
"create_estimate_revision",
"create_estimate_export",
"create_estimate_planning_handoff",
"generate_estimate_weekly_phasing",
"update_estimate_commercial_terms",
"create_task_for_user",
"assign_task",
"send_broadcast",
"list_broadcasts",
"get_broadcast_detail",
"approve_vacation",
"reject_vacation",
"get_pending_vacation_approvals",
"get_entitlement_summary",
"set_entitlement",
"create_role",
"update_role",
"delete_role",
"create_client",
"update_client",
]);
const ADMIN_ONLY_TOOLS = new Set([
"list_users",
"get_active_user_count",
"create_user",
"set_user_password",
"update_user_role",
"update_user_name",
"link_user_resource",
"auto_link_users_by_email",
"set_user_permissions",
"reset_user_permissions",
"get_effective_user_permissions",
"disable_user_totp",
"list_dispo_import_batches",
"get_dispo_import_batch",
"stage_dispo_import_batch",
"validate_dispo_import_batch",
"cancel_dispo_import_batch",
"list_dispo_staged_resources",
"list_dispo_staged_projects",
"list_dispo_staged_assignments",
"list_dispo_staged_vacations",
"list_dispo_staged_unresolved_records",
"resolve_dispo_staged_record",
"commit_dispo_import_batch",
"get_system_settings",
"update_system_settings",
"clear_stored_runtime_secrets",
"get_ai_configured",
"test_ai_connection",
"test_smtp_connection",
"test_gemini_connection",
"list_system_role_configs",
"update_system_role_config",
"list_webhooks",
"get_webhook",
"create_webhook",
"update_webhook",
"delete_webhook",
"test_webhook",
"create_org_unit",
"update_org_unit",
"create_country",
"update_country",
"create_metro_city",
"update_metro_city",
"delete_metro_city",
"list_holiday_calendars",
"get_holiday_calendar",
"create_holiday_calendar",
"update_holiday_calendar",
"delete_holiday_calendar",
"create_holiday_calendar_entry",
"update_holiday_calendar_entry",
"delete_holiday_calendar_entry",
]);
function hasLegacyToolAccess(
toolName: string,
permissions: Set<PermissionKey>,
userRole: string,
hasResourceOverviewAccess: boolean,
hasControllerAccess: boolean,
hasManagerAccess: boolean,
) {
const requiredPerm = TOOL_PERMISSION_MAP[toolName];
if (requiredPerm && !permissions.has(requiredPerm as PermissionKey)) {
return false;
}
if (ADMIN_ONLY_TOOLS.has(toolName) && userRole !== SystemRole.ADMIN) {
return false;
}
if (MANAGER_ONLY_TOOLS.has(toolName) && !hasManagerAccess) {
return false;
}
if (RESOURCE_OVERVIEW_TOOLS.has(toolName) && !hasResourceOverviewAccess) {
return false;
}
if (CONTROLLER_ONLY_TOOLS.has(toolName) && !hasControllerAccess) {
return false;
}
if (PLANNING_READ_TOOLS.has(toolName) && !permissions.has(PermissionKey.VIEW_PLANNING)) {
return false;
}
if (COST_TOOLS.has(toolName) && !permissions.has(PermissionKey.VIEW_COSTS)) {
return false;
}
if (ADVANCED_ASSISTANT_TOOLS.has(toolName) && !permissions.has(PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS)) {
return false;
}
return true;
}
function hasToolAccess(
tool: ToolDef,
permissions: Set<PermissionKey>,
userRole: string,
hasResourceOverviewAccess: boolean,
): boolean {
if (!tool.access) {
const hasControllerAccess = userRole === SystemRole.ADMIN
|| userRole === SystemRole.MANAGER
|| userRole === SystemRole.CONTROLLER;
const hasManagerAccess = userRole === SystemRole.ADMIN
|| userRole === SystemRole.MANAGER;
return hasLegacyToolAccess(
tool.function.name,
permissions,
userRole,
hasResourceOverviewAccess,
hasControllerAccess,
hasManagerAccess,
);
}
if (tool.access.requiredPermissions?.some((permission) => !permissions.has(permission))) {
return false;
}
if (tool.access.allowedSystemRoles && !tool.access.allowedSystemRoles.includes(userRole as SystemRole)) {
return false;
}
if (tool.access.requiresResourceOverview && !hasResourceOverviewAccess) {
return false;
}
if (tool.access.requiresPlanningRead && !permissions.has(PermissionKey.VIEW_PLANNING)) {
return false;
}
if (tool.access.requiresCostView && !permissions.has(PermissionKey.VIEW_COSTS)) {
return false;
}
if (tool.access.requiresAdvancedAssistant && !permissions.has(PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS)) {
return false;
}
return true;
}
export function getAvailableAssistantTools(
permissions: Set<PermissionKey>,
userRole: string,
): ToolDef[] {
const hasResourceOverviewAccess = permissions.has(PermissionKey.VIEW_ALL_RESOURCES)
|| permissions.has(PermissionKey.MANAGE_RESOURCES);
return TOOL_DEFINITIONS.filter((tool) => (
hasToolAccess(tool, permissions, userRole, hasResourceOverviewAccess)
));
return getAvailableAssistantToolsForContext(permissions, userRole);
}
@@ -23,7 +23,18 @@ const TOOL_SELECTION_HINTS = [
{
keywords: ["holiday", "holidays", "feiertag", "feiertage", "vacation", "vacations", "urlaub", "ferien", "abwesen"],
nameFragments: ["holiday", "vacation", "entitlement"],
exactTools: ["list_holidays_by_region", "get_resource_holidays", "get_my_timeline_holiday_overlays", "list_holiday_calendars", "get_holiday_calendar", "preview_resolved_holiday_calendar"],
exactTools: [
"list_holidays_by_region",
"get_resource_holidays",
"get_vacation_balance",
"get_entitlement_summary",
"list_vacations_upcoming",
"get_team_vacation_overlap",
"get_my_timeline_holiday_overlays",
"list_holiday_calendars",
"get_holiday_calendar",
"preview_resolved_holiday_calendar",
],
},
{
keywords: ["resource", "resources", "ressource", "ressourcen", "employee", "mitarbeiter", "person", "people", "team", "chapter", "skill", "skills"],
@@ -43,7 +54,16 @@ const TOOL_SELECTION_HINTS = [
{
keywords: ["dashboard", "widget", "widgets", "peak", "forecast", "insight", "insights", "anomaly", "anomalies", "report", "reports", "analyse", "analysis", "bericht"],
nameFragments: ["dashboard", "statistics", "report", "insight", "anomal", "health", "forecast", "skill"],
exactTools: ["get_statistics", "get_dashboard_detail", "detect_anomalies", "get_skill_gaps", "get_project_health", "get_budget_forecast", "get_insights_summary", "run_report"],
exactTools: [
"get_statistics",
"get_dashboard_detail",
"detect_anomalies",
"get_skill_gaps",
"get_project_health",
"get_budget_forecast",
"get_insights_summary",
"run_report",
],
},
{
keywords: ["estimate", "estimates", "angebot", "angebote", "budget", "budgets", "cost", "costs", "kosten", "rate", "rates", "preis", "preise"],
+151
View File
@@ -1345,6 +1345,15 @@ function getTrpcValidationIssues(error: TRPCError): Array<{
function toAssistantUserResourceLinkError(
error: unknown,
): AssistantToolErrorResult | null {
if (error instanceof TRPCError && error.code === "CONFLICT") {
if (error.message.includes("already linked")) {
return { error: "Resource is already linked to another user." };
}
if (error.message.includes("changed during update")) {
return { error: "Resource link changed during update. Please retry." };
}
}
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
if (error.message.includes("Resource")) {
return { error: "Resource not found with the given criteria." };
@@ -1478,6 +1487,9 @@ function toAssistantTaskActionError(
if (error.message.includes("already completed")) {
return { error: "Task is already completed." };
}
if (error.message.includes("dismissed")) {
return { error: "Task has been dismissed and cannot be executed." };
}
}
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
@@ -1812,6 +1824,14 @@ function toAssistantNotificationCreationError(
return { error: "No recipients matched the broadcast target." };
}
if (
context === "broadcast"
&& trpcError?.code === "BAD_REQUEST"
&& trpcError.message === "Scheduled broadcasts with task metadata are not supported yet."
) {
return { error: "Scheduled broadcasts with task metadata are not supported yet." };
}
if (trpcError?.code === "NOT_FOUND") {
if (trpcError.message.includes("broadcast")) {
return { error: "Broadcast not found with the given criteria." };
@@ -2048,6 +2068,128 @@ export const TOOL_DEFINITIONS: ToolDef[] = withToolAccess([
...settingsAdminToolDefinitions,
], LEGACY_MONOLITHIC_TOOL_ACCESS);
const TOOL_DEFINITIONS_BY_NAME = new Map(
TOOL_DEFINITIONS.map((tool) => [tool.function.name, tool]),
);
type AssistantToolAccessEvaluationContext = Pick<ToolContext, "permissions" | "userRole">;
type AssistantToolAccessFailure =
| { type: "role" }
| {
type: "permission";
permission?: PermissionKey;
message?: string;
};
function hasAssistantResourceOverviewAccess(
permissions: Set<PermissionKey>,
): boolean {
return permissions.has(PermissionKey.VIEW_ALL_RESOURCES)
|| permissions.has(PermissionKey.MANAGE_RESOURCES);
}
function getAssistantToolAccessRequirements(
tool: ToolDef,
): ToolAccessRequirements | undefined {
return tool.access ?? LEGACY_MONOLITHIC_TOOL_ACCESS[tool.function.name];
}
function getAssistantToolAccessFailure(
tool: ToolDef,
ctx: AssistantToolAccessEvaluationContext,
): AssistantToolAccessFailure | null {
const access = getAssistantToolAccessRequirements(tool);
if (!access) {
return null;
}
if (
access.allowedSystemRoles
&& !access.allowedSystemRoles.includes(ctx.userRole as SystemRole)
) {
return { type: "role" };
}
const missingRequiredPermission = access.requiredPermissions?.find(
(permission) => !ctx.permissions.has(permission),
);
if (missingRequiredPermission) {
return {
type: "permission",
permission: missingRequiredPermission,
};
}
if (access.requiresPlanningRead && !ctx.permissions.has(PermissionKey.VIEW_PLANNING)) {
return {
type: "permission",
permission: PermissionKey.VIEW_PLANNING,
};
}
if (access.requiresCostView && !ctx.permissions.has(PermissionKey.VIEW_COSTS)) {
return {
type: "permission",
permission: PermissionKey.VIEW_COSTS,
};
}
if (
access.requiresAdvancedAssistant
&& !ctx.permissions.has(PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS)
) {
return {
type: "permission",
permission: PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
};
}
if (
access.requiresResourceOverview
&& !hasAssistantResourceOverviewAccess(ctx.permissions)
) {
return {
type: "permission",
message: "Permission denied: you need resource overview access to perform this action.",
};
}
return null;
}
function toAssistantToolAccessError(
failure: AssistantToolAccessFailure,
): AssistantVisibleError {
if (failure.type === "role") {
return new AssistantVisibleError("You do not have permission to perform this action.");
}
if (failure.permission) {
return new AssistantVisibleError(
`Permission denied: you need the "${failure.permission}" permission to perform this action.`,
);
}
return new AssistantVisibleError(
failure.message ?? "You do not have permission to perform this action.",
);
}
export function canAccessAssistantTool(
tool: ToolDef,
ctx: AssistantToolAccessEvaluationContext,
): boolean {
return getAssistantToolAccessFailure(tool, ctx) === null;
}
export function getAvailableAssistantToolsForContext(
permissions: Set<PermissionKey>,
userRole: string,
): ToolDef[] {
return TOOL_DEFINITIONS.filter((tool) => canAccessAssistantTool(tool, { permissions, userRole }));
}
// ─── Helpers ────────────────────────────────────────────────────────────────
/** Resolve a responsible person name against existing resources. Returns the exact displayName or an error object. */
@@ -2285,6 +2427,7 @@ const executors = {
...createUserSelfServiceExecutors({
createUserCaller,
createScopedCallerContext,
toAssistantCurrentUserError: toAssistantUserMutationError,
toAssistantTotpEnableError,
}),
...createNotificationsTasksExecutors({
@@ -2352,6 +2495,14 @@ export async function executeTool(
if (!executor) return { content: JSON.stringify({ error: `Unknown tool: ${name}` }) };
try {
const toolDefinition = TOOL_DEFINITIONS_BY_NAME.get(name);
const accessFailure = toolDefinition
? getAssistantToolAccessFailure(toolDefinition, ctx)
: null;
if (accessFailure) {
throw toAssistantToolAccessError(accessFailure);
}
const params = JSON.parse(args);
// Audit-log all mutation tool executions (EGAI 4.1.3.1 / IAAI 3.6.26)
@@ -0,0 +1,770 @@
import type { TRPCContext } from "../../trpc.js";
import { AllocationStatus, PermissionKey, SystemRole } from "@capakraken/shared";
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
export const advancedTimelineToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
name: "find_best_project_resource",
description: "Advanced assistant tool: find the best already-assigned resource on a project for a given period, ranked by remaining capacity or LCR. Holiday- and vacation-aware. Requires viewCosts and advanced assistant permissions.",
parameters: {
type: "object",
properties: {
projectIdentifier: { type: "string", description: "Project ID, short code, or project name." },
startDate: { type: "string", description: "Optional start date in YYYY-MM-DD. Default: today." },
endDate: { type: "string", description: "Optional end date in YYYY-MM-DD. If omitted, durationDays is used." },
durationDays: { type: "integer", description: "Optional duration in calendar days when endDate is omitted. Default: 21." },
minHoursPerDay: { type: "number", description: "Minimum remaining availability per effective working day. Default: 3." },
rankingMode: { type: "string", description: "Ranking mode: lowest_lcr, highest_remaining_hours_per_day, or highest_remaining_hours. Default: lowest_lcr." },
chapter: { type: "string", description: "Optional chapter filter for candidate resources." },
roleName: { type: "string", description: "Optional role filter for candidate resources." },
},
required: ["projectIdentifier"],
},
},
},
{
type: "function",
function: {
name: "get_timeline_entries_view",
description: "Advanced assistant tool: read-only timeline entries view with the same timeline/disposition readmodel used by the app. Returns allocations, demands, assignments, and matching holiday overlays for a period.",
parameters: {
type: "object",
properties: {
startDate: { type: "string", description: "Optional start date in YYYY-MM-DD. Default: today." },
endDate: { type: "string", description: "Optional end date in YYYY-MM-DD. If omitted, durationDays is used." },
durationDays: { type: "integer", description: "Optional duration in calendar days when endDate is omitted. Default: 21." },
resourceIds: { type: "array", items: { type: "string" }, description: "Optional resource IDs to scope the view." },
projectIds: { type: "array", items: { type: "string" }, description: "Optional project IDs to scope the view." },
clientIds: { type: "array", items: { type: "string" }, description: "Optional client IDs to scope the view." },
chapters: { type: "array", items: { type: "string" }, description: "Optional chapter filters." },
eids: { type: "array", items: { type: "string" }, description: "Optional employee IDs to scope the view." },
countryCodes: { type: "array", items: { type: "string" }, description: "Optional country codes such as DE or ES." },
},
},
},
},
{
type: "function",
function: {
name: "get_timeline_holiday_overlays",
description: "Advanced assistant tool: read-only holiday overlays for the timeline, resolved with the same holiday logic as the app. Useful to explain regional holiday differences for assigned or filtered resources.",
parameters: {
type: "object",
properties: {
startDate: { type: "string", description: "Optional start date in YYYY-MM-DD. Default: today." },
endDate: { type: "string", description: "Optional end date in YYYY-MM-DD. If omitted, durationDays is used." },
durationDays: { type: "integer", description: "Optional duration in calendar days when endDate is omitted. Default: 21." },
resourceIds: { type: "array", items: { type: "string" }, description: "Optional resource IDs to scope the overlays." },
projectIds: { type: "array", items: { type: "string" }, description: "Optional project IDs to scope the overlays via matching assignments." },
clientIds: { type: "array", items: { type: "string" }, description: "Optional client IDs to scope the overlays via matching projects." },
chapters: { type: "array", items: { type: "string" }, description: "Optional chapter filters." },
eids: { type: "array", items: { type: "string" }, description: "Optional employee IDs to scope the overlays." },
countryCodes: { type: "array", items: { type: "string" }, description: "Optional country codes such as DE or ES." },
},
},
},
},
{
type: "function",
function: {
name: "get_project_timeline_context",
description: "Advanced assistant tool: read-only project timeline/disposition context. Reuses the same project context readmodel as the app and adds holiday overlays plus cross-project overlap summaries for assigned resources.",
parameters: {
type: "object",
properties: {
projectIdentifier: { type: "string", description: "Project ID, short code, or project name." },
startDate: { type: "string", description: "Optional holiday/conflict window start date in YYYY-MM-DD. Defaults to the project start date when available." },
endDate: { type: "string", description: "Optional holiday/conflict window end date in YYYY-MM-DD. Defaults to the project end date when available." },
durationDays: { type: "integer", description: "Optional duration in calendar days when endDate is omitted." },
},
required: ["projectIdentifier"],
},
},
},
{
type: "function",
function: {
name: "preview_project_shift",
description: "Advanced assistant tool: read-only preview of the timeline shift validation for a project. Uses the same preview logic as the timeline router and does not write changes.",
parameters: {
type: "object",
properties: {
projectIdentifier: { type: "string", description: "Project ID, short code, or project name." },
newStartDate: { type: "string", description: "New start date in YYYY-MM-DD." },
newEndDate: { type: "string", description: "New end date in YYYY-MM-DD." },
},
required: ["projectIdentifier", "newStartDate", "newEndDate"],
},
},
},
{
type: "function",
function: {
name: "update_timeline_allocation_inline",
description: "Advanced assistant mutation: update a timeline allocation inline with the same manager/admin + manageAllocations validation as the timeline API. Supports hours/day, dates, includeSaturday, and role changes. Requires useAssistantAdvancedTools.",
parameters: {
type: "object",
properties: {
allocationId: { type: "string", description: "Allocation, assignment, or demand row ID to update." },
hoursPerDay: { type: "number", description: "Optional new booked hours per day." },
startDate: { type: "string", description: "Optional new start date in YYYY-MM-DD." },
endDate: { type: "string", description: "Optional new end date in YYYY-MM-DD." },
includeSaturday: { type: "boolean", description: "Optional Saturday-working flag stored in metadata." },
role: { type: "string", description: "Optional new role label." },
},
required: ["allocationId"],
},
},
},
{
type: "function",
function: {
name: "apply_timeline_project_shift",
description: "Advanced assistant mutation: apply the real timeline project shift mutation, including validation, date movement, cost recalculation, audit logging, and SSE. Requires manager/admin, manageAllocations, and useAssistantAdvancedTools.",
parameters: {
type: "object",
properties: {
projectIdentifier: { type: "string", description: "Project ID, short code, or project name." },
newStartDate: { type: "string", description: "New project start date in YYYY-MM-DD." },
newEndDate: { type: "string", description: "New project end date in YYYY-MM-DD." },
},
required: ["projectIdentifier", "newStartDate", "newEndDate"],
},
},
},
{
type: "function",
function: {
name: "quick_assign_timeline_resource",
description: "Advanced assistant mutation: create a timeline quick-assignment with the same manager/admin + manageAllocations rules as the timeline UI. Resolves resource and project identifiers before calling the real mutation. Requires useAssistantAdvancedTools.",
parameters: {
type: "object",
properties: {
resourceIdentifier: { type: "string", description: "Resource ID, eid, or display name." },
projectIdentifier: { type: "string", description: "Project ID, short code, or project name." },
startDate: { type: "string", description: "Start date in YYYY-MM-DD." },
endDate: { type: "string", description: "End date in YYYY-MM-DD." },
hoursPerDay: { type: "number", description: "Hours per day. Default: 8." },
role: { type: "string", description: "Role label. Default: Team Member." },
roleId: { type: "string", description: "Optional concrete role ID." },
status: { type: "string", enum: ["PROPOSED", "CONFIRMED", "ACTIVE", "COMPLETED", "CANCELLED"], description: "Assignment status. Default: PROPOSED." },
},
required: ["resourceIdentifier", "projectIdentifier", "startDate", "endDate"],
},
},
},
{
type: "function",
function: {
name: "batch_quick_assign_timeline_resources",
description: "Advanced assistant mutation: batch-create timeline quick-assignments using the same timeline router logic, permission checks, and audit/SSE side effects as the app. Requires manager/admin, manageAllocations, and useAssistantAdvancedTools.",
parameters: {
type: "object",
properties: {
assignments: {
type: "array",
minItems: 1,
maxItems: 50,
items: {
type: "object",
properties: {
resourceIdentifier: { type: "string", description: "Resource ID, eid, or display name." },
projectIdentifier: { type: "string", description: "Project ID, short code, or project name." },
startDate: { type: "string", description: "Start date in YYYY-MM-DD." },
endDate: { type: "string", description: "End date in YYYY-MM-DD." },
hoursPerDay: { type: "number", description: "Hours per day. Default: 8." },
role: { type: "string", description: "Role label. Default: Team Member." },
status: { type: "string", enum: ["PROPOSED", "CONFIRMED", "ACTIVE", "COMPLETED", "CANCELLED"], description: "Assignment status. Default: PROPOSED." },
},
required: ["resourceIdentifier", "projectIdentifier", "startDate", "endDate"],
},
description: "Assignment rows to create in one batch.",
},
},
required: ["assignments"],
},
},
},
{
type: "function",
function: {
name: "batch_shift_timeline_allocations",
description: "Advanced assistant mutation: shift multiple timeline allocations by a shared day delta using the real timeline batch move/resize mutation. Requires manager/admin, manageAllocations, and useAssistantAdvancedTools.",
parameters: {
type: "object",
properties: {
allocationIds: { type: "array", items: { type: "string" }, description: "Allocation IDs to shift." },
daysDelta: { type: "integer", description: "Signed day delta to apply." },
mode: { type: "string", enum: ["move", "resize-start", "resize-end"], description: "Shift mode. Default: move." },
},
required: ["allocationIds", "daysDelta"],
},
},
},
], {
find_best_project_resource: {
requiresPlanningRead: true,
requiresCostView: true,
requiresAdvancedAssistant: true,
},
get_timeline_entries_view: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
requiresAdvancedAssistant: true,
},
get_timeline_holiday_overlays: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
requiresAdvancedAssistant: true,
},
get_project_timeline_context: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
requiresAdvancedAssistant: true,
},
preview_project_shift: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
requiresAdvancedAssistant: true,
},
update_timeline_allocation_inline: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
requiredPermissions: [PermissionKey.MANAGE_ALLOCATIONS],
requiresAdvancedAssistant: true,
},
apply_timeline_project_shift: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
requiredPermissions: [PermissionKey.MANAGE_ALLOCATIONS],
requiresAdvancedAssistant: true,
},
quick_assign_timeline_resource: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
requiredPermissions: [PermissionKey.MANAGE_ALLOCATIONS],
requiresAdvancedAssistant: true,
},
batch_quick_assign_timeline_resources: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
requiredPermissions: [PermissionKey.MANAGE_ALLOCATIONS],
requiresAdvancedAssistant: true,
},
batch_shift_timeline_allocations: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
requiredPermissions: [PermissionKey.MANAGE_ALLOCATIONS],
requiresAdvancedAssistant: true,
},
});
type AssistantToolErrorResult = { error: string };
type ResolvedProject = {
id: string;
name?: string | null;
shortCode?: string | null;
};
type ResolvedResource = {
id: string;
displayName: string;
};
type TimelineMutationContext = "updateInline" | "applyShift" | "quickAssign" | "batchShift";
type BatchQuickAssignmentInput = {
resourceId: string;
projectId: string;
startDate: Date;
endDate: Date;
hoursPerDay?: number;
role?: string;
status?: AllocationStatus;
};
type AdvancedTimelineDeps = {
assertPermission: (ctx: ToolContext, perm: PermissionKey) => void;
createStaffingCaller: (ctx: TRPCContext) => {
getBestProjectResourceDetail: (params: {
projectId: string;
startDate?: Date;
endDate?: Date;
durationDays?: number;
minHoursPerDay?: number;
rankingMode?: "lowest_lcr" | "highest_remaining_hours_per_day" | "highest_remaining_hours";
chapter?: string;
roleName?: string;
}) => Promise<unknown>;
};
createTimelineCaller: (ctx: TRPCContext) => {
getEntriesDetail: (params: {
startDate?: string;
endDate?: string;
durationDays?: number;
resourceIds?: string[];
projectIds?: string[];
clientIds?: string[];
chapters?: string[];
eids?: string[];
countryCodes?: string[];
}) => Promise<unknown>;
getHolidayOverlayDetail: (params: {
startDate?: string;
endDate?: string;
durationDays?: number;
resourceIds?: string[];
projectIds?: string[];
clientIds?: string[];
chapters?: string[];
eids?: string[];
countryCodes?: string[];
}) => Promise<unknown>;
getProjectContextDetail: (params: {
projectId: string;
startDate?: string;
endDate?: string;
durationDays?: number;
}) => Promise<unknown>;
getShiftPreviewDetail: (params: {
projectId: string;
newStartDate: Date;
newEndDate: Date;
}) => Promise<unknown>;
updateAllocationInline: (params: {
allocationId: string;
hoursPerDay?: number;
startDate?: Date;
endDate?: Date;
includeSaturday?: boolean;
role?: string;
}) => Promise<{
id: string;
projectId: string;
resourceId?: string | null;
startDate: Date;
endDate: Date;
hoursPerDay: number;
role?: string | null;
status: string;
}>;
applyShift: (params: {
projectId: string;
newStartDate: Date;
newEndDate: Date;
}) => Promise<{
project: {
id: string;
startDate: Date;
endDate: Date;
};
validation: unknown;
}>;
quickAssign: (params: {
resourceId: string;
projectId: string;
startDate: Date;
endDate: Date;
hoursPerDay?: number;
role?: string;
roleId?: string;
status?: AllocationStatus;
}) => Promise<{
id: string;
projectId: string;
resourceId?: string | null;
startDate: Date | string;
endDate: Date | string;
hoursPerDay: number;
role?: string | null;
status: string;
}>;
batchQuickAssign: (params: { assignments: BatchQuickAssignmentInput[] }) => Promise<{ count: number }>;
batchShiftAllocations: (params: {
allocationIds: string[];
daysDelta: number;
mode?: "move" | "resize-start" | "resize-end";
}) => Promise<{ count: number }>;
};
createScopedCallerContext: (ctx: ToolContext) => TRPCContext;
resolveProjectIdentifier: (
ctx: ToolContext,
identifier: string,
) => Promise<ResolvedProject | AssistantToolErrorResult>;
resolveResourceIdentifier: (
ctx: ToolContext,
identifier: string,
) => Promise<ResolvedResource | AssistantToolErrorResult>;
parseIsoDate: (value: string, fieldName: string) => Date;
fmtDate: (value: Date | null | undefined) => string | null;
isAssistantToolErrorResult: (value: unknown) => value is AssistantToolErrorResult;
toAssistantIndexedFieldError: (index: number, field: string, message: string) => unknown;
toAssistantTimelineMutationError: (error: unknown, context: TimelineMutationContext) => unknown;
};
function toDate(value: Date | string): Date {
return value instanceof Date ? value : new Date(value);
}
export function createAdvancedTimelineExecutors(
deps: AdvancedTimelineDeps,
): Record<string, ToolExecutor> {
return {
async find_best_project_resource(params: {
projectIdentifier: string;
startDate?: string;
endDate?: string;
durationDays?: number;
minHoursPerDay?: number;
rankingMode?: "lowest_lcr" | "highest_remaining_hours_per_day" | "highest_remaining_hours";
chapter?: string;
roleName?: string;
}, ctx: ToolContext) {
deps.assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS);
deps.assertPermission(ctx, PermissionKey.VIEW_COSTS);
const project = await deps.resolveProjectIdentifier(ctx, params.projectIdentifier);
if ("error" in project) {
return project;
}
const caller = deps.createStaffingCaller(deps.createScopedCallerContext(ctx));
return caller.getBestProjectResourceDetail({
projectId: project.id,
...(params.startDate ? { startDate: deps.parseIsoDate(params.startDate, "startDate") } : {}),
...(params.endDate ? { endDate: deps.parseIsoDate(params.endDate, "endDate") } : {}),
...(params.durationDays !== undefined ? { durationDays: params.durationDays } : {}),
...(params.minHoursPerDay !== undefined ? { minHoursPerDay: params.minHoursPerDay } : {}),
...(params.rankingMode ? { rankingMode: params.rankingMode } : {}),
...(params.chapter ? { chapter: params.chapter } : {}),
...(params.roleName ? { roleName: params.roleName } : {}),
});
},
async get_timeline_entries_view(params: {
startDate?: string;
endDate?: string;
durationDays?: number;
resourceIds?: string[];
projectIds?: string[];
clientIds?: string[];
chapters?: string[];
eids?: string[];
countryCodes?: string[];
}, ctx: ToolContext) {
deps.assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS);
const caller = deps.createTimelineCaller(deps.createScopedCallerContext(ctx));
return caller.getEntriesDetail({ ...params });
},
async get_timeline_holiday_overlays(params: {
startDate?: string;
endDate?: string;
durationDays?: number;
resourceIds?: string[];
projectIds?: string[];
clientIds?: string[];
chapters?: string[];
eids?: string[];
countryCodes?: string[];
}, ctx: ToolContext) {
deps.assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS);
const caller = deps.createTimelineCaller(deps.createScopedCallerContext(ctx));
return caller.getHolidayOverlayDetail({ ...params });
},
async get_project_timeline_context(params: {
projectIdentifier: string;
startDate?: string;
endDate?: string;
durationDays?: number;
}, ctx: ToolContext) {
deps.assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS);
const project = await deps.resolveProjectIdentifier(ctx, params.projectIdentifier);
if ("error" in project) {
return project;
}
const caller = deps.createTimelineCaller(deps.createScopedCallerContext(ctx));
return caller.getProjectContextDetail({
projectId: project.id,
...(params.startDate ? { startDate: params.startDate } : {}),
...(params.endDate ? { endDate: params.endDate } : {}),
...(params.durationDays !== undefined ? { durationDays: params.durationDays } : {}),
});
},
async preview_project_shift(params: {
projectIdentifier: string;
newStartDate: string;
newEndDate: string;
}, ctx: ToolContext) {
deps.assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS);
const project = await deps.resolveProjectIdentifier(ctx, params.projectIdentifier);
if ("error" in project) {
return project;
}
const caller = deps.createTimelineCaller(deps.createScopedCallerContext(ctx));
return caller.getShiftPreviewDetail({
projectId: project.id,
newStartDate: deps.parseIsoDate(params.newStartDate, "newStartDate"),
newEndDate: deps.parseIsoDate(params.newEndDate, "newEndDate"),
});
},
async update_timeline_allocation_inline(params: {
allocationId: string;
hoursPerDay?: number;
startDate?: string;
endDate?: string;
includeSaturday?: boolean;
role?: string;
}, ctx: ToolContext) {
deps.assertPermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
deps.assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS);
const caller = deps.createTimelineCaller(deps.createScopedCallerContext(ctx));
let updated;
try {
updated = await caller.updateAllocationInline({
allocationId: params.allocationId,
...(params.hoursPerDay !== undefined ? { hoursPerDay: params.hoursPerDay } : {}),
...(params.startDate ? { startDate: deps.parseIsoDate(params.startDate, "startDate") } : {}),
...(params.endDate ? { endDate: deps.parseIsoDate(params.endDate, "endDate") } : {}),
...(params.includeSaturday !== undefined ? { includeSaturday: params.includeSaturday } : {}),
...(params.role !== undefined ? { role: params.role } : {}),
});
} catch (error) {
const mapped = deps.toAssistantTimelineMutationError(error, "updateInline");
if (mapped) {
return mapped;
}
throw error;
}
return {
__action: "invalidate",
scope: ["allocation", "timeline", "project"],
success: true,
message: `Updated timeline allocation ${updated.id}.`,
allocation: {
id: updated.id,
projectId: updated.projectId,
resourceId: updated.resourceId ?? null,
startDate: deps.fmtDate(updated.startDate),
endDate: deps.fmtDate(updated.endDate),
hoursPerDay: updated.hoursPerDay,
role: updated.role ?? null,
status: updated.status,
},
};
},
async apply_timeline_project_shift(params: {
projectIdentifier: string;
newStartDate: string;
newEndDate: string;
}, ctx: ToolContext) {
deps.assertPermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
deps.assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS);
const project = await deps.resolveProjectIdentifier(ctx, params.projectIdentifier);
if ("error" in project) {
return project;
}
const newStartDate = deps.parseIsoDate(params.newStartDate, "newStartDate");
const newEndDate = deps.parseIsoDate(params.newEndDate, "newEndDate");
const caller = deps.createTimelineCaller(deps.createScopedCallerContext(ctx));
let result;
try {
result = await caller.applyShift({
projectId: project.id,
newStartDate,
newEndDate,
});
} catch (error) {
const mapped = deps.toAssistantTimelineMutationError(error, "applyShift");
if (mapped) {
return mapped;
}
throw error;
}
return {
__action: "invalidate",
scope: ["allocation", "timeline", "project"],
success: true,
message: `Shifted project ${project.shortCode ?? project.name ?? project.id} to ${deps.fmtDate(newStartDate)} - ${deps.fmtDate(newEndDate)}.`,
project: {
id: result.project.id,
startDate: deps.fmtDate(result.project.startDate),
endDate: deps.fmtDate(result.project.endDate),
},
validation: result.validation,
};
},
async quick_assign_timeline_resource(params: {
resourceIdentifier: string;
projectIdentifier: string;
startDate: string;
endDate: string;
hoursPerDay?: number;
role?: string;
roleId?: string;
status?: AllocationStatus;
}, ctx: ToolContext) {
deps.assertPermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
deps.assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS);
const [resource, project] = await Promise.all([
deps.resolveResourceIdentifier(ctx, params.resourceIdentifier),
deps.resolveProjectIdentifier(ctx, params.projectIdentifier),
]);
if ("error" in resource) {
return resource;
}
if ("error" in project) {
return project;
}
const caller = deps.createTimelineCaller(deps.createScopedCallerContext(ctx));
let allocation;
try {
allocation = await caller.quickAssign({
resourceId: resource.id,
projectId: project.id,
startDate: deps.parseIsoDate(params.startDate, "startDate"),
endDate: deps.parseIsoDate(params.endDate, "endDate"),
...(params.hoursPerDay !== undefined ? { hoursPerDay: params.hoursPerDay } : {}),
...(params.role !== undefined ? { role: params.role } : {}),
...(params.roleId !== undefined ? { roleId: params.roleId } : {}),
...(params.status !== undefined ? { status: params.status } : {}),
});
} catch (error) {
const mapped = deps.toAssistantTimelineMutationError(error, "quickAssign");
if (mapped) {
return mapped;
}
throw error;
}
return {
__action: "invalidate",
scope: ["allocation", "timeline", "project"],
success: true,
message: `Quick-assigned ${resource.displayName} to ${project.name} (${project.shortCode ?? project.id}).`,
allocation: {
id: allocation.id,
projectId: allocation.projectId,
resourceId: allocation.resourceId ?? null,
startDate: deps.fmtDate(toDate(allocation.startDate)),
endDate: deps.fmtDate(toDate(allocation.endDate)),
hoursPerDay: allocation.hoursPerDay,
role: allocation.role ?? null,
status: allocation.status,
},
};
},
async batch_quick_assign_timeline_resources(params: {
assignments: Array<{
resourceIdentifier: string;
projectIdentifier: string;
startDate: string;
endDate: string;
hoursPerDay?: number;
role?: string;
status?: AllocationStatus;
}>;
}, ctx: ToolContext) {
deps.assertPermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
deps.assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS);
const resolvedAssignments = await Promise.all(params.assignments.map(async (assignment, index) => {
const [resource, project] = await Promise.all([
deps.resolveResourceIdentifier(ctx, assignment.resourceIdentifier),
deps.resolveProjectIdentifier(ctx, assignment.projectIdentifier),
]);
if ("error" in resource) {
return deps.toAssistantIndexedFieldError(index, "resourceIdentifier", resource.error);
}
if ("error" in project) {
return deps.toAssistantIndexedFieldError(index, "projectIdentifier", project.error);
}
return {
resourceId: resource.id,
projectId: project.id,
startDate: deps.parseIsoDate(assignment.startDate, `assignments[${index}].startDate`),
endDate: deps.parseIsoDate(assignment.endDate, `assignments[${index}].endDate`),
...(assignment.hoursPerDay !== undefined ? { hoursPerDay: assignment.hoursPerDay } : {}),
...(assignment.role !== undefined ? { role: assignment.role } : {}),
...(assignment.status !== undefined ? { status: assignment.status } : {}),
};
}));
const resolutionError = resolvedAssignments.find(deps.isAssistantToolErrorResult);
if (resolutionError) {
return resolutionError;
}
const validAssignments = resolvedAssignments.filter(
(assignment): assignment is BatchQuickAssignmentInput => !deps.isAssistantToolErrorResult(assignment),
);
const caller = deps.createTimelineCaller(deps.createScopedCallerContext(ctx));
let result;
try {
result = await caller.batchQuickAssign({
assignments: validAssignments,
});
} catch (error) {
const mapped = deps.toAssistantTimelineMutationError(error, "quickAssign");
if (mapped) {
return mapped;
}
throw error;
}
return {
__action: "invalidate",
scope: ["allocation", "timeline", "project"],
success: true,
message: `Created ${result.count} timeline quick-assignment(s).`,
count: result.count,
};
},
async batch_shift_timeline_allocations(params: {
allocationIds: string[];
daysDelta: number;
mode?: "move" | "resize-start" | "resize-end";
}, ctx: ToolContext) {
deps.assertPermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
deps.assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS);
const caller = deps.createTimelineCaller(deps.createScopedCallerContext(ctx));
let result;
try {
result = await caller.batchShiftAllocations({
allocationIds: params.allocationIds,
daysDelta: params.daysDelta,
...(params.mode !== undefined ? { mode: params.mode } : {}),
});
} catch (error) {
const mapped = deps.toAssistantTimelineMutationError(error, "batchShift");
if (mapped) {
return mapped;
}
throw error;
}
return {
__action: "invalidate",
scope: ["allocation", "timeline", "project"],
success: true,
message: `Shifted ${result.count} allocation(s) by ${params.daysDelta} day(s).`,
count: result.count,
};
},
};
}
@@ -0,0 +1,497 @@
import type { TRPCContext } from "../../trpc.js";
import { AllocationStatus, PermissionKey, UpdateAssignmentSchema } from "@capakraken/shared";
import { SystemRole } from "@capakraken/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { fmtEur } from "../../lib/format-utils.js";
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
type AssistantToolErrorResult = { error: string };
type ResolvedProject = {
id: string;
name: string;
shortCode: string;
};
type ResolvedResource = {
id: string;
displayName: string;
};
type AllocationPlanningDeps = {
assertPermission: (ctx: ToolContext, perm: PermissionKey) => void;
createAllocationCaller: (ctx: TRPCContext) => {
listView: (params: {
resourceId?: string;
projectId?: string;
status?: AllocationStatus;
}) => Promise<{
assignments: Array<{
id: string;
status: string;
hoursPerDay: number;
dailyCostCents?: number | null;
startDate: Date | string;
endDate: Date | string;
role?: string | null;
roleEntity?: { name?: string | null } | null;
resource?: { displayName?: string | null; eid?: string | null } | null;
project?: { name?: string | null; shortCode?: string | null } | null;
}>;
}>;
ensureAssignment: (params: {
resourceId: string;
projectId: string;
startDate: Date;
endDate: Date;
hoursPerDay: number;
role?: string;
}) => Promise<{
action: "created" | "reactivated";
assignment: {
id: string;
status: string;
};
}>;
resolveAssignment: (params: {
assignmentId?: string;
resourceId?: string;
projectId?: string;
startDate?: Date;
endDate?: Date;
selectionMode: "WINDOW" | "EXACT_START";
excludeCancelled?: boolean;
}) => Promise<{
id: string;
status: string;
startDate: Date;
endDate: Date;
resource: { displayName: string };
project: { name: string; shortCode: string };
}>;
updateAssignment: (params: {
id: string;
data: z.input<typeof UpdateAssignmentSchema>;
}) => Promise<unknown>;
};
createTimelineCaller: (ctx: TRPCContext) => {
getBudgetStatus: (params: { projectId: string }) => Promise<{
projectName: string;
projectCode: string;
budgetCents: number;
confirmedCents: number;
proposedCents: number;
allocatedCents: number;
remainingCents: number;
utilizationPercent: number;
winProbabilityWeightedCents: number;
totalAllocations: number;
}>;
};
createScopedCallerContext: (ctx: ToolContext) => TRPCContext;
resolveProjectIdentifier: (
ctx: ToolContext,
identifier: string,
) => Promise<ResolvedProject | AssistantToolErrorResult>;
resolveResourceIdentifier: (
ctx: ToolContext,
identifier: string,
) => Promise<ResolvedResource | AssistantToolErrorResult>;
parseIsoDate: (value: string, fieldName: string) => Date;
parseOptionalIsoDate: (value: string | undefined, fieldName: string) => Date | undefined;
fmtDate: (value: Date | null | undefined) => string | null;
toAssistantAllocationNotFoundError: (error: unknown) => unknown;
};
export const allocationPlanningReadToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
name: "list_allocations",
description: "List assignments/allocations, optionally filtered by resource or project. Shows who is assigned where, hours/day, dates, and cost.",
parameters: {
type: "object",
properties: {
resourceId: { type: "string", description: "Filter by resource ID" },
projectId: { type: "string", description: "Filter by project ID" },
resourceName: { type: "string", description: "Filter by resource name (partial match)" },
projectCode: { type: "string", description: "Filter by project short code (partial match)" },
status: { type: "string", description: "Filter by status: PROPOSED, CONFIRMED, ACTIVE, COMPLETED, CANCELLED" },
limit: { type: "integer", description: "Max results. Default: 30" },
},
},
},
},
{
type: "function",
function: {
name: "get_budget_status",
description: "Get the budget status of a project: total budget, confirmed/proposed costs, remaining, utilization percentage.",
parameters: {
type: "object",
properties: {
projectId: { type: "string", description: "Project ID or short code" },
},
required: ["projectId"],
},
},
},
], {
list_allocations: {
requiresPlanningRead: true,
},
get_budget_status: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
requiresCostView: true,
},
});
export const allocationPlanningMutationToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
name: "create_allocation",
description: "Create a new allocation/booking for a resource on a project. Requires manageAllocations permission. Always confirm with the user before calling this. Created with PROPOSED status.",
parameters: {
type: "object",
properties: {
resourceId: { type: "string", description: "Resource ID" },
projectId: { type: "string", description: "Project ID" },
startDate: { type: "string", description: "Start date YYYY-MM-DD" },
endDate: { type: "string", description: "End date YYYY-MM-DD" },
hoursPerDay: { type: "number", description: "Hours per day (e.g. 8)" },
role: { type: "string", description: "Optional role name" },
},
required: ["resourceId", "projectId", "startDate", "endDate", "hoursPerDay"],
},
},
},
{
type: "function",
function: {
name: "cancel_allocation",
description: "Cancel an existing allocation. Can find by allocation ID, or by resource name + project code + date range. Requires manageAllocations permission. Always confirm with the user before calling this.",
parameters: {
type: "object",
properties: {
allocationId: { type: "string", description: "Allocation ID (if known)" },
resourceName: { type: "string", description: "Resource name (partial match)" },
projectCode: { type: "string", description: "Project short code (partial match)" },
startDate: { type: "string", description: "Filter by start date YYYY-MM-DD" },
endDate: { type: "string", description: "Filter by end date YYYY-MM-DD" },
},
},
},
},
{
type: "function",
function: {
name: "update_allocation_status",
description: "Change the status of an existing allocation. Can reactivate cancelled allocations, confirm proposed ones, etc. Requires manageAllocations permission. Always confirm with the user before calling.",
parameters: {
type: "object",
properties: {
allocationId: { type: "string", description: "Allocation ID" },
resourceName: { type: "string", description: "Resource name (partial match, used if no allocationId)" },
projectCode: { type: "string", description: "Project short code (partial match, used if no allocationId)" },
startDate: { type: "string", description: "Start date filter YYYY-MM-DD (used if no allocationId)" },
newStatus: { type: "string", description: "New status: PROPOSED, CONFIRMED, ACTIVE, COMPLETED, CANCELLED" },
},
required: ["newStatus"],
},
},
},
], {
create_allocation: {
requiredPermissions: [PermissionKey.MANAGE_ALLOCATIONS],
},
cancel_allocation: {
requiredPermissions: [PermissionKey.MANAGE_ALLOCATIONS],
},
update_allocation_status: {
requiredPermissions: [PermissionKey.MANAGE_ALLOCATIONS],
},
});
export function createAllocationPlanningExecutors(
deps: AllocationPlanningDeps,
): Record<string, ToolExecutor> {
return {
async list_allocations(params: {
resourceId?: string;
projectId?: string;
resourceName?: string;
projectCode?: string;
status?: string;
limit?: number;
}, ctx: ToolContext) {
const caller = deps.createAllocationCaller(deps.createScopedCallerContext(ctx));
const status = params.status && Object.values(AllocationStatus).includes(params.status as AllocationStatus)
? params.status as AllocationStatus
: undefined;
const readModel = await caller.listView({
...(params.resourceId ? { resourceId: params.resourceId } : {}),
...(params.projectId ? { projectId: params.projectId } : {}),
...(status ? { status } : {}),
});
const resourceNameQuery = params.resourceName?.trim().toLowerCase();
const projectCodeQuery = params.projectCode?.trim().toLowerCase();
const limit = Math.min(params.limit ?? 30, 50);
return readModel.assignments
.filter((assignment) => {
if (
resourceNameQuery
&& !assignment.resource?.displayName?.toLowerCase().includes(resourceNameQuery)
) {
return false;
}
if (
projectCodeQuery
&& !assignment.project?.shortCode?.toLowerCase().includes(projectCodeQuery)
) {
return false;
}
return true;
})
.slice(0, limit)
.map((assignment) => ({
id: assignment.id,
resource: assignment.resource?.displayName ?? "Unknown",
resourceEid: assignment.resource?.eid ?? null,
project: assignment.project?.name ?? "Unknown",
projectCode: assignment.project?.shortCode ?? null,
role: assignment.role ?? assignment.roleEntity?.name ?? null,
status: assignment.status,
hoursPerDay: assignment.hoursPerDay,
dailyCost: assignment.dailyCostCents == null ? null : fmtEur(assignment.dailyCostCents),
start: deps.fmtDate(new Date(assignment.startDate)),
end: deps.fmtDate(new Date(assignment.endDate)),
}));
},
async get_budget_status(params: { projectId: string }, ctx: ToolContext) {
const project = await deps.resolveProjectIdentifier(ctx, params.projectId);
if ("error" in project) {
return project;
}
const caller = deps.createTimelineCaller(deps.createScopedCallerContext(ctx));
const budgetStatus = await caller.getBudgetStatus({ projectId: project.id });
if (budgetStatus.budgetCents <= 0) {
return {
project: budgetStatus.projectName,
code: budgetStatus.projectCode,
budget: "Not set",
note: "No budget defined for this project",
totalAllocations: budgetStatus.totalAllocations,
};
}
return {
project: budgetStatus.projectName,
code: budgetStatus.projectCode,
budget: fmtEur(budgetStatus.budgetCents),
confirmed: fmtEur(budgetStatus.confirmedCents),
proposed: fmtEur(budgetStatus.proposedCents),
allocated: fmtEur(budgetStatus.allocatedCents),
remaining: fmtEur(budgetStatus.remainingCents),
utilization: `${budgetStatus.utilizationPercent.toFixed(1)}%`,
winWeighted: fmtEur(budgetStatus.winProbabilityWeightedCents),
};
},
async create_allocation(params: {
resourceId: string;
projectId: string;
startDate: string;
endDate: string;
hoursPerDay: number;
role?: string;
}, ctx: ToolContext) {
deps.assertPermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const [resource, project] = await Promise.all([
deps.resolveResourceIdentifier(ctx, params.resourceId),
deps.resolveProjectIdentifier(ctx, params.projectId),
]);
if ("error" in resource) {
return resource;
}
if ("error" in project) {
return project;
}
const caller = deps.createAllocationCaller(deps.createScopedCallerContext(ctx));
try {
const result = await caller.ensureAssignment({
resourceId: resource.id,
projectId: project.id,
startDate: deps.parseIsoDate(params.startDate, "startDate"),
endDate: deps.parseIsoDate(params.endDate, "endDate"),
hoursPerDay: params.hoursPerDay,
...(params.role ? { role: params.role } : {}),
});
return {
__action: "invalidate",
scope: ["allocation", "timeline"],
success: true,
message: `${result.action === "reactivated" ? "Reactivated" : "Created"} allocation: ${resource.displayName}${project.name} (${project.shortCode}), ${params.hoursPerDay}h/day, ${params.startDate} to ${params.endDate}`,
allocationId: result.assignment.id,
status: result.assignment.status,
};
} catch (error) {
if (error instanceof TRPCError && error.code === "CONFLICT") {
return { error: "Allocation already exists for this resource/project/dates. No new allocation created." };
}
throw error;
}
},
async cancel_allocation(params: {
allocationId?: string;
resourceName?: string;
projectCode?: string;
startDate?: string;
endDate?: string;
}, ctx: ToolContext) {
deps.assertPermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const caller = deps.createAllocationCaller(deps.createScopedCallerContext(ctx));
let resourceId: string | undefined;
let projectId: string | undefined;
if (!params.allocationId && params.resourceName && params.projectCode) {
const [resource, project] = await Promise.all([
deps.resolveResourceIdentifier(ctx, params.resourceName),
deps.resolveProjectIdentifier(ctx, params.projectCode),
]);
if ("error" in resource) {
return resource;
}
if ("error" in project) {
return project;
}
resourceId = resource.id;
projectId = project.id;
}
const startDate = deps.parseOptionalIsoDate(params.startDate, "startDate");
const endDate = deps.parseOptionalIsoDate(params.endDate, "endDate");
let assignment;
try {
assignment = await caller.resolveAssignment({
...(params.allocationId ? { assignmentId: params.allocationId } : {}),
...(resourceId ? { resourceId } : {}),
...(projectId ? { projectId } : {}),
...(startDate ? { startDate } : {}),
...(endDate ? { endDate } : {}),
selectionMode: "WINDOW",
excludeCancelled: true,
});
} catch (error) {
const mapped = deps.toAssistantAllocationNotFoundError(error);
if (mapped) {
return mapped;
}
throw error;
}
try {
await caller.updateAssignment({
id: assignment.id,
data: UpdateAssignmentSchema.parse({ status: AllocationStatus.CANCELLED }),
});
} catch (error) {
const mapped = deps.toAssistantAllocationNotFoundError(error);
if (mapped) {
return mapped;
}
throw error;
}
return {
__action: "invalidate",
scope: ["allocation", "timeline"],
success: true,
message: `Cancelled allocation: ${assignment.resource.displayName}${assignment.project.name} (${assignment.project.shortCode}), ${deps.fmtDate(assignment.startDate)} to ${deps.fmtDate(assignment.endDate)}`,
};
},
async update_allocation_status(params: {
allocationId?: string;
resourceName?: string;
projectCode?: string;
startDate?: string;
newStatus: string;
}, ctx: ToolContext) {
deps.assertPermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const validStatuses = ["PROPOSED", "CONFIRMED", "ACTIVE", "COMPLETED", "CANCELLED"];
if (!validStatuses.includes(params.newStatus)) {
return { error: `Invalid status: ${params.newStatus}. Valid: ${validStatuses.join(", ")}` };
}
const caller = deps.createAllocationCaller(deps.createScopedCallerContext(ctx));
let resourceId: string | undefined;
let projectId: string | undefined;
if (!params.allocationId && params.resourceName && params.projectCode) {
const [resource, project] = await Promise.all([
deps.resolveResourceIdentifier(ctx, params.resourceName),
deps.resolveProjectIdentifier(ctx, params.projectCode),
]);
if ("error" in resource) {
return resource;
}
if ("error" in project) {
return project;
}
resourceId = resource.id;
projectId = project.id;
}
const startDate = deps.parseOptionalIsoDate(params.startDate, "startDate");
let assignment;
try {
assignment = await caller.resolveAssignment({
...(params.allocationId ? { assignmentId: params.allocationId } : {}),
...(resourceId ? { resourceId } : {}),
...(projectId ? { projectId } : {}),
...(startDate ? { startDate } : {}),
selectionMode: "EXACT_START",
});
} catch (error) {
const mapped = deps.toAssistantAllocationNotFoundError(error);
if (mapped) {
return mapped;
}
throw error;
}
const oldStatus = assignment.status;
try {
await caller.updateAssignment({
id: assignment.id,
data: UpdateAssignmentSchema.parse({
status: params.newStatus as AllocationStatus,
}),
});
} catch (error) {
const mapped = deps.toAssistantAllocationNotFoundError(error);
if (mapped) {
return mapped;
}
throw error;
}
return {
__action: "invalidate",
scope: ["allocation", "timeline"],
success: true,
message: `Updated allocation status: ${assignment.resource.displayName}${assignment.project.name} (${assignment.project.shortCode}), ${deps.fmtDate(assignment.startDate)} to ${deps.fmtDate(assignment.endDate)}: ${oldStatus}${params.newStatus}`,
};
},
};
}
@@ -1,6 +1,6 @@
import type { TRPCContext } from "../../trpc.js";
import { PermissionKey } from "@capakraken/shared";
import type { ToolContext, ToolDef, ToolExecutor } from "./shared.js";
import { PermissionKey, SystemRole } from "@capakraken/shared";
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
type AssistantToolErrorResult = { error: string };
@@ -67,7 +67,7 @@ export type ChargeabilityComputationDeps = {
) => Promise<ResolvedProject | AssistantToolErrorResult>;
};
export const chargeabilityComputationReadToolDefinitions: ToolDef[] = [
export const chargeabilityComputationReadToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
@@ -122,7 +122,23 @@ export const chargeabilityComputationReadToolDefinitions: ToolDef[] = [
},
},
},
];
], {
get_chargeability_report: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
requiredPermissions: [PermissionKey.VIEW_COSTS],
requiresAdvancedAssistant: true,
},
get_resource_computation_graph: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
requiredPermissions: [PermissionKey.VIEW_COSTS],
requiresAdvancedAssistant: true,
},
get_project_computation_graph: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
requiredPermissions: [PermissionKey.VIEW_COSTS],
requiresAdvancedAssistant: true,
},
});
export function createChargeabilityComputationExecutors(
deps: ChargeabilityComputationDeps,
@@ -2,11 +2,12 @@ import type { TRPCContext } from "../../trpc.js";
import {
CreateClientSchema,
CreateOrgUnitSchema,
SystemRole,
UpdateClientSchema,
UpdateOrgUnitSchema,
} from "@capakraken/shared";
import { z } from "zod";
import type { ToolContext, ToolDef, ToolExecutor } from "./shared.js";
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
type AssistantToolErrorResult = { error: string };
@@ -49,7 +50,7 @@ type ClientsOrgUnitsDeps = {
) => AssistantToolErrorResult | null;
};
export const clientMutationToolDefinitions: ToolDef[] = [
export const clientMutationToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
@@ -102,9 +103,19 @@ export const clientMutationToolDefinitions: ToolDef[] = [
},
},
},
];
], {
create_client: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
},
update_client: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
},
delete_client: {
allowedSystemRoles: [SystemRole.ADMIN],
},
});
export const orgUnitMutationToolDefinitions: ToolDef[] = [
export const orgUnitMutationToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
@@ -142,7 +153,14 @@ export const orgUnitMutationToolDefinitions: ToolDef[] = [
},
},
},
];
], {
create_org_unit: {
allowedSystemRoles: [SystemRole.ADMIN],
},
update_org_unit: {
allowedSystemRoles: [SystemRole.ADMIN],
},
});
export function createClientsOrgUnitsExecutors(
deps: ClientsOrgUnitsDeps,
@@ -1,5 +1,6 @@
import { SystemRole } from "@capakraken/shared";
import type { TRPCContext } from "../../trpc.js";
import type { ToolContext, ToolDef, ToolExecutor } from "./shared.js";
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
type ConfigReadmodelsDeps = {
createManagementLevelCaller: (ctx: TRPCContext) => {
@@ -77,7 +78,7 @@ type ConfigReadmodelsDeps = {
createScopedCallerContext: (ctx: ToolContext) => TRPCContext;
};
export const configReadmodelToolDefinitions: ToolDef[] = [
export const configReadmodelToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
@@ -118,7 +119,23 @@ export const configReadmodelToolDefinitions: ToolDef[] = [
parameters: { type: "object", properties: {} },
},
},
];
], {
list_management_levels: {
requiresPlanningRead: true,
},
list_utilization_categories: {
requiresPlanningRead: true,
},
list_calculation_rules: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
list_effort_rules: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
list_experience_multipliers: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
});
export function createConfigReadmodelExecutors(
deps: ConfigReadmodelsDeps,
@@ -1,4 +1,5 @@
import type { Prisma } from "@capakraken/db";
import { SystemRole } from "@capakraken/shared";
import type { TRPCContext } from "../../trpc.js";
import {
CreateCountrySchema,
@@ -7,7 +8,7 @@ import {
UpdateMetroCitySchema,
} from "@capakraken/shared";
import { z } from "zod";
import type { ToolContext, ToolDef, ToolExecutor } from "./shared.js";
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
type AssistantToolErrorResult = { error: string };
@@ -52,7 +53,7 @@ type CountryMetroAdminDeps = {
) => AssistantToolErrorResult | null;
};
export const countryMetroAdminToolDefinitions: ToolDef[] = [
export const countryMetroAdminToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
@@ -146,7 +147,23 @@ export const countryMetroAdminToolDefinitions: ToolDef[] = [
},
},
},
];
], {
create_country: {
allowedSystemRoles: [SystemRole.ADMIN],
},
update_country: {
allowedSystemRoles: [SystemRole.ADMIN],
},
create_metro_city: {
allowedSystemRoles: [SystemRole.ADMIN],
},
update_metro_city: {
allowedSystemRoles: [SystemRole.ADMIN],
},
delete_metro_city: {
allowedSystemRoles: [SystemRole.ADMIN],
},
});
export function createCountryMetroAdminExecutors(
deps: CountryMetroAdminDeps,
@@ -2,11 +2,16 @@ import type {
CreateEstimateInput,
EstimateExportFormat,
EstimateStatus,
PermissionKey,
UpdateEstimateDraftInput,
} from "@capakraken/shared";
import { PermissionKey, SystemRole } from "@capakraken/shared";
import type { TRPCContext } from "../../trpc.js";
import type { ToolContext, ToolDef, ToolExecutor } from "./shared.js";
import {
withToolAccess,
type ToolContext,
type ToolDef,
type ToolExecutor,
} from "./shared.js";
type AssistantToolErrorResult = { error: string };
@@ -155,7 +160,7 @@ async function resolveEstimateProjectId(
return project.id;
}
export const estimateReadToolDefinitions: ToolDef[] = [
export const estimateReadToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
@@ -228,9 +233,27 @@ export const estimateReadToolDefinitions: ToolDef[] = [
},
},
},
];
], {
get_estimate_detail: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
requiresCostView: true,
},
list_estimate_versions: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
get_estimate_version_snapshot: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
requiresCostView: true,
},
get_estimate_weekly_phasing: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
get_estimate_commercial_terms: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
});
export const estimateMutationToolDefinitions: ToolDef[] = [
export const estimateMutationToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
@@ -412,7 +435,48 @@ export const estimateMutationToolDefinitions: ToolDef[] = [
},
},
},
];
], {
create_estimate: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
requiredPermissions: [PermissionKey.MANAGE_PROJECTS],
},
clone_estimate: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
requiredPermissions: [PermissionKey.MANAGE_PROJECTS],
},
update_estimate_draft: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
requiredPermissions: [PermissionKey.MANAGE_PROJECTS],
},
submit_estimate_version: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
requiredPermissions: [PermissionKey.MANAGE_PROJECTS],
},
approve_estimate_version: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
requiredPermissions: [PermissionKey.MANAGE_PROJECTS],
},
create_estimate_revision: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
requiredPermissions: [PermissionKey.MANAGE_PROJECTS],
},
create_estimate_export: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
requiredPermissions: [PermissionKey.MANAGE_PROJECTS],
},
create_estimate_planning_handoff: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
requiredPermissions: [PermissionKey.MANAGE_ALLOCATIONS],
},
generate_estimate_weekly_phasing: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
requiredPermissions: [PermissionKey.MANAGE_PROJECTS],
},
update_estimate_commercial_terms: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
requiredPermissions: [PermissionKey.MANAGE_PROJECTS],
},
});
export function createEstimateExecutors(
deps: EstimateToolsDeps,
@@ -1,5 +1,6 @@
import { SystemRole } from "@capakraken/shared";
import type { TRPCContext } from "../../trpc.js";
import type { ToolContext, ToolDef, ToolExecutor } from "./shared.js";
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
type AssistantToolErrorResult = { error: string };
@@ -143,7 +144,7 @@ type NotificationsTasksDeps = {
) => AssistantToolErrorResult | null;
};
export const notificationInboxToolDefinitions: ToolDef[] = [
export const notificationInboxToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
@@ -207,9 +208,13 @@ export const notificationInboxToolDefinitions: ToolDef[] = [
},
},
},
];
], {
create_notification: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
},
});
export const notificationTaskToolDefinitions: ToolDef[] = [
export const notificationTaskToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
@@ -444,7 +449,23 @@ export const notificationTaskToolDefinitions: ToolDef[] = [
},
},
},
];
], {
create_task_for_user: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
},
assign_task: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
},
send_broadcast: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
},
list_broadcasts: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
},
get_broadcast_detail: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
},
});
export function createNotificationsTasksExecutors(
deps: NotificationsTasksDeps,
@@ -25,9 +25,6 @@ type ProjectRecord = ProjectSummaryRecord & {
status?: string;
};
type ParsedCreateProjectInput = ReturnType<typeof CreateProjectSchema.parse>;
type ParsedUpdateProjectInput = ReturnType<typeof UpdateProjectSchema.parse>;
type ResponsiblePersonResolution =
| {
status: "resolved";
@@ -41,17 +38,10 @@ type ResponsiblePersonResolution =
type ProjectToolsDeps = {
assertPermission: (ctx: ToolContext, perm: PermissionKey) => void;
createProjectCaller: (ctx: TRPCContext) => {
searchSummariesDetail: (params: {
search?: string | undefined;
status?: ProjectStatus | undefined;
limit: number;
}) => Promise<unknown>;
searchSummariesDetail: (params: any) => Promise<unknown>;
getByIdentifierDetail: (params: { identifier: string }) => Promise<unknown>;
update: (params: {
id: string;
data: ParsedUpdateProjectInput;
}) => Promise<ProjectSummaryRecord>;
create: (params: ParsedCreateProjectInput) => Promise<ProjectRecord>;
update: (params: any) => Promise<ProjectSummaryRecord>;
create: (params: any) => Promise<ProjectRecord>;
delete: (params: { id: string }) => Promise<unknown>;
generateCover: (params: {
projectId: string;
@@ -1,7 +1,7 @@
import type { TRPCContext } from "../../trpc.js";
import { CreateRoleSchema, UpdateRoleSchema } from "@capakraken/shared";
import { CreateRoleSchema, PermissionKey, SystemRole, UpdateRoleSchema } from "@capakraken/shared";
import { z } from "zod";
import type { ToolContext, ToolDef, ToolExecutor } from "./shared.js";
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
type AssistantToolErrorResult = { error: string };
@@ -63,7 +63,7 @@ type RolesAnalyticsDeps = {
) => AssistantToolErrorResult | null;
};
export const rolesAnalyticsReadToolDefinitions: ToolDef[] = [
export const rolesAnalyticsReadToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
@@ -109,9 +109,22 @@ export const rolesAnalyticsReadToolDefinitions: ToolDef[] = [
},
},
},
];
], {
list_roles: {
requiresPlanningRead: true,
},
search_by_skill: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
get_statistics: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
get_chargeability: {
requiresCostView: true,
},
});
export const rolesAnalyticsMutationToolDefinitions: ToolDef[] = [
export const rolesAnalyticsMutationToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
@@ -160,7 +173,20 @@ export const rolesAnalyticsMutationToolDefinitions: ToolDef[] = [
},
},
},
];
], {
create_role: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
requiredPermissions: [PermissionKey.MANAGE_ROLES],
},
update_role: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
requiredPermissions: [PermissionKey.MANAGE_ROLES],
},
delete_role: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
requiredPermissions: [PermissionKey.MANAGE_ROLES],
},
});
export function createRolesAnalyticsExecutors(
deps: RolesAnalyticsDeps,
@@ -142,6 +142,7 @@ export const scenarioRateAnalysisToolDefinitions: ToolDef[] = withToolAccess([
], {
lookup_rate: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
requiredPermissions: [PermissionKey.VIEW_COSTS],
},
simulate_scenario: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
@@ -0,0 +1,809 @@
import { isAiConfigured } from "../../ai-client.js";
import { resolveSystemSettingsRuntime } from "../../lib/system-settings-runtime.js";
import type { TRPCContext } from "../../trpc.js";
import { SystemRole } from "@capakraken/shared";
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
export const settingsAdminToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
name: "get_system_settings",
description: "Get sanitized system settings through the real settings router. Admin role required.",
parameters: {
type: "object",
properties: {},
},
},
},
{
type: "function",
function: {
name: "update_system_settings",
description: "Update non-secret system settings through the real settings router. Runtime secrets must be provisioned via deployment environment or secret manager. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
aiProvider: { type: "string", enum: ["openai", "azure"] },
azureOpenAiEndpoint: { type: "string" },
azureOpenAiDeployment: { type: "string" },
azureApiVersion: { type: "string" },
aiMaxCompletionTokens: { type: "integer" },
aiTemperature: { type: "number" },
aiSummaryPrompt: { type: "string" },
scoreWeights: { type: "object" },
scoreVisibleRoles: { type: "array", items: { type: "string" } },
smtpHost: { type: "string" },
smtpPort: { type: "integer" },
smtpUser: { type: "string" },
smtpFrom: { type: "string" },
smtpTls: { type: "boolean" },
anonymizationEnabled: { type: "boolean" },
anonymizationDomain: { type: "string" },
anonymizationMode: { type: "string", enum: ["global"] },
azureDalleDeployment: { type: "string" },
azureDalleEndpoint: { type: "string" },
geminiModel: { type: "string" },
imageProvider: { type: "string", enum: ["dalle", "gemini"] },
vacationDefaultDays: { type: "integer" },
timelineUndoMaxSteps: { type: "integer" },
},
},
},
},
{
type: "function",
function: {
name: "clear_stored_runtime_secrets",
description: "Clear legacy database-stored runtime secrets after they have been migrated to deployment secret management. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {},
},
},
},
{
type: "function",
function: {
name: "test_ai_connection",
description: "Run the real AI connection test from system settings. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {},
},
},
},
{
type: "function",
function: {
name: "test_smtp_connection",
description: "Run the real SMTP connection test from system settings. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {},
},
},
},
{
type: "function",
function: {
name: "test_gemini_connection",
description: "Run the real Gemini connection test from system settings. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {},
},
},
},
{
type: "function",
function: {
name: "get_ai_configured",
description: "Get whether AI is configured for the current system via the real settings router. Admin role required.",
parameters: {
type: "object",
properties: {},
},
},
},
{
type: "function",
function: {
name: "list_system_role_configs",
description: "List system role configuration defaults via the real system-role-config router. Admin role required.",
parameters: {
type: "object",
properties: {},
},
},
},
{
type: "function",
function: {
name: "update_system_role_config",
description: "Update one system role configuration via the real system-role-config router. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
role: { type: "string", description: "System role key." },
label: { type: "string", description: "Optional role label." },
description: { type: "string", description: "Optional role description." },
color: { type: "string", description: "Optional role color." },
defaultPermissions: { type: "array", items: { type: "string" }, description: "Optional default permission set." },
},
required: ["role"],
},
},
},
{
type: "function",
function: {
name: "list_webhooks",
description: "List webhooks via the real webhook router. Secrets are masked in assistant responses. Admin role required.",
parameters: {
type: "object",
properties: {},
},
},
},
{
type: "function",
function: {
name: "get_webhook",
description: "Get one webhook via the real webhook router. Secrets are masked in assistant responses. Admin role required.",
parameters: {
type: "object",
properties: {
id: { type: "string", description: "Webhook ID." },
},
required: ["id"],
},
},
},
{
type: "function",
function: {
name: "create_webhook",
description: "Create a webhook via the real webhook router. Secrets are masked in assistant responses. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
name: { type: "string", description: "Webhook name." },
url: { type: "string", description: "Webhook target URL." },
secret: { type: "string", description: "Optional webhook signing secret." },
events: { type: "array", items: { type: "string" }, description: "Subscribed webhook events." },
isActive: { type: "boolean", description: "Whether the webhook is active. Default: true." },
},
required: ["name", "url", "events"],
},
},
},
{
type: "function",
function: {
name: "update_webhook",
description: "Update a webhook via the real webhook router. Secrets are masked in assistant responses. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
id: { type: "string", description: "Webhook ID." },
data: {
type: "object",
properties: {
name: { type: "string" },
url: { type: "string" },
secret: { type: "string" },
events: { type: "array", items: { type: "string" } },
isActive: { type: "boolean" },
},
},
},
required: ["id", "data"],
},
},
},
{
type: "function",
function: {
name: "delete_webhook",
description: "Delete a webhook via the real webhook router. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
id: { type: "string", description: "Webhook ID." },
},
required: ["id"],
},
},
},
{
type: "function",
function: {
name: "test_webhook",
description: "Send a real test payload to a webhook via the real webhook router. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
id: { type: "string", description: "Webhook ID." },
},
required: ["id"],
},
},
},
{
type: "function",
function: {
name: "list_audit_log_entries",
description: "List audit log entries with full audit-router filters and cursor pagination. Controller/manager/admin roles only.",
parameters: {
type: "object",
properties: {
entityType: { type: "string", description: "Optional entity type filter." },
entityId: { type: "string", description: "Optional entity ID filter." },
userId: { type: "string", description: "Optional user ID filter." },
action: { type: "string", description: "Optional action filter such as CREATE, UPDATE, DELETE, SHIFT, IMPORT." },
source: { type: "string", description: "Optional source filter such as ui or assistant." },
startDate: { type: "string", description: "Optional start date in YYYY-MM-DD." },
endDate: { type: "string", description: "Optional end date in YYYY-MM-DD." },
search: { type: "string", description: "Optional case-insensitive search across entity name, summary, and entity type." },
limit: { type: "integer", description: "Max results. Default: 50, max: 100." },
cursor: { type: "string", description: "Optional pagination cursor (last seen audit entry ID)." },
},
},
},
},
{
type: "function",
function: {
name: "get_audit_log_entry",
description: "Get one audit log entry including the full changes payload. Controller/manager/admin roles only.",
parameters: {
type: "object",
properties: {
id: { type: "string", description: "Audit log entry ID." },
},
required: ["id"],
},
},
},
{
type: "function",
function: {
name: "get_audit_log_timeline",
description: "Get audit log entries grouped by day for a time window. Controller/manager/admin roles only.",
parameters: {
type: "object",
properties: {
startDate: { type: "string", description: "Optional start date in YYYY-MM-DD." },
endDate: { type: "string", description: "Optional end date in YYYY-MM-DD." },
limit: { type: "integer", description: "Max entries. Default: 200, max: 500." },
},
},
},
},
{
type: "function",
function: {
name: "get_audit_activity_summary",
description: "Get audit activity totals by entity type, action, and user for a date range. Controller/manager/admin roles only.",
parameters: {
type: "object",
properties: {
startDate: { type: "string", description: "Optional start date in YYYY-MM-DD." },
endDate: { type: "string", description: "Optional end date in YYYY-MM-DD." },
},
},
},
},
{
type: "function",
function: {
name: "get_shoring_ratio",
description: "Get the onshore/offshore staffing ratio for a project. Higher offshore is better (cost-efficient). The threshold is the MINIMUM offshore target. Shows country breakdown and whether the target is met.",
parameters: {
type: "object",
properties: {
projectId: { type: "string", description: "Project ID or short code" },
},
required: ["projectId"],
},
},
},
], {
get_system_settings: {
allowedSystemRoles: [SystemRole.ADMIN],
},
update_system_settings: {
allowedSystemRoles: [SystemRole.ADMIN],
},
clear_stored_runtime_secrets: {
allowedSystemRoles: [SystemRole.ADMIN],
},
test_ai_connection: {
allowedSystemRoles: [SystemRole.ADMIN],
},
test_smtp_connection: {
allowedSystemRoles: [SystemRole.ADMIN],
},
test_gemini_connection: {
allowedSystemRoles: [SystemRole.ADMIN],
},
get_ai_configured: {
allowedSystemRoles: [SystemRole.ADMIN],
},
list_system_role_configs: {
allowedSystemRoles: [SystemRole.ADMIN],
},
update_system_role_config: {
allowedSystemRoles: [SystemRole.ADMIN],
},
list_webhooks: {
allowedSystemRoles: [SystemRole.ADMIN],
},
get_webhook: {
allowedSystemRoles: [SystemRole.ADMIN],
},
create_webhook: {
allowedSystemRoles: [SystemRole.ADMIN],
},
update_webhook: {
allowedSystemRoles: [SystemRole.ADMIN],
},
delete_webhook: {
allowedSystemRoles: [SystemRole.ADMIN],
},
test_webhook: {
allowedSystemRoles: [SystemRole.ADMIN],
},
list_audit_log_entries: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
get_audit_log_entry: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
get_audit_log_timeline: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
get_audit_activity_summary: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
get_shoring_ratio: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
});
type SettingsAdminDeps = {
createSettingsCaller: (ctx: TRPCContext) => {
getSystemSettings: () => Promise<unknown>;
updateSystemSettings: (params: {
aiProvider?: "openai" | "azure";
azureOpenAiEndpoint?: string;
azureOpenAiDeployment?: string;
azureApiVersion?: string;
aiMaxCompletionTokens?: number;
aiTemperature?: number;
aiSummaryPrompt?: string;
scoreWeights?: {
skillDepth: number;
skillBreadth: number;
costEfficiency: number;
chargeability: number;
experience: number;
};
scoreVisibleRoles?: SystemRole[];
smtpHost?: string;
smtpPort?: number;
smtpUser?: string;
smtpFrom?: string;
smtpTls?: boolean;
anonymizationEnabled?: boolean;
anonymizationDomain?: string;
anonymizationMode?: "global";
azureDalleDeployment?: string;
azureDalleEndpoint?: string;
geminiModel?: string;
imageProvider?: "dalle" | "gemini";
vacationDefaultDays?: number;
timelineUndoMaxSteps?: number;
}) => Promise<unknown>;
clearStoredRuntimeSecrets: () => Promise<unknown>;
testAiConnection: () => Promise<unknown>;
testSmtpConnection: () => Promise<unknown>;
testGeminiConnection: () => Promise<unknown>;
};
createSystemRoleConfigCaller: (ctx: TRPCContext) => {
list: () => Promise<unknown>;
update: (params: {
role: string;
label?: string;
description?: string | null;
color?: string | null;
defaultPermissions?: string[];
}) => Promise<unknown>;
};
createWebhookCaller: (ctx: TRPCContext) => {
list: () => Promise<Array<{ secret?: string | null }>>;
getById: (params: { id: string }) => Promise<{ secret?: string | null }>;
create: (params: {
name: string;
url: string;
secret?: string;
events: [string, ...string[]];
isActive?: boolean;
}) => Promise<{ secret?: string | null }>;
update: (params: {
id: string;
data: {
name?: string;
url?: string;
secret?: string | null;
events?: [string, ...string[]];
isActive?: boolean;
};
}) => Promise<{ secret?: string | null }>;
delete: (params: { id: string }) => Promise<unknown>;
test: (params: { id: string }) => Promise<unknown>;
};
createAuditLogCaller: (ctx: TRPCContext) => {
listDetail: (params: {
entityType?: string;
entityId?: string;
userId?: string;
action?: string;
source?: string;
startDate?: Date;
endDate?: Date;
search?: string;
limit?: number;
cursor?: string;
}) => Promise<{ items: unknown[]; nextCursor?: string | null }>;
getByIdDetail: (params: { id: string }) => Promise<unknown>;
getTimelineDetail: (params: {
startDate?: Date;
endDate?: Date;
limit?: number;
}) => Promise<unknown>;
getActivitySummary: (params: {
startDate?: Date;
endDate?: Date;
}) => Promise<unknown>;
};
createProjectCaller: (ctx: TRPCContext) => {
getShoringRatio: (params: { projectId: string }) => Promise<{
totalHours: number;
byCountry: Record<string, { pct: number; resourceCount: number }>;
offshoreRatio: number;
threshold: number;
onshoreRatio: number;
onshoreCountryCode: string;
unknownCount: number;
}>;
};
createScopedCallerContext: (ctx: ToolContext) => TRPCContext;
parseIsoDate: (value: string, fieldName: string) => Date;
resolveProjectIdentifier: (
ctx: ToolContext,
identifier: string,
) => Promise<{ id: string; name: string; shortCode: string } | { error: string }>;
sanitizeWebhook: <T extends { secret?: string | null }>(webhook: T) => Omit<T, "secret"> & { hasSecret: boolean };
sanitizeWebhookList: <T extends { secret?: string | null }>(webhooks: T[]) => Array<Omit<T, "secret"> & { hasSecret: boolean }>;
toAssistantWebhookNotFoundError: (error: unknown) => unknown;
toAssistantWebhookMutationError: (error: unknown, action?: "create" | "update") => unknown;
toAssistantAuditLogEntryNotFoundError: (error: unknown) => unknown;
};
export function createSettingsAdminExecutors(deps: SettingsAdminDeps): Record<string, ToolExecutor> {
return {
async get_system_settings(_params: Record<string, never>, ctx: ToolContext) {
const caller = deps.createSettingsCaller(deps.createScopedCallerContext(ctx));
return caller.getSystemSettings();
},
async update_system_settings(params: {
aiProvider?: "openai" | "azure";
azureOpenAiEndpoint?: string;
azureOpenAiDeployment?: string;
azureApiVersion?: string;
aiMaxCompletionTokens?: number;
aiTemperature?: number;
aiSummaryPrompt?: string;
scoreWeights?: {
skillDepth: number;
skillBreadth: number;
costEfficiency: number;
chargeability: number;
experience: number;
};
scoreVisibleRoles?: SystemRole[];
smtpHost?: string;
smtpPort?: number;
smtpUser?: string;
smtpFrom?: string;
smtpTls?: boolean;
anonymizationEnabled?: boolean;
anonymizationDomain?: string;
anonymizationMode?: "global";
azureDalleDeployment?: string;
azureDalleEndpoint?: string;
geminiModel?: string;
imageProvider?: "dalle" | "gemini";
vacationDefaultDays?: number;
timelineUndoMaxSteps?: number;
}, ctx: ToolContext) {
const caller = deps.createSettingsCaller(deps.createScopedCallerContext(ctx));
return caller.updateSystemSettings(params);
},
async clear_stored_runtime_secrets(_params: Record<string, never>, ctx: ToolContext) {
const caller = deps.createSettingsCaller(deps.createScopedCallerContext(ctx));
return caller.clearStoredRuntimeSecrets();
},
async test_ai_connection(_params: Record<string, never>, ctx: ToolContext) {
const caller = deps.createSettingsCaller(deps.createScopedCallerContext(ctx));
return caller.testAiConnection();
},
async test_smtp_connection(_params: Record<string, never>, ctx: ToolContext) {
const caller = deps.createSettingsCaller(deps.createScopedCallerContext(ctx));
return caller.testSmtpConnection();
},
async test_gemini_connection(_params: Record<string, never>, ctx: ToolContext) {
const caller = deps.createSettingsCaller(deps.createScopedCallerContext(ctx));
return caller.testGeminiConnection();
},
async get_ai_configured(_params: Record<string, never>, ctx: ToolContext) {
const settings = resolveSystemSettingsRuntime(await ctx.db.systemSettings.findUnique({
where: { id: "singleton" },
select: {
aiProvider: true,
azureOpenAiEndpoint: true,
azureOpenAiDeployment: true,
azureOpenAiApiKey: true,
},
}));
return { configured: isAiConfigured(settings) };
},
async list_system_role_configs(_params: Record<string, never>, ctx: ToolContext) {
const caller = deps.createSystemRoleConfigCaller(deps.createScopedCallerContext(ctx));
return caller.list();
},
async update_system_role_config(params: {
role: string;
label?: string;
description?: string | null;
color?: string | null;
defaultPermissions?: string[];
}, ctx: ToolContext) {
const caller = deps.createSystemRoleConfigCaller(deps.createScopedCallerContext(ctx));
return caller.update(params);
},
async list_webhooks(_params: Record<string, never>, ctx: ToolContext) {
const caller = deps.createWebhookCaller(deps.createScopedCallerContext(ctx));
const webhooks = await caller.list();
return deps.sanitizeWebhookList(webhooks);
},
async get_webhook(params: {
id: string;
}, ctx: ToolContext) {
const caller = deps.createWebhookCaller(deps.createScopedCallerContext(ctx));
let webhook;
try {
webhook = await caller.getById({ id: params.id });
} catch (error) {
const mapped = deps.toAssistantWebhookNotFoundError(error);
if (mapped) {
return mapped;
}
throw error;
}
return deps.sanitizeWebhook(webhook);
},
async create_webhook(params: {
name: string;
url: string;
secret?: string;
events: string[];
isActive?: boolean;
}, ctx: ToolContext) {
const caller = deps.createWebhookCaller(deps.createScopedCallerContext(ctx));
let webhook;
try {
webhook = await caller.create({
name: params.name,
url: params.url,
events: params.events as [string, ...string[]],
...(params.secret !== undefined ? { secret: params.secret } : {}),
...(params.isActive !== undefined ? { isActive: params.isActive } : {}),
});
} catch (error) {
const mapped = deps.toAssistantWebhookMutationError(error, "create");
if (mapped) {
return mapped;
}
throw error;
}
return deps.sanitizeWebhook(webhook);
},
async update_webhook(params: {
id: string;
data: {
name?: string;
url?: string;
secret?: string | null;
events?: string[];
isActive?: boolean;
};
}, ctx: ToolContext) {
const caller = deps.createWebhookCaller(deps.createScopedCallerContext(ctx));
let webhook;
try {
webhook = await caller.update({
id: params.id,
data: {
...(params.data.name !== undefined ? { name: params.data.name } : {}),
...(params.data.url !== undefined ? { url: params.data.url } : {}),
...(params.data.secret !== undefined ? { secret: params.data.secret } : {}),
...(params.data.events !== undefined ? { events: params.data.events as [string, ...string[]] } : {}),
...(params.data.isActive !== undefined ? { isActive: params.data.isActive } : {}),
},
});
} catch (error) {
const mapped = deps.toAssistantWebhookMutationError(error, "update");
if (mapped) {
return mapped;
}
throw error;
}
return deps.sanitizeWebhook(webhook);
},
async delete_webhook(params: {
id: string;
}, ctx: ToolContext) {
const caller = deps.createWebhookCaller(deps.createScopedCallerContext(ctx));
try {
await caller.delete({ id: params.id });
} catch (error) {
const mapped = deps.toAssistantWebhookNotFoundError(error);
if (mapped) {
return mapped;
}
throw error;
}
return { ok: true, id: params.id };
},
async test_webhook(params: {
id: string;
}, ctx: ToolContext) {
const caller = deps.createWebhookCaller(deps.createScopedCallerContext(ctx));
try {
return await caller.test({ id: params.id });
} catch (error) {
const mapped = deps.toAssistantWebhookNotFoundError(error);
if (mapped) {
return mapped;
}
throw error;
}
},
async list_audit_log_entries(params: {
entityType?: string;
entityId?: string;
userId?: string;
action?: string;
source?: string;
startDate?: string;
endDate?: string;
search?: string;
limit?: number;
cursor?: string;
}, ctx: ToolContext) {
const caller = deps.createAuditLogCaller(deps.createScopedCallerContext(ctx));
const result = await caller.listDetail({
...(params.entityType ? { entityType: params.entityType } : {}),
...(params.entityId ? { entityId: params.entityId } : {}),
...(params.userId ? { userId: params.userId } : {}),
...(params.action ? { action: params.action } : {}),
...(params.source ? { source: params.source } : {}),
...(params.startDate ? { startDate: deps.parseIsoDate(params.startDate, "startDate") } : {}),
...(params.endDate ? { endDate: deps.parseIsoDate(params.endDate, "endDate") } : {}),
...(params.search ? { search: params.search } : {}),
...(params.cursor ? { cursor: params.cursor } : {}),
...(params.limit !== undefined ? { limit: Math.min(Math.max(params.limit, 1), 100) } : {}),
});
return {
filters: {
entityType: params.entityType ?? null,
entityId: params.entityId ?? null,
userId: params.userId ?? null,
action: params.action ?? null,
source: params.source ?? null,
startDate: params.startDate ?? null,
endDate: params.endDate ?? null,
search: params.search ?? null,
},
itemCount: result.items.length,
nextCursor: result.nextCursor ?? null,
items: result.items,
};
},
async get_audit_log_entry(params: {
id: string;
}, ctx: ToolContext) {
const caller = deps.createAuditLogCaller(deps.createScopedCallerContext(ctx));
try {
return await caller.getByIdDetail({ id: params.id });
} catch (error) {
const mapped = deps.toAssistantAuditLogEntryNotFoundError(error);
if (mapped) {
return mapped;
}
throw error;
}
},
async get_audit_log_timeline(params: {
startDate?: string;
endDate?: string;
limit?: number;
}, ctx: ToolContext) {
const caller = deps.createAuditLogCaller(deps.createScopedCallerContext(ctx));
return caller.getTimelineDetail({
...(params.startDate ? { startDate: deps.parseIsoDate(params.startDate, "startDate") } : {}),
...(params.endDate ? { endDate: deps.parseIsoDate(params.endDate, "endDate") } : {}),
...(params.limit !== undefined ? { limit: Math.min(Math.max(params.limit, 1), 500) } : {}),
});
},
async get_audit_activity_summary(params: {
startDate?: string;
endDate?: string;
}, ctx: ToolContext) {
const caller = deps.createAuditLogCaller(deps.createScopedCallerContext(ctx));
return caller.getActivitySummary({
...(params.startDate ? { startDate: deps.parseIsoDate(params.startDate, "startDate") } : {}),
...(params.endDate ? { endDate: deps.parseIsoDate(params.endDate, "endDate") } : {}),
});
},
async get_shoring_ratio(params: { projectId: string }, ctx: ToolContext) {
const project = await deps.resolveProjectIdentifier(ctx, params.projectId);
if ("error" in project) {
return project;
}
const caller = deps.createProjectCaller(deps.createScopedCallerContext(ctx));
const result = await caller.getShoringRatio({ projectId: project.id });
if (result.totalHours <= 0) {
return `Project "${project.name}" (${project.shortCode}): No active assignments — shoring ratio not available.`;
}
const countryParts = Object.entries(result.byCountry)
.sort((a, b) => b[1].pct - a[1].pct)
.map(([code, info]) => `${code} ${info.pct}% (${info.resourceCount} people)`)
.join(", ");
const status = result.offshoreRatio >= result.threshold
? `Target met (>=${result.threshold}% offshore)`
: result.offshoreRatio >= result.threshold - 10
? `Close to target (${result.threshold}% offshore needed)`
: `Below target — only ${result.offshoreRatio}% offshore, need ${result.threshold}%`;
return `Project "${project.name}" (${project.shortCode}): ${result.onshoreRatio}% onshore (${result.onshoreCountryCode}), ${result.offshoreRatio}% offshore. ${status}. Breakdown: ${countryParts}.${result.unknownCount > 0 ? ` (${result.unknownCount} resource(s) without country)` : ""}`;
},
};
}
@@ -0,0 +1,47 @@
import type { prisma } from "@capakraken/db";
import type { PermissionKey, SystemRole } from "@capakraken/shared";
import type { TRPCContext } from "../../trpc.js";
export type ToolContext = {
db: typeof prisma;
userId: string;
userRole: string;
permissions: Set<PermissionKey>;
session?: TRPCContext["session"];
dbUser?: TRPCContext["dbUser"];
roleDefaults?: TRPCContext["roleDefaults"];
};
export interface ToolAccessRequirements {
requiredPermissions?: PermissionKey[];
allowedSystemRoles?: SystemRole[];
requiresPlanningRead?: boolean;
requiresCostView?: boolean;
requiresAdvancedAssistant?: boolean;
requiresResourceOverview?: boolean;
}
export interface ToolDef {
type: "function";
function: {
name: string;
description: string;
parameters: Record<string, unknown>;
};
access?: ToolAccessRequirements;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type ToolExecutor = (params: any, ctx: ToolContext) => Promise<unknown>;
export function withToolAccess(
tools: ToolDef[],
accessByName: Partial<Record<string, ToolAccessRequirements>>,
): ToolDef[] {
return tools.map((tool) => ({
...tool,
...(accessByName[tool.function.name]
? { access: accessByName[tool.function.name] }
: {}),
}));
}
@@ -1,6 +1,6 @@
import { SystemRole } from "@capakraken/shared";
import type { TRPCContext } from "../../trpc.js";
import type { ToolContext, ToolDef, ToolExecutor } from "./shared.js";
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
type AssistantToolErrorResult = { error: string };
@@ -48,7 +48,7 @@ type UserAdminDeps = {
) => AssistantToolErrorResult | null;
};
export const userAdminToolDefinitions: ToolDef[] = [
export const userAdminToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
@@ -212,7 +212,41 @@ export const userAdminToolDefinitions: ToolDef[] = [
},
},
},
];
], {
list_users: {
allowedSystemRoles: [SystemRole.ADMIN],
},
create_user: {
allowedSystemRoles: [SystemRole.ADMIN],
},
set_user_password: {
allowedSystemRoles: [SystemRole.ADMIN],
},
update_user_role: {
allowedSystemRoles: [SystemRole.ADMIN],
},
update_user_name: {
allowedSystemRoles: [SystemRole.ADMIN],
},
link_user_resource: {
allowedSystemRoles: [SystemRole.ADMIN],
},
auto_link_users_by_email: {
allowedSystemRoles: [SystemRole.ADMIN],
},
set_user_permissions: {
allowedSystemRoles: [SystemRole.ADMIN],
},
reset_user_permissions: {
allowedSystemRoles: [SystemRole.ADMIN],
},
get_effective_user_permissions: {
allowedSystemRoles: [SystemRole.ADMIN],
},
disable_user_totp: {
allowedSystemRoles: [SystemRole.ADMIN],
},
});
export function createUserAdminExecutors(
deps: UserAdminDeps,
@@ -1,5 +1,6 @@
import { SystemRole } from "@capakraken/shared";
import type { TRPCContext } from "../../trpc.js";
import type { ToolContext, ToolDef, ToolExecutor } from "./shared.js";
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
type AssistantToolErrorResult = { error: string };
@@ -35,12 +36,15 @@ type UserSelfServiceDeps = {
activeCount: () => Promise<unknown>;
};
createScopedCallerContext: (ctx: ToolContext) => TRPCContext;
toAssistantCurrentUserError: (
error: unknown,
) => AssistantToolErrorResult | null;
toAssistantTotpEnableError: (
error: unknown,
) => AssistantToolErrorResult | null;
};
export const userSelfServiceToolDefinitions: ToolDef[] = [
export const userSelfServiceToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
@@ -187,11 +191,30 @@ export const userSelfServiceToolDefinitions: ToolDef[] = [
parameters: { type: "object", properties: {} },
},
},
];
], {
list_assignable_users: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
},
get_active_user_count: {
allowedSystemRoles: [SystemRole.ADMIN],
},
});
export function createUserSelfServiceExecutors(
deps: UserSelfServiceDeps,
): Record<string, ToolExecutor> {
async function withCurrentUserErrorMapping<T>(run: () => Promise<T>) {
try {
return await run();
} catch (error) {
const mapped = deps.toAssistantCurrentUserError(error);
if (mapped) {
return mapped;
}
throw error;
}
}
return {
async list_assignable_users(_params: Record<string, never>, ctx: ToolContext) {
const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx));
@@ -200,17 +223,22 @@ export function createUserSelfServiceExecutors(
async get_current_user(_params: Record<string, never>, ctx: ToolContext) {
const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx));
return caller.me();
return withCurrentUserErrorMapping(() => caller.me());
},
async get_dashboard_layout(_params: Record<string, never>, ctx: ToolContext) {
const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx));
return caller.getDashboardLayout();
return withCurrentUserErrorMapping(() => caller.getDashboardLayout());
},
async save_dashboard_layout(params: { layout: unknown[] }, ctx: ToolContext) {
const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx));
const result = await caller.saveDashboardLayout({ layout: params.layout });
const result = await withCurrentUserErrorMapping(
() => caller.saveDashboardLayout({ layout: params.layout }),
);
if ("error" in result) {
return result;
}
return {
__action: "invalidate" as const,
scope: ["dashboard"],
@@ -222,12 +250,17 @@ export function createUserSelfServiceExecutors(
async get_favorite_project_ids(_params: Record<string, never>, ctx: ToolContext) {
const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx));
return caller.getFavoriteProjectIds();
return withCurrentUserErrorMapping(() => caller.getFavoriteProjectIds());
},
async toggle_favorite_project(params: { projectId: string }, ctx: ToolContext) {
const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx));
const result = await caller.toggleFavoriteProject({ projectId: params.projectId });
const result = await withCurrentUserErrorMapping(
() => caller.toggleFavoriteProject({ projectId: params.projectId }),
);
if ("error" in result) {
return result;
}
return {
__action: "invalidate" as const,
scope: ["project"],
@@ -239,7 +272,7 @@ export function createUserSelfServiceExecutors(
async get_column_preferences(_params: Record<string, never>, ctx: ToolContext) {
const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx));
return caller.getColumnPreferences();
return withCurrentUserErrorMapping(() => caller.getColumnPreferences());
},
async set_column_preferences(params: {
@@ -249,12 +282,15 @@ export function createUserSelfServiceExecutors(
rowOrder?: string[] | null;
}, ctx: ToolContext) {
const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx));
const result = await caller.setColumnPreferences({
const result = await withCurrentUserErrorMapping(() => caller.setColumnPreferences({
view: params.view,
...(params.visible !== undefined ? { visible: params.visible } : {}),
...(params.sort !== undefined ? { sort: params.sort } : {}),
...(params.rowOrder !== undefined ? { rowOrder: params.rowOrder } : {}),
});
}));
if ("error" in result) {
return result;
}
return {
__action: "invalidate" as const,
scope: ["user"],
@@ -266,7 +302,10 @@ export function createUserSelfServiceExecutors(
async generate_totp_secret(_params: Record<string, never>, ctx: ToolContext) {
const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx));
const result = await caller.generateTotpSecret();
const result = await withCurrentUserErrorMapping(() => caller.generateTotpSecret());
if ("error" in result) {
return result;
}
return {
__action: "invalidate" as const,
scope: ["user"],
@@ -299,7 +338,7 @@ export function createUserSelfServiceExecutors(
async get_mfa_status(_params: Record<string, never>, ctx: ToolContext) {
const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx));
return caller.getMfaStatus();
return withCurrentUserErrorMapping(() => caller.getMfaStatus());
},
async get_active_user_count(_params: Record<string, never>, ctx: ToolContext) {
@@ -47,7 +47,10 @@ function requireImmediateBroadcastTransaction(
function buildBroadcastCreateData(
senderId: string,
input: z.infer<typeof CreateBroadcastInputSchema>,
recipientCount?: number,
options: {
includeScheduledAt?: boolean;
recipientCount?: number;
} = {},
) {
return {
senderId,
@@ -59,8 +62,10 @@ function buildBroadcastCreateData(
channel: input.channel,
targetType: input.targetType,
...(input.targetValue !== undefined ? { targetValue: input.targetValue } : {}),
...(input.scheduledAt !== undefined ? { scheduledAt: input.scheduledAt } : {}),
...(recipientCount !== undefined ? { recipientCount } : {}),
...(options.includeScheduledAt && input.scheduledAt !== undefined
? { scheduledAt: input.scheduledAt }
: {}),
...(options.recipientCount !== undefined ? { recipientCount: options.recipientCount } : {}),
};
}
@@ -93,7 +98,10 @@ async function createScheduledBroadcastRecord(
recipientIds: string[],
) {
return db.notificationBroadcast.create({
data: buildBroadcastCreateData(senderId, input, recipientIds.length),
data: buildBroadcastCreateData(senderId, input, {
includeScheduledAt: true,
recipientCount: recipientIds.length,
}),
});
}
@@ -183,7 +191,7 @@ export async function createBroadcast(
try {
return await createScheduledBroadcastRecord(ctx.db, senderId, input, recipientIds);
} catch (error) {
rethrowNotificationReferenceError(error);
rethrowNotificationReferenceError(error, "broadcast");
}
}
@@ -198,7 +206,7 @@ export async function createBroadcast(
persistedBroadcast = transactionResult.broadcast;
notificationIds = transactionResult.notificationIds;
} catch (error) {
rethrowNotificationReferenceError(error);
rethrowNotificationReferenceError(error, "broadcast");
}
emitImmediateBroadcastSideEffects(ctx.db, input, notificationIds);
@@ -84,7 +84,10 @@ export function getNotificationErrorCandidates(error: unknown): Array<{
return candidates;
}
export function rethrowNotificationReferenceError(error: unknown): never {
export function rethrowNotificationReferenceError(
error: unknown,
recipientContext: "notification" | "task" | "broadcast" = "notification",
): never {
for (const candidate of getNotificationErrorCandidates(error)) {
const fieldName = typeof candidate.meta?.field_name === "string"
? candidate.meta.field_name.toLowerCase()
@@ -122,9 +125,14 @@ export function rethrowNotificationReferenceError(error: unknown): never {
&& (candidate.code === "P2003" || candidate.code === "P2025")
&& fieldName.includes("userid")
) {
const message = recipientContext === "broadcast"
? "Broadcast recipient user not found"
: recipientContext === "task"
? "Task recipient user not found"
: "Notification recipient user not found";
throw new TRPCError({
code: "NOT_FOUND",
message: "Broadcast recipient user not found",
message,
cause: error,
});
}
@@ -347,25 +355,32 @@ export async function createManagedNotification(
input: z.infer<typeof CreateManagedNotificationInputSchema>,
) {
const currentUserId = requireNotificationDbUser(ctx).id;
const isTaskLikeCategory = input.category === "TASK" || input.category === "APPROVAL";
const taskStatus = input.taskStatus ?? (isTaskLikeCategory ? "OPEN" : undefined);
const notificationId = await createNotification({
db: ctx.db,
userId: input.userId,
type: input.type,
title: input.title,
body: input.body,
entityId: input.entityId,
entityType: input.entityType,
category: input.category,
priority: input.priority,
link: input.link,
taskStatus: input.taskStatus,
taskAction: input.taskAction,
assigneeId: input.assigneeId,
dueDate: input.dueDate,
channel: input.channel,
senderId: input.senderId ?? currentUserId,
});
let notificationId: string;
try {
notificationId = await createNotification({
db: ctx.db,
userId: input.userId,
type: input.type,
title: input.title,
body: input.body,
entityId: input.entityId,
entityType: input.entityType,
category: input.category,
priority: input.priority,
link: input.link,
taskStatus,
taskAction: input.taskAction,
assigneeId: input.assigneeId,
dueDate: input.dueDate,
channel: input.channel,
senderId: input.senderId ?? currentUserId,
});
} catch (error) {
rethrowNotificationReferenceError(error, "notification");
}
if (input.category === "TASK" || input.category === "APPROVAL") {
emitTaskAssigned(input.userId, notificationId);
@@ -12,6 +12,7 @@ import {
import {
AssignTaskInputSchema,
CreateTaskInputSchema,
getNotificationErrorCandidates,
ListNotificationTasksInputSchema,
NotificationIdInputSchema,
type NotificationProcedureContext,
@@ -22,6 +23,19 @@ import {
UpdateNotificationTaskStatusInputSchema,
} from "./notification-procedure-base.js";
function requireTaskActionTransaction(
db: NotificationProcedureContext["db"],
): NonNullable<NotificationProcedureContext["db"]["$transaction"]> {
if (typeof db.$transaction !== "function") {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Task action execution requires transactional persistence support.",
});
}
return db.$transaction.bind(db);
}
export async function listNotificationTasks(
ctx: NotificationProcedureContext,
input: z.infer<typeof ListNotificationTasksInputSchema>,
@@ -207,6 +221,12 @@ export async function executeNotificationTaskAction(
message: "This task is already completed",
});
}
if (task.taskStatus === "DISMISSED") {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "This task has been dismissed",
});
}
const parsed = parseTaskAction(task.taskAction);
if (!parsed) {
@@ -237,21 +257,29 @@ export async function executeNotificationTaskAction(
});
}
const actionResult = await handler.execute(parsed.entityId, ctx.db, userId);
if (!actionResult.success) {
throw new TRPCError({
code: "BAD_REQUEST",
message: actionResult.message,
});
}
const transaction = requireTaskActionTransaction(ctx.db);
const { completedTask, actionResult } = await transaction(async (tx) => {
const actionResult = await handler.execute(parsed.entityId, tx as typeof ctx.db, userId);
if (!actionResult.success) {
throw new TRPCError({
code: "BAD_REQUEST",
message: actionResult.message,
});
}
const completedTask = await ctx.db.notification.update({
where: { id: input.id },
data: {
taskStatus: "DONE",
completedAt: new Date(),
completedBy: userId,
},
const completedTask = await tx.notification.update({
where: { id: input.id },
data: {
taskStatus: "DONE",
completedAt: new Date(),
completedBy: userId,
},
});
return {
completedTask,
actionResult,
};
});
emitTaskCompleted(task.userId, task.id);
@@ -270,23 +298,28 @@ export async function createTask(
input: z.infer<typeof CreateTaskInputSchema>,
) {
const senderId = requireNotificationDbUser(ctx).id;
const notificationId = await createNotification({
db: ctx.db,
userId: input.userId,
type: "TASK_CREATED",
category: "TASK",
taskStatus: "OPEN",
title: input.title,
priority: input.priority,
senderId,
channel: input.channel,
body: input.body,
dueDate: input.dueDate,
taskAction: input.taskAction,
entityId: input.entityId,
entityType: input.entityType,
link: input.link,
});
let notificationId: string;
try {
notificationId = await createNotification({
db: ctx.db,
userId: input.userId,
type: "TASK_CREATED",
category: "TASK",
taskStatus: "OPEN",
title: input.title,
priority: input.priority,
senderId,
channel: input.channel,
body: input.body,
dueDate: input.dueDate,
taskAction: input.taskAction,
entityId: input.entityId,
entityType: input.entityType,
link: input.link,
});
} catch (error) {
rethrowNotificationReferenceError(error, "task");
}
emitTaskAssigned(input.userId, notificationId);
@@ -320,7 +353,16 @@ export async function assignTask(
data: { assigneeId: input.assigneeId },
});
} catch (error) {
rethrowNotificationReferenceError(error);
for (const candidate of getNotificationErrorCandidates(error)) {
if (candidate.code === "P2025") {
throw new TRPCError({
code: "NOT_FOUND",
message: "Task not found",
cause: error,
});
}
}
rethrowNotificationReferenceError(error, "task");
}
emitTaskAssigned(input.assigneeId, updated.id);
@@ -0,0 +1,134 @@
import { z } from "zod";
import {
reportEntitySchema,
ReportTemplateConfigSchema,
type EntityKey,
} from "./report-query-config.js";
export const ReportBlueprintSummarySchema = z.object({
id: z.string().min(1),
label: z.string().min(1),
description: z.string().min(1),
entity: reportEntitySchema,
templateName: z.string().min(1),
config: ReportTemplateConfigSchema,
});
export const ReportBlueprintCatalogSchema = z.array(ReportBlueprintSummarySchema);
export type ReportBlueprintSummary = z.infer<typeof ReportBlueprintSummarySchema>;
const REPORT_BLUEPRINTS = ReportBlueprintCatalogSchema.parse([
{
id: "resource-month-sah-transparency",
label: "SAH transparency",
description: "Explains how monthly SAH is reduced by holidays and absences per person.",
entity: "resource_month",
templateName: "Monthly SAH transparency",
config: {
entity: "resource_month",
columns: [
"monthKey",
"displayName",
"eid",
"chapter",
"countryName",
"federalState",
"metroCityName",
"orgUnitName",
"managementLevelGroupName",
"monthlyBaseWorkingDays",
"monthlyEffectiveWorkingDays",
"monthlyBaseAvailableHours",
"monthlyPublicHolidayCount",
"monthlyPublicHolidayWorkdayCount",
"monthlyPublicHolidayHoursDeduction",
"monthlyAbsenceDayEquivalent",
"monthlyAbsenceHoursDeduction",
"monthlySahHours",
"monthlyChargeabilityTargetPct",
"monthlyTargetHours",
],
filters: [],
sortBy: "displayName",
sortDir: "asc",
},
},
{
id: "resource-month-chargeability-audit",
label: "Chargeability audit",
description: "Shows the full path from monthly SAH to booked, target and unassigned hours.",
entity: "resource_month",
templateName: "Monthly chargeability audit",
config: {
entity: "resource_month",
columns: [
"monthKey",
"displayName",
"eid",
"chapter",
"countryName",
"federalState",
"metroCityName",
"orgUnitName",
"managementLevelGroupName",
"monthlyBaseAvailableHours",
"monthlyPublicHolidayCount",
"monthlyPublicHolidayHoursDeduction",
"monthlyAbsenceDayEquivalent",
"monthlyAbsenceHoursDeduction",
"monthlySahHours",
"monthlyChargeabilityTargetPct",
"monthlyTargetHours",
"monthlyActualBookedHours",
"monthlyExpectedBookedHours",
"monthlyActualChargeabilityPct",
"monthlyExpectedChargeabilityPct",
"monthlyUnassignedHours",
"lcrCents",
"currency",
],
filters: [],
sortBy: "monthlyActualChargeabilityPct",
sortDir: "desc",
},
},
{
id: "resource-month-location-comparison",
label: "Location comparison",
description: "Compares holiday impact across country, state and city contexts for the same month.",
entity: "resource_month",
templateName: "Monthly holiday comparison by location",
config: {
entity: "resource_month",
columns: [
"monthKey",
"displayName",
"chapter",
"countryName",
"federalState",
"metroCityName",
"orgUnitName",
"monthlyBaseWorkingDays",
"monthlyBaseAvailableHours",
"monthlyPublicHolidayCount",
"monthlyPublicHolidayWorkdayCount",
"monthlyPublicHolidayHoursDeduction",
"monthlyAbsenceDayEquivalent",
"monthlyAbsenceHoursDeduction",
"monthlySahHours",
"monthlyActualChargeabilityPct",
],
filters: [],
groupBy: "federalState",
sortBy: "monthlyPublicHolidayHoursDeduction",
sortDir: "desc",
},
},
]);
export function listReportBlueprints(entity?: EntityKey): ReportBlueprintSummary[] {
return entity
? REPORT_BLUEPRINTS.filter((blueprint) => blueprint.entity === entity)
: REPORT_BLUEPRINTS;
}
@@ -0,0 +1,214 @@
import { z } from "zod";
import {
reportEntitySchema,
ReportTemplateConfigSchema,
type EntityKey,
} from "./report-query-config.js";
export const RESOURCE_MONTH_RECOMMENDED_COLUMNS = [
"monthKey",
"displayName",
"eid",
"chapter",
"countryCode",
"countryName",
"federalState",
"metroCityName",
"orgUnitName",
"managementLevelGroupName",
"monthlyBaseWorkingDays",
"monthlyEffectiveWorkingDays",
"monthlyBaseAvailableHours",
"monthlyPublicHolidayCount",
"monthlyPublicHolidayWorkdayCount",
"monthlyPublicHolidayHoursDeduction",
"monthlyAbsenceDayEquivalent",
"monthlyAbsenceHoursDeduction",
"monthlySahHours",
"monthlyChargeabilityTargetPct",
"monthlyTargetHours",
"monthlyActualBookedHours",
"monthlyExpectedBookedHours",
"monthlyActualChargeabilityPct",
"monthlyExpectedChargeabilityPct",
"monthlyUnassignedHours",
] as const;
export const RESOURCE_MONTH_MINIMUM_AUDIT_COLUMNS = [
"monthKey",
"displayName",
"countryName",
"federalState",
"metroCityName",
"monthlyPublicHolidayCount",
"monthlyPublicHolidayHoursDeduction",
"monthlyAbsenceDayEquivalent",
"monthlyAbsenceHoursDeduction",
"monthlySahHours",
"monthlyTargetHours",
"monthlyActualBookedHours",
"monthlyUnassignedHours",
] as const;
export const ReportBlueprintSummarySchema = z.object({
id: z.string().min(1),
label: z.string().min(1),
description: z.string().min(1),
entity: reportEntitySchema,
templateName: z.string().min(1),
config: ReportTemplateConfigSchema,
});
export const ReportBlueprintCatalogSchema = z.array(ReportBlueprintSummarySchema);
export type ReportBlueprintSummary = z.infer<typeof ReportBlueprintSummarySchema>;
export type ResourceMonthTemplateCompleteness = {
scope: "resource_month";
isAuditReady: boolean;
isRecommendedComplete: boolean;
recommendedColumnCount: number;
selectedRecommendedColumnCount: number;
minimumAuditColumnCount: number;
selectedMinimumAuditColumnCount: number;
missingRecommendedColumns: string[];
missingMinimumAuditColumns: string[];
};
const REPORT_BLUEPRINTS = ReportBlueprintCatalogSchema.parse([
{
id: "resource-month-sah-transparency",
label: "SAH transparency",
description: "Explains how monthly SAH is reduced by holidays and absences per person.",
entity: "resource_month",
templateName: "Monthly SAH transparency",
config: {
entity: "resource_month",
columns: [
"monthKey",
"displayName",
"eid",
"chapter",
"countryName",
"federalState",
"metroCityName",
"orgUnitName",
"managementLevelGroupName",
"monthlyBaseWorkingDays",
"monthlyEffectiveWorkingDays",
"monthlyBaseAvailableHours",
"monthlyPublicHolidayCount",
"monthlyPublicHolidayWorkdayCount",
"monthlyPublicHolidayHoursDeduction",
"monthlyAbsenceDayEquivalent",
"monthlyAbsenceHoursDeduction",
"monthlySahHours",
"monthlyChargeabilityTargetPct",
"monthlyTargetHours",
],
filters: [],
sortBy: "displayName",
sortDir: "asc",
},
},
{
id: "resource-month-chargeability-audit",
label: "Chargeability audit",
description: "Shows the full path from monthly SAH to booked, target and unassigned hours.",
entity: "resource_month",
templateName: "Monthly chargeability audit",
config: {
entity: "resource_month",
columns: [
"monthKey",
"displayName",
"eid",
"chapter",
"countryName",
"federalState",
"metroCityName",
"orgUnitName",
"managementLevelGroupName",
"monthlyBaseAvailableHours",
"monthlyPublicHolidayCount",
"monthlyPublicHolidayHoursDeduction",
"monthlyAbsenceDayEquivalent",
"monthlyAbsenceHoursDeduction",
"monthlySahHours",
"monthlyChargeabilityTargetPct",
"monthlyTargetHours",
"monthlyActualBookedHours",
"monthlyExpectedBookedHours",
"monthlyActualChargeabilityPct",
"monthlyExpectedChargeabilityPct",
"monthlyUnassignedHours",
"lcrCents",
"currency",
],
filters: [],
sortBy: "monthlyActualChargeabilityPct",
sortDir: "desc",
},
},
{
id: "resource-month-location-comparison",
label: "Location comparison",
description: "Compares holiday impact across country, state and city contexts for the same month.",
entity: "resource_month",
templateName: "Monthly holiday comparison by location",
config: {
entity: "resource_month",
columns: [
"monthKey",
"displayName",
"chapter",
"countryName",
"federalState",
"metroCityName",
"orgUnitName",
"monthlyBaseWorkingDays",
"monthlyBaseAvailableHours",
"monthlyPublicHolidayCount",
"monthlyPublicHolidayWorkdayCount",
"monthlyPublicHolidayHoursDeduction",
"monthlyAbsenceDayEquivalent",
"monthlyAbsenceHoursDeduction",
"monthlySahHours",
"monthlyActualChargeabilityPct",
],
filters: [],
groupBy: "federalState",
sortBy: "monthlyPublicHolidayHoursDeduction",
sortDir: "desc",
},
},
]);
export function listReportBlueprints(entity?: EntityKey): ReportBlueprintSummary[] {
return entity
? REPORT_BLUEPRINTS.filter((blueprint) => blueprint.entity === entity)
: REPORT_BLUEPRINTS;
}
export function buildResourceMonthTemplateCompleteness(
columns: Iterable<string>,
): ResourceMonthTemplateCompleteness {
const selectedColumns = new Set(columns);
const missingRecommendedColumns = RESOURCE_MONTH_RECOMMENDED_COLUMNS
.filter((column) => !selectedColumns.has(column));
const missingMinimumAuditColumns = RESOURCE_MONTH_MINIMUM_AUDIT_COLUMNS
.filter((column) => !selectedColumns.has(column));
return {
scope: "resource_month",
isAuditReady: missingMinimumAuditColumns.length === 0,
isRecommendedComplete: missingRecommendedColumns.length === 0,
recommendedColumnCount: RESOURCE_MONTH_RECOMMENDED_COLUMNS.length,
selectedRecommendedColumnCount: RESOURCE_MONTH_RECOMMENDED_COLUMNS.length - missingRecommendedColumns.length,
minimumAuditColumnCount: RESOURCE_MONTH_MINIMUM_AUDIT_COLUMNS.length,
selectedMinimumAuditColumnCount:
RESOURCE_MONTH_MINIMUM_AUDIT_COLUMNS.length - missingMinimumAuditColumns.length,
missingRecommendedColumns,
missingMinimumAuditColumns,
};
}
+18
View File
@@ -17,15 +17,21 @@ const RESOURCE_COLUMNS: ColumnDef[] = [
{ key: "currency", label: "Currency", dataType: "string" },
{ key: "chargeabilityTarget", label: "Chargeability Target (%)", dataType: "number" },
{ key: "fte", label: "FTE", dataType: "number" },
{ key: "enterpriseId", label: "Enterprise ID", dataType: "string" },
{ key: "portfolioUrl", label: "Portfolio URL", dataType: "string" },
{ key: "valueScore", label: "Value Score", dataType: "number" },
{ key: "valueScoreUpdatedAt", label: "Value Score Updated At", dataType: "date" },
{ key: "isActive", label: "Active", dataType: "boolean" },
{ key: "chgResponsibility", label: "Chg Responsibility", dataType: "boolean" },
{ key: "rolledOff", label: "Rolled Off", dataType: "boolean" },
{ key: "departed", label: "Departed", dataType: "boolean" },
{ key: "postalCode", label: "Postal Code", dataType: "string" },
{ key: "federalState", label: "Federal State", dataType: "string" },
{ key: "blueprint.name", label: "Blueprint", dataType: "string", prismaPath: "blueprint" },
{ key: "country.code", label: "Country Code", dataType: "string", prismaPath: "country" },
{ key: "country.name", label: "Country", dataType: "string", prismaPath: "country" },
{ key: "metroCity.name", label: "Metro City", dataType: "string", prismaPath: "metroCity" },
{ key: "clientUnit.name", label: "Client Unit", dataType: "string", prismaPath: "clientUnit" },
{ key: "orgUnit.name", label: "Org Unit", dataType: "string", prismaPath: "orgUnit" },
{ key: "managementLevelGroup.name", label: "Mgmt Level Group", dataType: "string", prismaPath: "managementLevelGroup" },
{ key: "managementLevel.name", label: "Mgmt Level", dataType: "string", prismaPath: "managementLevel" },
@@ -43,6 +49,9 @@ const PROJECT_COLUMNS: ColumnDef[] = [
{ key: "status", label: "Status", dataType: "string" },
{ key: "winProbability", label: "Win Probability (%)", dataType: "number" },
{ key: "budgetCents", label: "Budget (cents)", dataType: "number" },
{ key: "shoringThreshold", label: "Shoring Threshold (%)", dataType: "number" },
{ key: "onshoreCountryCode", label: "Onshore Country Code", dataType: "string" },
{ key: "color", label: "Color", dataType: "string" },
{ key: "clientId", label: "Client ID", dataType: "string" },
{ key: "startDate", label: "Start Date", dataType: "date" },
{ key: "endDate", label: "End Date", dataType: "date" },
@@ -61,14 +70,23 @@ const ASSIGNMENT_COLUMNS: ColumnDef[] = [
{ key: "resource.displayName", label: "Resource", dataType: "string", prismaPath: "resource" },
{ key: "resource.eid", label: "Resource EID", dataType: "string", prismaPath: "resource" },
{ key: "resource.chapter", label: "Resource Chapter", dataType: "string", prismaPath: "resource" },
{ key: "resource.resourceType", label: "Resource Type", dataType: "string", prismaPath: "resource" },
{ key: "resource.chargeabilityTarget", label: "Resource Chargeability Target (%)", dataType: "number", prismaPath: "resource" },
{ key: "resource.country.code", label: "Resource Country Code", dataType: "string", prismaPath: "resource" },
{ key: "resource.federalState", label: "Resource State", dataType: "string", prismaPath: "resource" },
{ key: "resource.country.name", label: "Resource Country", dataType: "string", prismaPath: "resource" },
{ key: "resource.metroCity.name", label: "Resource City", dataType: "string", prismaPath: "resource" },
{ key: "resource.orgUnit.name", label: "Resource Org Unit", dataType: "string", prismaPath: "resource" },
{ key: "resource.managementLevelGroup.name", label: "Resource Mgmt Level Group", dataType: "string", prismaPath: "resource" },
{ key: "resource.managementLevel.name", label: "Resource Mgmt Level", dataType: "string", prismaPath: "resource" },
{ key: "project.name", label: "Project", dataType: "string", prismaPath: "project" },
{ key: "project.shortCode", label: "Project Code", dataType: "string", prismaPath: "project" },
{ key: "project.status", label: "Project Status", dataType: "string", prismaPath: "project" },
{ key: "project.orderType", label: "Project Order Type", dataType: "string", prismaPath: "project" },
{ key: "project.allocationType", label: "Project Allocation Type", dataType: "string", prismaPath: "project" },
{ key: "project.blueprint.name", label: "Project Blueprint", dataType: "string", prismaPath: "project" },
{ key: "project.client.name", label: "Project Client", dataType: "string", prismaPath: "project" },
{ key: "project.utilizationCategory.name", label: "Project Util. Category", dataType: "string", prismaPath: "project" },
{ key: "startDate", label: "Start Date", dataType: "date" },
{ key: "endDate", label: "End Date", dataType: "date" },
{ key: "hoursPerDay", label: "Hours/Day", dataType: "number" },
@@ -0,0 +1,87 @@
export type ResourceMonthReportExplainability = {
entity: "resource_month";
periodMonth: string | null;
locationContextColumns: string[];
holidayMetricColumns: string[];
absenceMetricColumns: string[];
capacityMetricColumns: string[];
chargeabilityMetricColumns: string[];
missingRecommendedColumns: string[];
notes: string[];
};
export type ReportExplainability = ResourceMonthReportExplainability;
const RESOURCE_MONTH_LOCATION_COLUMNS = [
"countryCode",
"countryName",
"federalState",
"metroCityName",
] as const;
const RESOURCE_MONTH_HOLIDAY_COLUMNS = [
"monthlyPublicHolidayCount",
"monthlyPublicHolidayWorkdayCount",
"monthlyPublicHolidayHoursDeduction",
] as const;
const RESOURCE_MONTH_ABSENCE_COLUMNS = [
"monthlyAbsenceDayEquivalent",
"monthlyAbsenceHoursDeduction",
] as const;
const RESOURCE_MONTH_CAPACITY_COLUMNS = [
"monthlyBaseWorkingDays",
"monthlyEffectiveWorkingDays",
"monthlyBaseAvailableHours",
"monthlySahHours",
"monthlyChargeabilityTargetPct",
"monthlyTargetHours",
] as const;
const RESOURCE_MONTH_CHARGEABILITY_COLUMNS = [
"monthlyActualBookedHours",
"monthlyExpectedBookedHours",
"monthlyActualChargeabilityPct",
"monthlyExpectedChargeabilityPct",
"monthlyUnassignedHours",
] as const;
const RESOURCE_MONTH_RECOMMENDED_COLUMNS = [
...RESOURCE_MONTH_LOCATION_COLUMNS,
...RESOURCE_MONTH_HOLIDAY_COLUMNS,
...RESOURCE_MONTH_ABSENCE_COLUMNS,
...RESOURCE_MONTH_CAPACITY_COLUMNS,
] as const;
function pickIncludedColumns(
requestedColumns: string[],
columnGroup: readonly string[],
): string[] {
return columnGroup.filter((column) => requestedColumns.includes(column));
}
export function buildResourceMonthReportExplainability(
requestedColumns: string[],
periodMonth?: string,
): ResourceMonthReportExplainability {
const missingRecommendedColumns = RESOURCE_MONTH_RECOMMENDED_COLUMNS.filter(
(column) => !requestedColumns.includes(column),
);
return {
entity: "resource_month",
periodMonth: periodMonth ?? null,
locationContextColumns: pickIncludedColumns(requestedColumns, RESOURCE_MONTH_LOCATION_COLUMNS),
holidayMetricColumns: pickIncludedColumns(requestedColumns, RESOURCE_MONTH_HOLIDAY_COLUMNS),
absenceMetricColumns: pickIncludedColumns(requestedColumns, RESOURCE_MONTH_ABSENCE_COLUMNS),
capacityMetricColumns: pickIncludedColumns(requestedColumns, RESOURCE_MONTH_CAPACITY_COLUMNS),
chargeabilityMetricColumns: pickIncludedColumns(requestedColumns, RESOURCE_MONTH_CHARGEABILITY_COLUMNS),
missingRecommendedColumns,
notes: [
"monthlySahHours already reflects region-specific public holidays from country, federal state, and city context when those attributes exist on the resource.",
"monthlyAbsence* metrics only deduct workdays that are not already counted as public holidays.",
"monthlyBaseAvailableHours shows pre-deduction capacity; compare it with holiday, absence, and SAH columns to explain the final monthly availability.",
],
};
}
@@ -1,6 +1,7 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { COLUMN_MAP, type ColumnDef, RESOURCE_MONTH_COLUMNS } from "./report-columns.js";
import type { ReportExplainability } from "./report-explainability.js";
export const ENTITY_MAP = {
resource: "resource",
@@ -17,12 +18,14 @@ const ALLOWED_SCALAR_FIELDS: Record<EntityKey, Set<string>> = {
resource: new Set([
"id", "eid", "displayName", "email", "chapter", "resourceType",
"lcrCents", "ucrCents", "currency", "chargeabilityTarget", "fte",
"enterpriseId", "portfolioUrl", "valueScore", "valueScoreUpdatedAt",
"isActive", "chgResponsibility", "rolledOff", "departed",
"postalCode", "federalState", "createdAt", "updatedAt",
]),
project: new Set([
"id", "shortCode", "name", "orderType", "allocationType", "status",
"winProbability", "budgetCents", "startDate", "endDate",
"winProbability", "budgetCents", "shoringThreshold", "onshoreCountryCode", "color",
"startDate", "endDate",
"responsiblePerson", "createdAt", "updatedAt",
]),
assignment: new Set([
@@ -179,6 +182,7 @@ export interface ReportQueryResult {
columns: string[];
totalCount: number;
groups: ReportGroupSummary[];
explainability?: ReportExplainability;
}
export function validateReportInput(input: ReportInput | z.infer<typeof ReportTemplateConfigSchema>): void {
+14 -2
View File
@@ -15,6 +15,7 @@ import {
validateReportInput,
} from "./report-query-config.js";
import { COLUMN_MAP } from "./report-columns.js";
import { buildResourceMonthReportExplainability } from "./report-explainability.js";
import { buildReportGroups, pickColumns, sortInMemoryRows } from "./report-query-utils.js";
import { executeResourceMonthReport } from "./report-resource-month-query.js";
@@ -65,7 +66,14 @@ export const reportQueryProcedures = {
csvLines.push(outputColumns.map((column) => csvEscape(row[column])).join(","));
});
return { csv: csvLines.join("\n"), rowCount: result.rows.length };
return {
csv: csvLines.join("\n"),
rowCount: result.rows.length,
rows: result.rows,
columns: result.columns,
groups: result.groups,
...(result.explainability ? { explainability: result.explainability } : {}),
};
}),
};
@@ -76,7 +84,11 @@ async function executeReportQuery(
validateReportInput(input);
if (input.entity === "resource_month") {
return executeResourceMonthReport(db, input);
const result = await executeResourceMonthReport(db, input);
return {
...result,
explainability: buildResourceMonthReportExplainability(input.columns, input.periodMonth),
};
}
const { entity, columns, filters, sortBy, sortDir, limit, offset } = input;
@@ -2,6 +2,10 @@ import { Prisma } from "@capakraken/db";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import type { TRPCContext } from "../trpc.js";
import {
buildResourceMonthTemplateCompleteness,
type ResourceMonthTemplateCompleteness,
} from "./report-blueprints-support.js";
import {
type EntityKey,
ReportTemplateConfigSchema,
@@ -28,63 +32,6 @@ type ReportTemplateRecord = {
updatedAt: Date;
};
const RESOURCE_MONTH_RECOMMENDED_COLUMNS = [
"monthKey",
"displayName",
"eid",
"chapter",
"countryCode",
"countryName",
"federalState",
"metroCityName",
"orgUnitName",
"managementLevelGroupName",
"monthlyBaseWorkingDays",
"monthlyEffectiveWorkingDays",
"monthlyBaseAvailableHours",
"monthlyPublicHolidayCount",
"monthlyPublicHolidayWorkdayCount",
"monthlyPublicHolidayHoursDeduction",
"monthlyAbsenceDayEquivalent",
"monthlyAbsenceHoursDeduction",
"monthlySahHours",
"monthlyChargeabilityTargetPct",
"monthlyTargetHours",
"monthlyActualBookedHours",
"monthlyExpectedBookedHours",
"monthlyActualChargeabilityPct",
"monthlyExpectedChargeabilityPct",
"monthlyUnassignedHours",
] as const;
const RESOURCE_MONTH_MINIMUM_AUDIT_COLUMNS = [
"monthKey",
"displayName",
"countryName",
"federalState",
"metroCityName",
"monthlyPublicHolidayCount",
"monthlyPublicHolidayHoursDeduction",
"monthlyAbsenceDayEquivalent",
"monthlyAbsenceHoursDeduction",
"monthlySahHours",
"monthlyTargetHours",
"monthlyActualBookedHours",
"monthlyUnassignedHours",
] as const;
type ResourceMonthTemplateCompleteness = {
scope: "resource_month";
isAuditReady: boolean;
isRecommendedComplete: boolean;
recommendedColumnCount: number;
selectedRecommendedColumnCount: number;
minimumAuditColumnCount: number;
selectedMinimumAuditColumnCount: number;
missingRecommendedColumns: string[];
missingMinimumAuditColumns: string[];
};
type ReportTemplateContext = Pick<TRPCContext, "db" | "dbUser">;
export const SaveReportTemplateInputSchema = z.object({
@@ -303,23 +250,7 @@ function getTemplateCompleteness(
return null;
}
const selectedColumns = new Set(config.columns);
const missingRecommendedColumns = RESOURCE_MONTH_RECOMMENDED_COLUMNS
.filter((column) => !selectedColumns.has(column));
const missingMinimumAuditColumns = RESOURCE_MONTH_MINIMUM_AUDIT_COLUMNS
.filter((column) => !selectedColumns.has(column));
return {
scope: "resource_month",
isAuditReady: missingMinimumAuditColumns.length === 0,
isRecommendedComplete: missingRecommendedColumns.length === 0,
recommendedColumnCount: RESOURCE_MONTH_RECOMMENDED_COLUMNS.length,
selectedRecommendedColumnCount: RESOURCE_MONTH_RECOMMENDED_COLUMNS.length - missingRecommendedColumns.length,
minimumAuditColumnCount: RESOURCE_MONTH_MINIMUM_AUDIT_COLUMNS.length,
selectedMinimumAuditColumnCount: RESOURCE_MONTH_MINIMUM_AUDIT_COLUMNS.length - missingMinimumAuditColumns.length,
missingRecommendedColumns,
missingMinimumAuditColumns,
};
return buildResourceMonthTemplateCompleteness(config.columns);
}
function toTemplateEntity(entity: EntityKey): ReportTemplateEntity {
+8
View File
@@ -1,5 +1,8 @@
import { z } from "zod";
import { controllerProcedure, createTRPCRouter } from "../trpc.js";
import { listReportBlueprints, ReportBlueprintCatalogSchema } from "./report-blueprints-support.js";
import { reportQueryProcedures } from "./report-query-engine.js";
import { reportEntitySchema } from "./report-query-config.js";
import {
DeleteReportTemplateInputSchema,
deleteReportTemplate,
@@ -11,6 +14,11 @@ import {
export const reportRouter = createTRPCRouter({
...reportQueryProcedures,
listBlueprints: controllerProcedure
.input(z.object({ entity: reportEntitySchema.optional() }).default({}))
.output(ReportBlueprintCatalogSchema)
.query(({ input }) => listReportBlueprints(input.entity)),
listTemplates: controllerProcedure.query(({ ctx }) => listReportTemplates(ctx)),
saveTemplate: controllerProcedure
@@ -77,6 +77,16 @@ export interface TimelineAllocationCarveResult {
resourceId: string | null;
}
export interface TimelineAllocationExtractResult {
action: "unchanged" | "extracted";
allocationGroupId: string;
extractedAllocationId: string;
updatedAllocationIds: string[];
createdAllocationIds: string[];
projectId: string;
resourceId: string | null;
}
export async function carveTimelineAllocationRange(input: {
db: PrismaClient;
allocationId: string;
@@ -183,3 +193,119 @@ export async function carveTimelineAllocationRange(input: {
};
});
}
export async function extractTimelineAllocationFragment(input: {
db: PrismaClient;
allocationId: string;
startDate: Date;
endDate: Date;
}): Promise<TimelineAllocationExtractResult> {
const resolved = await loadAllocationEntry(input.db, input.allocationId);
if (resolved.kind !== "assignment") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Only staffed assignments can currently be extracted into fragments.",
});
}
const extractStart = toUtcCalendarDate(input.startDate);
const extractEnd = toUtcCalendarDate(input.endDate);
if (extractEnd < extractStart) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Extract end date must be on or after the extract start date.",
});
}
const assignment = resolved.assignment;
const assignmentStart = toUtcCalendarDate(assignment.startDate);
const assignmentEnd = toUtcCalendarDate(assignment.endDate);
if (extractStart < assignmentStart || extractEnd > assignmentEnd) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "The requested extract range must be fully inside the existing allocation.",
});
}
const { groupId, metadata } = readFragmentMetadata(assignment.metadata, assignment.id);
const hasLeftFragment = extractStart > assignmentStart;
const hasRightFragment = extractEnd < assignmentEnd;
if (!hasLeftFragment && !hasRightFragment) {
return {
action: "unchanged",
allocationGroupId: groupId,
extractedAllocationId: assignment.id,
updatedAllocationIds: [],
createdAllocationIds: [],
projectId: assignment.projectId,
resourceId: assignment.resourceId,
};
}
return input.db.$transaction(async (tx) => {
const createdAllocationIds: string[] = [];
const updated = await updateAssignment(
tx as unknown as Parameters<typeof updateAssignment>[0],
assignment.id,
{
startDate: extractStart,
endDate: extractEnd,
metadata,
},
);
if (hasLeftFragment) {
const createdLeft = await createAssignment(
tx as unknown as Parameters<typeof createAssignment>[0],
{
demandRequirementId: assignment.demandRequirementId ?? undefined,
resourceId: assignment.resourceId,
projectId: assignment.projectId,
startDate: assignmentStart,
endDate: addDays(extractStart, -1),
hoursPerDay: assignment.hoursPerDay,
percentage: assignment.percentage,
role: assignment.role ?? undefined,
roleId: assignment.roleId ?? undefined,
dailyCostCents: assignment.dailyCostCents,
status: toSharedAllocationStatus(assignment.status),
metadata,
},
);
createdAllocationIds.push(createdLeft.id);
}
if (hasRightFragment) {
const createdRight = await createAssignment(
tx as unknown as Parameters<typeof createAssignment>[0],
{
demandRequirementId: assignment.demandRequirementId ?? undefined,
resourceId: assignment.resourceId,
projectId: assignment.projectId,
startDate: addDays(extractEnd, 1),
endDate: assignmentEnd,
hoursPerDay: assignment.hoursPerDay,
percentage: assignment.percentage,
role: assignment.role ?? undefined,
roleId: assignment.roleId ?? undefined,
dailyCostCents: assignment.dailyCostCents,
status: toSharedAllocationStatus(assignment.status),
metadata,
},
);
createdAllocationIds.push(createdRight.id);
}
return {
action: "extracted" as const,
allocationGroupId: groupId,
extractedAllocationId: updated.id,
updatedAllocationIds: [updated.id],
createdAllocationIds,
projectId: assignment.projectId,
resourceId: assignment.resourceId,
};
});
}
@@ -36,3 +36,15 @@ export const timelineBatchShiftAllocationsInputSchema = z.object({
daysDelta: z.number().int().min(-3650).max(3650),
mode: z.enum(["move", "resize-start", "resize-end"]).default("move"),
});
export const timelineCarveAllocationRangeInputSchema = z.object({
allocationId: z.string(),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
});
export const timelineExtractAllocationFragmentInputSchema = z.object({
allocationId: z.string(),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
});
@@ -3,14 +3,18 @@ import { PermissionKey } from "@capakraken/shared";
import { managerProcedure, requirePermission } from "../trpc.js";
import {
UpdateAllocationHoursSchema,
timelineCarveAllocationRangeInputSchema,
timelineBatchQuickAssignInputSchema,
timelineBatchShiftAllocationsInputSchema,
timelineExtractAllocationFragmentInputSchema,
timelineQuickAssignInputSchema,
} from "./timeline-allocation-mutation-schema-support.js";
import {
applyTimelineAllocationBatchShiftMutation,
carveTimelineAllocationRangeMutation,
createTimelineBatchQuickAssignMutation,
createTimelineQuickAssignMutation,
extractTimelineAllocationFragmentMutation,
updateTimelineAllocationInlineMutation,
} from "./timeline-allocation-router-support.js";
@@ -50,4 +54,28 @@ export const timelineAllocationMutationProcedures = {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
return applyTimelineAllocationBatchShiftMutation(ctx.db as PrismaClient, input);
}),
carveAllocationRange: managerProcedure
.input(timelineCarveAllocationRangeInputSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
return carveTimelineAllocationRangeMutation({
db: ctx.db as PrismaClient,
allocationId: input.allocationId,
startDate: input.startDate,
endDate: input.endDate,
});
}),
extractAllocationFragment: managerProcedure
.input(timelineExtractAllocationFragmentInputSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
return extractTimelineAllocationFragmentMutation({
db: ctx.db as PrismaClient,
allocationId: input.allocationId,
startDate: input.startDate,
endDate: input.endDate,
});
}),
};
@@ -1,9 +1,17 @@
import type { PrismaClient } from "@capakraken/db";
import { emitAllocationUpdated } from "../sse/event-bus.js";
import {
emitAllocationCreated,
emitAllocationDeleted,
emitAllocationUpdated,
} from "../sse/event-bus.js";
import {
createTimelineBatchQuickAssignments,
createTimelineQuickAssignment,
} from "./timeline-allocation-assignment-procedure-support.js";
import {
carveTimelineAllocationRange,
extractTimelineAllocationFragment,
} from "./timeline-allocation-fragment-support.js";
import { applyTimelineInlineAllocationUpdate } from "./timeline-allocation-inline-support.js";
import { shiftTimelineAllocations } from "./timeline-allocation-procedure-support.js";
@@ -67,3 +75,68 @@ export async function applyTimelineAllocationBatchShiftMutation(
) {
return shiftTimelineAllocations(db, input);
}
export async function carveTimelineAllocationRangeMutation(input: {
db: PrismaClient;
allocationId: string;
startDate: Date;
endDate: Date;
}) {
const result = await carveTimelineAllocationRange({
db: input.db,
allocationId: input.allocationId,
startDate: input.startDate,
endDate: input.endDate,
});
for (const allocationId of result.updatedAllocationIds) {
emitAllocationUpdated({
id: allocationId,
projectId: result.projectId,
resourceId: result.resourceId,
});
}
for (const allocationId of result.createdAllocationIds) {
emitAllocationCreated({
id: allocationId,
projectId: result.projectId,
resourceId: result.resourceId,
});
}
for (const allocationId of result.deletedAllocationIds) {
emitAllocationDeleted(allocationId, result.projectId, result.resourceId);
}
return result;
}
export async function extractTimelineAllocationFragmentMutation(input: {
db: PrismaClient;
allocationId: string;
startDate: Date;
endDate: Date;
}) {
const result = await extractTimelineAllocationFragment({
db: input.db,
allocationId: input.allocationId,
startDate: input.startDate,
endDate: input.endDate,
});
for (const allocationId of result.updatedAllocationIds) {
emitAllocationUpdated({
id: allocationId,
projectId: result.projectId,
resourceId: result.resourceId,
});
}
for (const allocationId of result.createdAllocationIds) {
emitAllocationCreated({
id: allocationId,
projectId: result.projectId,
resourceId: result.resourceId,
});
}
return result;
}