From 447d42acb84f1f4166732035b7c95e064a6ce6ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Mon, 30 Mar 2026 20:56:00 +0200 Subject: [PATCH] refactor(api): extract assistant tool admin slices --- docs/architecture-hardening-backlog.md | 9 +- packages/api/src/router/assistant-tools.ts | 2773 ++--------------- .../assistant-tools/clients-org-units.ts | 318 ++ .../router/assistant-tools/roles-analytics.ts | 306 ++ .../assistant-tools/vacation-holidays.ts | 717 +++++ 5 files changed, 1580 insertions(+), 2543 deletions(-) create mode 100644 packages/api/src/router/assistant-tools/clients-org-units.ts create mode 100644 packages/api/src/router/assistant-tools/roles-analytics.ts create mode 100644 packages/api/src/router/assistant-tools/vacation-holidays.ts diff --git a/docs/architecture-hardening-backlog.md b/docs/architecture-hardening-backlog.md index 059d5a8..53a141c 100644 --- a/docs/architecture-hardening-backlog.md +++ b/docs/architecture-hardening-backlog.md @@ -22,11 +22,18 @@ - comment mention autocomplete now uses a dedicated entity-scoped API route instead of inheriting the narrower `user.listAssignable` audience - runtime secret handling is now environment-first end to end: admin updates no longer persist new operational secrets, runtime status is surfaced explicitly, and legacy database secret copies can be cleared through a dedicated cleanup path - `apps/web` system settings UI is now decomposed into section components with shared secret/runtime helpers, bringing all files in that slice back under the file-size guardrail +- the first API-side `assistant-tools` extraction is in place: settings, system-role config, webhooks, audit log access, and shoring ratio now live in a dedicated domain module with shared assistant-tool types +- the advanced timeline assistant toolset now lives in its own domain module, keeping the high-risk read/mutation pairings out of the monolithic router without changing the assistant contract +- the adjacent allocation planning assistant helpers now live in their own domain module, covering allocation listing, budget status, and the core allocation create/cancel/status mutations without changing the assistant contract +- the neighboring vacation and holiday assistant helpers now live in their own domain module, covering vacation-balance reads, regional/resource holiday inspection, and holiday calendar admin mutations without changing the assistant contract +- the adjacent roles, skill-search, and lightweight analytics assistant helpers now live in their own domain module, covering role CRUD plus `search_by_skill`, `get_statistics`, and `get_chargeability` without changing the assistant contract +- the neighboring client and org-unit admin mutations now live in their own domain module, keeping more CRUD wiring out of the monolithic router without changing the assistant contract ## Next Up Pin the next structural cleanup on the API side: -split `packages/api/src/router/assistant-tools.ts` into domain-oriented tool modules without changing the public tool contract. +continue splitting `packages/api/src/router/assistant-tools.ts` into domain-oriented tool modules without changing the public tool contract. +The next clean slice should stay adjacent to the extracted domains and target one cohesive block such as country plus metro-city admin helpers, or the remaining chargeability/computation read-model helpers that are still embedded in the monolithic router. ## Remaining Major Themes diff --git a/packages/api/src/router/assistant-tools.ts b/packages/api/src/router/assistant-tools.ts index 163c0c5..4b5b44a 100644 --- a/packages/api/src/router/assistant-tools.ts +++ b/packages/api/src/router/assistant-tools.ts @@ -3,42 +3,31 @@ * Each tool has a JSON schema (for the AI) and an execute function (for the server). */ -import { prisma, Prisma, ImportBatchStatus, StagedRecordStatus, DispoStagedRecordType, VacationType } from "@capakraken/db"; +import { Prisma, ImportBatchStatus, StagedRecordStatus, DispoStagedRecordType, VacationType } from "@capakraken/db"; import { CreateAssignmentSchema, - CreateClientSchema, CreateCountrySchema, type CreateEstimateInput, - CreateHolidayCalendarEntrySchema, - CreateHolidayCalendarSchema, CreateProjectSchema, CreateResourceSchema, CreateMetroCitySchema, - CreateOrgUnitSchema, - CreateRoleSchema, AllocationStatus, EstimateExportFormat, EstimateStatus, + type CommentEntityType, + COMMENT_ENTITY_TYPE_VALUES, PermissionKey, - PreviewResolvedHolidaysSchema, SystemRole, - UpdateClientSchema, type UpdateEstimateDraftInput, UpdateCountrySchema, - UpdateHolidayCalendarEntrySchema, - UpdateHolidayCalendarSchema, - UpdateAssignmentSchema, UpdateMetroCitySchema, - UpdateOrgUnitSchema, UpdateProjectSchema, - UpdateRoleSchema, UpdateResourceSchema, } from "@capakraken/shared"; import type { WeekdayAvailability } from "@capakraken/shared"; import { TRPCError } from "@trpc/server"; import { ZodError } from "zod"; import { fmtEur } from "../lib/format-utils.js"; -import { isAiConfigured } from "../ai-client.js"; import { timelineRouter } from "./timeline.js"; import { logger } from "../lib/logger.js"; import { createCallerFactory, type TRPCContext } from "../trpc.js"; @@ -76,7 +65,32 @@ import { insightsRouter } from "./insights.js"; import { scenarioRouter } from "./scenario.js"; import { allocationRouter } from "./allocation.js"; import { staffingRouter } from "./staffing.js"; -import { resolveSystemSettingsRuntime } from "../lib/system-settings-runtime.js"; +import { advancedTimelineToolDefinitions, createAdvancedTimelineExecutors } from "./assistant-tools/advanced-timeline.js"; +import { + allocationPlanningMutationToolDefinitions, + allocationPlanningReadToolDefinitions, + createAllocationPlanningExecutors, +} from "./assistant-tools/allocation-planning.js"; +import { settingsAdminToolDefinitions, createSettingsAdminExecutors } from "./assistant-tools/settings-admin.js"; +import { + createVacationHolidayExecutors, + vacationHolidayMutationToolDefinitions, + vacationHolidayReadToolDefinitions, +} from "./assistant-tools/vacation-holidays.js"; +import { + createRolesAnalyticsExecutors, + rolesAnalyticsMutationToolDefinitions, + rolesAnalyticsReadToolDefinitions, +} from "./assistant-tools/roles-analytics.js"; +import { + clientMutationToolDefinitions, + createClientsOrgUnitsExecutors, + orgUnitMutationToolDefinitions, +} from "./assistant-tools/clients-org-units.js"; +import type { ToolContext, ToolDef, ToolExecutor } from "./assistant-tools/shared.js"; +import { getCommentToolEntityDescription, getCommentToolScopeSentence } from "../lib/comment-entity-registry.js"; + +export type { ToolContext } from "./assistant-tools/shared.js"; // ─── Mutation tool set for audit logging (EGAI 4.1.3.1 / IAAI 3.6.26) ────── @@ -144,30 +158,6 @@ export const ADVANCED_ASSISTANT_TOOLS = new Set([ "get_project_computation_graph", ]); -// ─── Types ────────────────────────────────────────────────────────────────── - -export type ToolContext = { - db: typeof prisma; - userId: string; - userRole: string; - permissions: Set; - session?: TRPCContext["session"]; - dbUser?: TRPCContext["dbUser"]; - roleDefaults?: TRPCContext["roleDefaults"]; -}; - -export interface ToolDef { - type: "function"; - function: { - name: string; - description: string; - parameters: Record; - }; -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type ToolExecutor = (params: any, ctx: ToolContext) => Promise; - const createChargeabilityReportCaller = createCallerFactory(chargeabilityReportRouter); const createComputationGraphCaller = createCallerFactory(computationGraphRouter); const createTimelineCaller = createCallerFactory(timelineRouter); @@ -1775,6 +1765,10 @@ function toAssistantNotificationCreationError( return { error: "Broadcast not found with the given criteria." }; } + if (context === "broadcast" && prismaError.code === "P2025") { + return { error: "Broadcast not found with the given criteria." }; + } + if (context === "task") { return { error: "Task recipient user not found with the given criteria." }; } @@ -1970,513 +1964,13 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ }, required: ["identifier"], }, - }, }, - { - 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"], - }, - }, - }, - { - 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"], - }, - }, - }, - { - type: "function", - function: { - name: "get_vacation_balance", - description: "Get the holiday-aware vacation balance for a resource via the real entitlement workflow. Authenticated users can read their own balance; manager/admin/controller can read broader balances.", - parameters: { - type: "object", - properties: { - resourceId: { type: "string", description: "Resource ID, EID, or display name" }, - year: { type: "integer", description: "Year. Default: current year" }, - }, - required: ["resourceId"], - }, - }, - }, - { - type: "function", - function: { - name: "list_vacations_upcoming", - description: "List upcoming vacations across all resources, or for a specific resource/team. Shows who is off when.", - parameters: { - type: "object", - properties: { - resourceName: { type: "string", description: "Filter by resource name (partial match)" }, - chapter: { type: "string", description: "Filter by chapter/team" }, - daysAhead: { type: "integer", description: "How many days ahead to look. Default: 30" }, - limit: { type: "integer", description: "Max results. Default: 30" }, - }, - }, - }, - }, - { - type: "function", - function: { - name: "list_holidays_by_region", - description: "List resolved public holidays for a country, federal state, and optionally a city in a given year or date range. Use this to compare regions such as Bayern vs Hamburg.", - parameters: { - type: "object", - properties: { - countryCode: { type: "string", description: "Country code such as DE, ES, US, IN." }, - federalState: { type: "string", description: "Federal state / region code, e.g. BY, HH, NRW." }, - metroCity: { type: "string", description: "Optional city name for local city-specific holidays, e.g. Augsburg." }, - year: { type: "integer", description: "Full year, e.g. 2026. Default: current year." }, - periodStart: { type: "string", description: "Optional start date in YYYY-MM-DD. Requires periodEnd." }, - periodEnd: { type: "string", description: "Optional end date in YYYY-MM-DD. Requires periodStart." }, - }, - required: ["countryCode"], - }, - }, - }, - { - type: "function", - function: { - name: "get_resource_holidays", - description: "List resolved public holidays for a specific resource based on that person's country, federal state, and city context.", - parameters: { - type: "object", - properties: { - identifier: { type: "string", description: "Resource ID, EID, or display name." }, - year: { type: "integer", description: "Full year, e.g. 2026. Default: current year." }, - periodStart: { type: "string", description: "Optional start date in YYYY-MM-DD. Requires periodEnd." }, - periodEnd: { type: "string", description: "Optional end date in YYYY-MM-DD. Requires periodStart." }, - }, - required: ["identifier"], - }, - }, - }, - { - type: "function", - function: { - name: "list_holiday_calendars", - description: "List holiday calendars including scope, assignment, active flag, priority, and entry count. Useful to inspect the calendar-editor configuration context.", - parameters: { - type: "object", - properties: { - includeInactive: { type: "boolean", description: "Include inactive calendars. Default: false." }, - countryCode: { type: "string", description: "Optional country code filter such as DE or ES." }, - scopeType: { type: "string", description: "Optional scope filter: COUNTRY, STATE, CITY." }, - stateCode: { type: "string", description: "Optional state/region code filter such as BY or NRW." }, - metroCity: { type: "string", description: "Optional city-name filter." }, - }, - }, - }, - }, - { - type: "function", - function: { - name: "get_holiday_calendar", - description: "Get a single holiday calendar including all entries. Accepts either the calendar ID or its name.", - parameters: { - type: "object", - properties: { - identifier: { type: "string", description: "Holiday calendar ID or name." }, - }, - required: ["identifier"], - }, - }, - }, - { - type: "function", - function: { - name: "preview_resolved_holiday_calendar", - description: "Preview the resolved holiday result for a country/state/city scope and year, including which calendar each holiday comes from.", - parameters: { - type: "object", - properties: { - countryId: { type: "string", description: "Country ID." }, - stateCode: { type: "string", description: "Optional state/region code." }, - metroCityId: { type: "string", description: "Optional metro city ID for city-specific preview." }, - year: { type: "integer", description: "Full year, e.g. 2026." }, - }, - required: ["countryId", "year"], - }, - }, - }, - { - type: "function", - function: { - name: "create_holiday_calendar", - description: "Create a holiday calendar for a country, state, or city scope. Admin role required. Always confirm first.", - parameters: { - type: "object", - properties: { - name: { type: "string", description: "Calendar name." }, - scopeType: { type: "string", description: "COUNTRY, STATE, or CITY." }, - countryId: { type: "string", description: "Country ID." }, - stateCode: { type: "string", description: "Required for STATE calendars." }, - metroCityId: { type: "string", description: "Required for CITY calendars." }, - isActive: { type: "boolean", description: "Whether the calendar is active. Default: true." }, - priority: { type: "integer", description: "Priority used during calendar resolution. Default: 0." }, - }, - required: ["name", "scopeType", "countryId"], - }, - }, - }, - { - type: "function", - function: { - name: "update_holiday_calendar", - description: "Update an existing holiday calendar. Admin role required. Always confirm first.", - parameters: { - type: "object", - properties: { - id: { type: "string", description: "Holiday calendar ID." }, - data: { - type: "object", - properties: { - name: { type: "string" }, - stateCode: { type: "string" }, - metroCityId: { type: "string" }, - isActive: { type: "boolean" }, - priority: { type: "integer" }, - }, - }, - }, - required: ["id", "data"], - }, - }, - }, - { - type: "function", - function: { - name: "delete_holiday_calendar", - description: "Delete a holiday calendar and all of its entries. Admin role required. Always confirm first.", - parameters: { - type: "object", - properties: { - id: { type: "string", description: "Holiday calendar ID." }, - }, - required: ["id"], - }, - }, - }, - { - type: "function", - function: { - name: "create_holiday_calendar_entry", - description: "Create a holiday entry in an existing calendar. Admin role required. Always confirm first.", - parameters: { - type: "object", - properties: { - holidayCalendarId: { type: "string", description: "Holiday calendar ID." }, - date: { type: "string", description: "Date in YYYY-MM-DD format." }, - name: { type: "string", description: "Holiday name." }, - isRecurringAnnual: { type: "boolean", description: "Whether the holiday repeats every year." }, - source: { type: "string", description: "Optional source or legal basis." }, - }, - required: ["holidayCalendarId", "date", "name"], - }, - }, - }, - { - type: "function", - function: { - name: "update_holiday_calendar_entry", - description: "Update an existing holiday calendar entry. Admin role required. Always confirm first.", - parameters: { - type: "object", - properties: { - id: { type: "string", description: "Holiday calendar entry ID." }, - data: { - type: "object", - properties: { - date: { type: "string", description: "Date in YYYY-MM-DD format." }, - name: { type: "string" }, - isRecurringAnnual: { type: "boolean" }, - source: { type: "string" }, - }, - }, - }, - required: ["id", "data"], - }, - }, - }, - { - type: "function", - function: { - name: "delete_holiday_calendar_entry", - description: "Delete a holiday calendar entry. Admin role required. Always confirm first.", - parameters: { - type: "object", - properties: { - id: { type: "string", description: "Holiday calendar entry ID." }, - }, - required: ["id"], - }, - }, - }, - { - type: "function", - function: { - name: "list_roles", - description: "List all available roles with their colors.", - parameters: { type: "object", properties: {} }, - }, - }, - { - type: "function", - function: { - name: "search_by_skill", - description: "Find resources that have a specific skill. Controller/manager/admin access required.", - parameters: { - type: "object", - properties: { - skill: { type: "string", description: "Skill name to search for" }, - }, - required: ["skill"], - }, - }, - }, - { - type: "function", - function: { - name: "get_statistics", - description: "Get overview statistics: total resources, projects, active allocations, budget summary, projects by status, chapter breakdown.", - parameters: { type: "object", properties: {} }, - }, - }, - { - type: "function", - function: { - name: "get_chargeability", - description: "Get chargeability data for a resource in a given month: hours booked vs available, chargeability %, target comparison.", - parameters: { - type: "object", - properties: { - resourceId: { type: "string", description: "Resource ID, eid, or name" }, - month: { type: "string", description: "Month in YYYY-MM format, e.g. 2026-03. Default: current month" }, - }, - required: ["resourceId"], - }, - }, }, + ...advancedTimelineToolDefinitions, + ...allocationPlanningReadToolDefinitions, + ...vacationHolidayReadToolDefinitions, + ...vacationHolidayMutationToolDefinitions, + ...rolesAnalyticsReadToolDefinitions, { type: "function", function: { @@ -2576,6 +2070,48 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ }, // ── NAVIGATION TOOLS ── + { + type: "function", + function: { + name: "get_my_timeline_entries_view", + description: "Get the caller's own self-service timeline entries view for a date range using the real timeline self-service endpoint. Returns only data for the caller's linked resource.", + parameters: { + type: "object", + properties: { + startDate: { type: "string", description: "Start date in YYYY-MM-DD." }, + endDate: { type: "string", description: "End date in YYYY-MM-DD." }, + resourceIds: { type: "array", items: { type: "string" }, description: "Optional filters are accepted but will be scoped to the caller's own linked resource." }, + projectIds: { type: "array", items: { type: "string" }, description: "Optional project IDs to narrow the caller's own timeline view." }, + clientIds: { type: "array", items: { type: "string" }, description: "Optional client IDs to narrow the caller's own timeline view." }, + chapters: { type: "array", items: { type: "string" }, description: "Optional chapter filters. Self-service scoping still applies." }, + eids: { type: "array", items: { type: "string" }, description: "Optional employee IDs. Self-service scoping still applies." }, + countryCodes: { type: "array", items: { type: "string" }, description: "Optional country codes. Self-service scoping still applies." }, + }, + required: ["startDate", "endDate"], + }, + }, + }, + { + type: "function", + function: { + name: "get_my_timeline_holiday_overlays", + description: "Get the caller's own self-service holiday overlays for a date range using the real timeline self-service endpoint. Returns only holiday overlays for the caller's linked resource.", + parameters: { + type: "object", + properties: { + startDate: { type: "string", description: "Start date in YYYY-MM-DD." }, + endDate: { type: "string", description: "End date in YYYY-MM-DD." }, + resourceIds: { type: "array", items: { type: "string" }, description: "Optional filters are accepted but will be scoped to the caller's own linked resource." }, + projectIds: { type: "array", items: { type: "string" }, description: "Optional project IDs to narrow the caller's own holiday overlay view." }, + clientIds: { type: "array", items: { type: "string" }, description: "Optional client IDs to narrow the caller's own holiday overlay view." }, + chapters: { type: "array", items: { type: "string" }, description: "Optional chapter filters. Self-service scoping still applies." }, + eids: { type: "array", items: { type: "string" }, description: "Optional employee IDs. Self-service scoping still applies." }, + countryCodes: { type: "array", items: { type: "string" }, description: "Optional country codes. Self-service scoping still applies." }, + }, + required: ["startDate", "endDate"], + }, + }, + }, { type: "function", function: { @@ -2602,42 +2138,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ }, // ── WRITE TOOLS ── - { - 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" }, - }, - }, - }, - }, + ...allocationPlanningMutationToolDefinitions, { type: "function", function: { @@ -2657,24 +2158,6 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ }, }, }, - { - 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"], - }, - }, - }, { type: "function", function: { @@ -3307,108 +2790,10 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ }, // ── ROLES ── - { - type: "function", - function: { - name: "create_role", - description: "Create a new role. Requires manager or admin role plus manageRoles permission. Always confirm first.", - parameters: { - type: "object", - properties: { - name: { type: "string", description: "Role name" }, - description: { type: "string", description: "Optional role description" }, - color: { type: "string", description: "Hex color (e.g. #3b82f6). Default: #6b7280" }, - }, - required: ["name"], - }, - }, - }, - { - type: "function", - function: { - name: "update_role", - description: "Update a role. Requires manager or admin role plus manageRoles permission. Always confirm first.", - parameters: { - type: "object", - properties: { - id: { type: "string", description: "Role ID" }, - name: { type: "string", description: "New name" }, - description: { type: "string", description: "New description" }, - color: { type: "string", description: "New hex color" }, - isActive: { type: "boolean", description: "Set active state" }, - }, - required: ["id"], - }, - }, - }, - { - type: "function", - function: { - name: "delete_role", - description: "Delete a role. Requires manager or admin role plus manageRoles permission. Always confirm first.", - parameters: { - type: "object", - properties: { - id: { type: "string", description: "Role ID" }, - }, - required: ["id"], - }, - }, - }, + ...rolesAnalyticsMutationToolDefinitions, // ── CLIENTS ── - { - type: "function", - function: { - name: "create_client", - description: "Create a new client. Requires manager or admin role. Always confirm first.", - parameters: { - type: "object", - properties: { - name: { type: "string", description: "Client name" }, - code: { type: "string", description: "Client code" }, - parentId: { type: "string", description: "Optional parent client ID" }, - sortOrder: { type: "integer", description: "Sort order. Default: 0" }, - tags: { type: "array", items: { type: "string" }, description: "Optional client tags" }, - }, - required: ["name"], - }, - }, - }, - { - type: "function", - function: { - name: "update_client", - description: "Update a client. Requires manager or admin role. Always confirm first.", - parameters: { - type: "object", - properties: { - id: { type: "string", description: "Client ID" }, - name: { type: "string", description: "New name" }, - code: { type: "string", description: "New code" }, - sortOrder: { type: "integer", description: "New sort order" }, - isActive: { type: "boolean", description: "Set active state" }, - parentId: { type: "string", description: "Parent client ID; use null to clear" }, - tags: { type: "array", items: { type: "string" }, description: "Replacement client tags" }, - }, - required: ["id"], - }, - }, - }, - { - type: "function", - function: { - name: "delete_client", - description: "Delete a client. Requires admin role. Always confirm first.", - parameters: { - type: "object", - properties: { - id: { type: "string", description: "Client ID" }, - }, - required: ["id"], - }, - }, - }, + ...clientMutationToolDefinitions, // ── ADMIN / CONFIG READ TOOLS ── { @@ -3980,43 +3365,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ }, // ── ORG UNIT MANAGEMENT ── - { - type: "function", - function: { - name: "create_org_unit", - description: "Create a new organizational unit. Admin role required. Always confirm first.", - parameters: { - type: "object", - properties: { - name: { type: "string", description: "Org unit name" }, - shortName: { type: "string", description: "Short name/code" }, - level: { type: "integer", description: "Level (5, 6, or 7)" }, - parentId: { type: "string", description: "Parent org unit ID (optional)" }, - sortOrder: { type: "integer", description: "Sort order. Default: 0" }, - }, - required: ["name", "level"], - }, - }, - }, - { - type: "function", - function: { - name: "update_org_unit", - description: "Update an organizational unit. Admin role required. Always confirm first.", - parameters: { - type: "object", - properties: { - id: { type: "string", description: "Org unit ID" }, - name: { type: "string", description: "New name" }, - shortName: { type: "string", description: "New short name" }, - sortOrder: { type: "integer", description: "New sort order" }, - isActive: { type: "boolean", description: "Set active state" }, - parentId: { type: "string", description: "Parent org unit ID; use null to clear" }, - }, - required: ["id"], - }, - }, - }, + ...orgUnitMutationToolDefinitions, // ── COVER ART ── { @@ -4363,6 +3712,19 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ type: "string", description: "Required for resource_month reports. Format: YYYY-MM", }, + groupBy: { + type: "string", + description: "Optional scalar field used to group result rows into labeled sections.", + }, + sortBy: { + type: "string", + description: "Optional scalar field used to sort rows within the grouped result.", + }, + sortDir: { + type: "string", + enum: ["asc", "desc"], + description: "Sort direction for sortBy. Default: asc", + }, limit: { type: "integer", description: "Max results. Default: 50" }, }, required: ["entity", "columns"], @@ -4373,11 +3735,11 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ type: "function", function: { name: "list_comments", - description: "List comments (with replies) for a supported comment-enabled entity. Currently only estimate comments are enabled. Controller/manager/admin access required.", + description: `List comments (with replies) for a supported comment-enabled entity. ${getCommentToolScopeSentence()} Entity visibility is required.`, parameters: { type: "object", properties: { - entityType: { type: "string", enum: ["estimate"], description: "Supported entity type. Currently only 'estimate'." }, + entityType: { type: "string", enum: [...COMMENT_ENTITY_TYPE_VALUES], description: getCommentToolEntityDescription() }, entityId: { type: "string", description: "Entity ID" }, }, required: ["entityType", "entityId"], @@ -4452,11 +3814,11 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ type: "function", function: { name: "create_comment", - description: "Add a comment to a supported comment-enabled entity. Currently only estimate comments are enabled. Controller/manager/admin access required. Supports @mentions. Always confirm with the user first.", + description: `Add a comment to a supported comment-enabled entity. ${getCommentToolScopeSentence()} Entity visibility is required. Supports @mentions. Always confirm with the user first.`, parameters: { type: "object", properties: { - entityType: { type: "string", enum: ["estimate"], description: "Supported entity type. Currently only 'estimate'." }, + entityType: { type: "string", enum: [...COMMENT_ENTITY_TYPE_VALUES], description: getCommentToolEntityDescription() }, entityId: { type: "string", description: "Entity ID" }, body: { type: "string", description: "Comment body text. Use @[Name](userId) for mentions." }, }, @@ -4468,7 +3830,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ type: "function", function: { name: "resolve_comment", - description: "Mark a comment as resolved (or unresolve it) on a supported comment-enabled entity. Currently only estimate comments are enabled. Controller/manager/admin visibility is required, and only the comment author or an admin can change resolution.", + description: `Mark a comment as resolved (or unresolve it) on a supported comment-enabled entity. ${getCommentToolScopeSentence()} Entity visibility is required, and only the comment author or an admin can change resolution.`, parameters: { type: "object", properties: { @@ -4759,310 +4121,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ }, }, }, - { - 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"], - }, - }, - }, + ...settingsAdminToolDefinitions, ]; // ─── Helpers ──────────────────────────────────────────────────────────────── @@ -5138,824 +4197,63 @@ const executors = { return project; }, - 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) { - assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS); - assertPermission(ctx, PermissionKey.VIEW_COSTS); - - const project = await resolveProjectIdentifier(ctx, params.projectIdentifier); - if ("error" in project) { - return project; - } - - const caller = createStaffingCaller(createScopedCallerContext(ctx)); - return caller.getBestProjectResourceDetail({ - projectId: project.id, - ...(params.startDate ? { startDate: parseIsoDate(params.startDate, "startDate") } : {}), - ...(params.endDate ? { endDate: 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) { - assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS); - - const caller = createTimelineCaller(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) { - assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS); - - const caller = createTimelineCaller(createScopedCallerContext(ctx)); - return caller.getHolidayOverlayDetail({ ...params }); - }, - - async get_project_timeline_context(params: { - projectIdentifier: string; - startDate?: string; - endDate?: string; - durationDays?: number; - }, ctx: ToolContext) { - assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS); - - const project = await resolveProjectIdentifier(ctx, params.projectIdentifier); - if ("error" in project) { - return project; - } - - const caller = createTimelineCaller(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) { - assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS); - - const project = await resolveProjectIdentifier(ctx, params.projectIdentifier); - if ("error" in project) { - return project; - } - - const caller = createTimelineCaller(createScopedCallerContext(ctx)); - return caller.getShiftPreviewDetail({ - projectId: project.id, - newStartDate: parseIsoDate(params.newStartDate, "newStartDate"), - newEndDate: parseIsoDate(params.newEndDate, "newEndDate"), - }); - }, - - async update_timeline_allocation_inline(params: { - allocationId: string; - hoursPerDay?: number; - startDate?: string; - endDate?: string; - includeSaturday?: boolean; - role?: string; - }, ctx: ToolContext) { - assertPermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); - assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS); - - const caller = createTimelineCaller(createScopedCallerContext(ctx)); - let updated; - try { - updated = await caller.updateAllocationInline({ - allocationId: params.allocationId, - ...(params.hoursPerDay !== undefined ? { hoursPerDay: params.hoursPerDay } : {}), - ...(params.startDate ? { startDate: parseIsoDate(params.startDate, "startDate") } : {}), - ...(params.endDate ? { endDate: parseIsoDate(params.endDate, "endDate") } : {}), - ...(params.includeSaturday !== undefined ? { includeSaturday: params.includeSaturday } : {}), - ...(params.role !== undefined ? { role: params.role } : {}), - }); - } catch (error) { - const mapped = 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: fmtDate(updated.startDate), - endDate: 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) { - assertPermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); - assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS); - - const project = await resolveProjectIdentifier(ctx, params.projectIdentifier); - if ("error" in project) { - return project; - } - - const newStartDate = parseIsoDate(params.newStartDate, "newStartDate"); - const newEndDate = parseIsoDate(params.newEndDate, "newEndDate"); - const caller = createTimelineCaller(createScopedCallerContext(ctx)); - let result; - try { - result = await caller.applyShift({ - projectId: project.id, - newStartDate, - newEndDate, - }); - } catch (error) { - const mapped = 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 ${fmtDate(newStartDate)} - ${fmtDate(newEndDate)}.`, - project: { - id: result.project.id, - startDate: fmtDate(result.project.startDate), - endDate: 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) { - assertPermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); - assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS); - - const [resource, project] = await Promise.all([ - resolveResourceIdentifier(ctx, params.resourceIdentifier), - resolveProjectIdentifier(ctx, params.projectIdentifier), - ]); - if ("error" in resource) { - return resource; - } - if ("error" in project) { - return project; - } - - const caller = createTimelineCaller(createScopedCallerContext(ctx)); - let allocation; - try { - allocation = await caller.quickAssign({ - resourceId: resource.id, - projectId: project.id, - startDate: parseIsoDate(params.startDate, "startDate"), - endDate: 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 = 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: fmtDate(toDate(allocation.startDate)), - endDate: 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) { - assertPermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); - assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS); - - const resolvedAssignments = await Promise.all(params.assignments.map(async (assignment, index) => { - const [resource, project] = await Promise.all([ - resolveResourceIdentifier(ctx, assignment.resourceIdentifier), - resolveProjectIdentifier(ctx, assignment.projectIdentifier), - ]); - if ("error" in resource) { - return toAssistantIndexedFieldError(index, "resourceIdentifier", resource.error); - } - if ("error" in project) { - return toAssistantIndexedFieldError(index, "projectIdentifier", project.error); - } - return { - resourceId: resource.id, - projectId: project.id, - startDate: parseIsoDate(assignment.startDate, `assignments[${index}].startDate`), - endDate: 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( - (assignment): assignment is AssistantIndexedFieldErrorResult => isAssistantToolErrorResult(assignment), - ); - if (resolutionError) { - return resolutionError; - } - const validAssignments = resolvedAssignments.filter( - (assignment): assignment is BatchQuickAssignmentInput => !isAssistantToolErrorResult(assignment), - ); - - const caller = createTimelineCaller(createScopedCallerContext(ctx)); - let result; - try { - result = await caller.batchQuickAssign({ - assignments: validAssignments, - }); - } catch (error) { - const mapped = 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) { - assertPermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); - assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS); - - const caller = createTimelineCaller(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 = 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, - }; - }, - - async list_allocations(params: { - resourceId?: string; projectId?: string; - resourceName?: string; projectCode?: string; - status?: string; limit?: number; - }, ctx: ToolContext) { - const caller = createAllocationCaller(createScopedCallerContext(ctx)); - const status = params.status && Object.values(AllocationStatus).includes(params.status as AllocationStatus) - ? params.status as AllocationStatus - : undefined; - const readModel = await caller.listView({ - resourceId: params.resourceId, - projectId: params.projectId, - 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: fmtEur(assignment.dailyCostCents), - start: fmtDate(new Date(assignment.startDate)), - end: fmtDate(new Date(assignment.endDate)), - })); - }, - - async get_budget_status(params: { projectId: string }, ctx: ToolContext) { - const project = await resolveProjectIdentifier(ctx, params.projectId); - if ("error" in project) { - return project; - } - - const caller = createTimelineCaller(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 get_vacation_balance(params: { resourceId: string; year?: number }, ctx: ToolContext) { - const year = params.year ?? new Date().getFullYear(); - const resource = await resolveResourceIdentifier(ctx, params.resourceId); - if ("error" in resource) return resource; - - const caller = createEntitlementCaller(createScopedCallerContext(ctx)); - return caller.getBalanceDetail({ resourceId: resource.id, year }); - }, - - async list_vacations_upcoming(params: { - resourceName?: string; chapter?: string; daysAhead?: number; limit?: number; - }, ctx: ToolContext) { - const daysAhead = params.daysAhead ?? 30; - const limit = Math.min(params.limit ?? 30, 50); - const caller = createVacationCaller(createScopedCallerContext(ctx)); - const now = new Date(); - const until = new Date(now.getTime() + daysAhead * 24 * 60 * 60 * 1000); - - const vacations = await caller.list({ - status: "APPROVED", - startDate: now, - endDate: until, - limit, - }); - - const filtered = vacations - .filter((vacation) => { - if (params.resourceName) { - const resourceName = vacation.resource?.displayName?.toLowerCase() ?? ""; - if (!resourceName.includes(params.resourceName.toLowerCase())) { - return false; - } - } - if (params.chapter) { - const chapter = vacation.resource?.chapter?.toLowerCase() ?? ""; - if (!chapter.includes(params.chapter.toLowerCase())) { - return false; - } - } - return true; - }) - .slice(0, limit); - - return filtered.map((v) => ({ - resource: v.resource.displayName, - eid: v.resource.eid, - chapter: v.resource.chapter ?? null, - type: v.type, - start: fmtDate(v.startDate), - end: fmtDate(v.endDate), - isHalfDay: v.isHalfDay, - halfDayPart: v.halfDayPart, - })); - }, - - async list_holidays_by_region(params: { - countryCode: string; - federalState?: string; - metroCity?: string; - year?: number; - periodStart?: string; - periodEnd?: string; - }, ctx: ToolContext) { - const { year, periodStart, periodEnd } = resolveHolidayPeriod(params); - const caller = createHolidayCalendarCaller(createScopedCallerContext(ctx)); - const resolved = await caller.resolveHolidaysDetail({ - periodStart, - periodEnd, - countryCode: params.countryCode.trim().toUpperCase(), - ...(params.federalState ? { stateCode: params.federalState } : {}), - ...(params.metroCity ? { metroCityName: params.metroCity } : {}), - }); - - return { - locationContext: resolved.locationContext, - year, - periodStart: resolved.periodStart, - periodEnd: resolved.periodEnd, - count: resolved.count, - summary: resolved.summary, - holidays: resolved.holidays, - }; - }, - - async get_resource_holidays(params: { - identifier: string; - year?: number; - periodStart?: string; - periodEnd?: string; - }, ctx: ToolContext) { - const resource = await resolveResourceIdentifier(ctx, params.identifier); - if ("error" in resource) return resource; - - const { year, periodStart, periodEnd } = resolveHolidayPeriod(params); - const caller = createHolidayCalendarCaller(createScopedCallerContext(ctx)); - const resolved = await caller.resolveResourceHolidaysDetail({ - resourceId: resource.id, - periodStart, - periodEnd, - }); - - return { - resource: resolved.resource, - year, - periodStart: resolved.periodStart, - periodEnd: resolved.periodEnd, - count: resolved.count, - summary: resolved.summary, - holidays: resolved.holidays, - }; - }, - - async list_holiday_calendars(params: { - includeInactive?: boolean; - countryCode?: string; - scopeType?: "COUNTRY" | "STATE" | "CITY"; - stateCode?: string; - metroCity?: string; - }, ctx: ToolContext) { - const caller = createHolidayCalendarCaller(createScopedCallerContext(ctx)); - return caller.listCalendarsDetail(params); - }, - - async get_holiday_calendar(params: { - identifier: string; - }, ctx: ToolContext) { - const caller = createHolidayCalendarCaller(createScopedCallerContext(ctx)); - const identifier = params.identifier.trim(); - return resolveEntityOrAssistantError( - () => caller.getCalendarByIdentifierDetail({ identifier }), - `Holiday calendar not found: ${identifier}`, - ); - }, - - async preview_resolved_holiday_calendar(params: { - countryId: string; - stateCode?: string; - metroCityId?: string; - year: number; - }, ctx: ToolContext) { - const input = PreviewResolvedHolidaysSchema.parse(params); - const caller = createHolidayCalendarCaller(createScopedCallerContext(ctx)); - return caller.previewResolvedHolidaysDetail(input); - }, - - async create_holiday_calendar(params: { - name: string; - scopeType: "COUNTRY" | "STATE" | "CITY"; - countryId: string; - stateCode?: string; - metroCityId?: string; - isActive?: boolean; - priority?: number; - }, ctx: ToolContext) { - assertAdminRole(ctx); - const caller = createHolidayCalendarCaller(createScopedCallerContext(ctx)); - let created; - try { - created = await caller.createCalendar(CreateHolidayCalendarSchema.parse(params)); - } catch (error) { - const mapped = toAssistantHolidayCalendarMutationError(error); - if (mapped) { - return mapped; - } - throw error; - } - - return { - __action: "invalidate", - scope: ["holidayCalendar", "vacation"], - success: true, - calendar: formatHolidayCalendar(created), - message: `Created holiday calendar: ${created.name}`, - }; - }, - - async update_holiday_calendar(params: { - id: string; - data: { - name?: string; - stateCode?: string | null; - metroCityId?: string | null; - isActive?: boolean; - priority?: number; - }; - }, ctx: ToolContext) { - assertAdminRole(ctx); - const caller = createHolidayCalendarCaller(createScopedCallerContext(ctx)); - const input = { - id: params.id, - data: UpdateHolidayCalendarSchema.parse(params.data), - }; - let updated; - try { - updated = await caller.updateCalendar(input); - } catch (error) { - const mapped = toAssistantHolidayCalendarMutationError(error); - if (mapped) { - return mapped; - } - throw error; - } - - return { - __action: "invalidate", - scope: ["holidayCalendar", "vacation"], - success: true, - calendar: formatHolidayCalendar(updated), - message: `Updated holiday calendar: ${updated.name}`, - }; - }, - - async delete_holiday_calendar(params: { - id: string; - }, ctx: ToolContext) { - assertAdminRole(ctx); - const caller = createHolidayCalendarCaller(createScopedCallerContext(ctx)); - let deleted; - try { - deleted = await caller.deleteCalendar({ id: params.id }); - } catch (error) { - const mapped = toAssistantHolidayCalendarNotFoundError(error); - if (mapped) { - return mapped; - } - throw error; - } - - return { - __action: "invalidate", - scope: ["holidayCalendar", "vacation"], - success: true, - message: `Deleted holiday calendar: ${deleted.name}`, - }; - }, - - async create_holiday_calendar_entry(params: { - holidayCalendarId: string; - date: string; - name: string; - isRecurringAnnual?: boolean; - source?: string; - }, ctx: ToolContext) { - assertAdminRole(ctx); - const caller = createHolidayCalendarCaller(createScopedCallerContext(ctx)); - let created; - try { - created = await caller.createEntry(CreateHolidayCalendarEntrySchema.parse(params)); - } catch (error) { - const mapped = toAssistantHolidayEntryMutationError(error); - if (mapped) { - return mapped; - } - throw error; - } - - return { - __action: "invalidate", - scope: ["holidayCalendar", "vacation"], - success: true, - entry: formatHolidayCalendarEntry(created), - message: `Created holiday entry: ${created.name}`, - }; - }, - - async update_holiday_calendar_entry(params: { - id: string; - data: { - date?: string; - name?: string; - isRecurringAnnual?: boolean; - source?: string | null; - }; - }, ctx: ToolContext) { - assertAdminRole(ctx); - const caller = createHolidayCalendarCaller(createScopedCallerContext(ctx)); - const input = { - id: params.id, - data: UpdateHolidayCalendarEntrySchema.parse(params.data), - }; - let updated; - try { - updated = await caller.updateEntry(input); - } catch (error) { - const mapped = toAssistantHolidayEntryMutationError(error); - if (mapped) { - return mapped; - } - throw error; - } - - return { - __action: "invalidate", - scope: ["holidayCalendar", "vacation"], - success: true, - entry: formatHolidayCalendarEntry(updated), - message: `Updated holiday entry: ${updated.name}`, - }; - }, - - async delete_holiday_calendar_entry(params: { - id: string; - }, ctx: ToolContext) { - assertAdminRole(ctx); - const caller = createHolidayCalendarCaller(createScopedCallerContext(ctx)); - let deleted; - try { - deleted = await caller.deleteEntry({ id: params.id }); - } catch (error) { - const mapped = toAssistantHolidayEntryNotFoundError(error); - if (mapped) { - return mapped; - } - throw error; - } - - return { - __action: "invalidate", - scope: ["holidayCalendar", "vacation"], - success: true, - message: `Deleted holiday entry: ${deleted.name}`, - }; - }, - - async list_roles(_params: Record, ctx: ToolContext) { - const caller = createRoleCaller(createScopedCallerContext(ctx)); - const roles = await caller.list({}); - return roles.map((role) => ({ - id: role.id, - name: role.name, - color: role.color, - })); - }, - - async search_by_skill(params: { skill: string }, ctx: ToolContext) { - const caller = createResourceCaller(createScopedCallerContext(ctx)); - const matched = await caller.searchBySkills({ - rules: [{ skill: params.skill, minProficiency: 1 }], - operator: "OR", - }); - - return matched.slice(0, 20).map((resource) => ({ - id: resource.id, - eid: resource.eid, - name: resource.displayName, - matchedSkill: resource.matchedSkills[0]?.skill ?? null, - level: resource.matchedSkills[0]?.proficiency ?? null, - chapter: resource.chapter ?? null, - })); - }, - - async get_statistics(_params: Record, ctx: ToolContext) { - const caller = createDashboardCaller(createScopedCallerContext(ctx)); - return caller.getStatisticsDetail(); - }, - - async get_chargeability(params: { resourceId: string; month?: string }, ctx: ToolContext) { - const now = new Date(); - const month = params.month ?? `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; - const resource = await resolveResourceIdentifier(ctx, params.resourceId); - if ("error" in resource) { - return resource; - } - - const caller = createResourceCaller(createScopedCallerContext(ctx)); - return caller.getChargeabilitySummary({ - resourceId: resource.id, - month, - }); - }, + ...createAdvancedTimelineExecutors({ + assertPermission, + createStaffingCaller, + createTimelineCaller, + createScopedCallerContext, + resolveProjectIdentifier, + resolveResourceIdentifier, + parseIsoDate, + fmtDate, + isAssistantToolErrorResult, + toAssistantIndexedFieldError, + toAssistantTimelineMutationError, + }), + ...createAllocationPlanningExecutors({ + assertPermission, + createAllocationCaller, + createTimelineCaller, + createScopedCallerContext, + resolveProjectIdentifier, + resolveResourceIdentifier, + parseIsoDate, + parseOptionalIsoDate, + fmtDate, + toAssistantAllocationNotFoundError, + }), + ...createVacationHolidayExecutors({ + createEntitlementCaller, + createVacationCaller, + createHolidayCalendarCaller, + createScopedCallerContext, + resolveResourceIdentifier, + resolveHolidayPeriod, + resolveEntityOrAssistantError, + assertAdminRole, + fmtDate, + formatHolidayCalendar, + formatHolidayCalendarEntry, + toAssistantHolidayCalendarMutationError, + toAssistantHolidayCalendarNotFoundError, + toAssistantHolidayEntryMutationError, + toAssistantHolidayEntryNotFoundError, + }), + ...createRolesAnalyticsExecutors({ + createRoleCaller, + createResourceCaller, + createDashboardCaller, + createScopedCallerContext, + resolveResourceIdentifier, + toAssistantRoleMutationError, + }), + ...createClientsOrgUnitsExecutors({ + createClientCaller, + createOrgUnitCaller, + createScopedCallerContext, + toAssistantClientMutationError, + toAssistantOrgUnitMutationError, + }), async get_chargeability_report(params: { startMonth: string; @@ -6082,6 +4380,52 @@ const executors = { // ── NAVIGATION TOOLS ── + async get_my_timeline_entries_view(params: { + startDate: string; + endDate: string; + resourceIds?: string[]; + projectIds?: string[]; + clientIds?: string[]; + chapters?: string[]; + eids?: string[]; + countryCodes?: string[]; + }, ctx: ToolContext) { + const caller = createTimelineCaller(createScopedCallerContext(ctx)); + return caller.getMyEntriesView({ + startDate: parseIsoDate(params.startDate, "startDate"), + endDate: parseIsoDate(params.endDate, "endDate"), + ...(params.resourceIds ? { resourceIds: params.resourceIds } : {}), + ...(params.projectIds ? { projectIds: params.projectIds } : {}), + ...(params.clientIds ? { clientIds: params.clientIds } : {}), + ...(params.chapters ? { chapters: params.chapters } : {}), + ...(params.eids ? { eids: params.eids } : {}), + ...(params.countryCodes ? { countryCodes: params.countryCodes } : {}), + }); + }, + + async get_my_timeline_holiday_overlays(params: { + startDate: string; + endDate: string; + resourceIds?: string[]; + projectIds?: string[]; + clientIds?: string[]; + chapters?: string[]; + eids?: string[]; + countryCodes?: string[]; + }, ctx: ToolContext) { + const caller = createTimelineCaller(createScopedCallerContext(ctx)); + return caller.getMyHolidayOverlays({ + startDate: parseIsoDate(params.startDate, "startDate"), + endDate: parseIsoDate(params.endDate, "endDate"), + ...(params.resourceIds ? { resourceIds: params.resourceIds } : {}), + ...(params.projectIds ? { projectIds: params.projectIds } : {}), + ...(params.clientIds ? { clientIds: params.clientIds } : {}), + ...(params.chapters ? { chapters: params.chapters } : {}), + ...(params.eids ? { eids: params.eids } : {}), + ...(params.countryCodes ? { countryCodes: params.countryCodes } : {}), + }); + }, + async navigate_to_page(params: { page: string; eids?: string; chapters?: string; projectIds?: string; clientIds?: string; @@ -6126,190 +4470,6 @@ const executors = { // ── WRITE TOOLS ── - async create_allocation(params: { - resourceId: string; projectId: string; - startDate: string; endDate: string; - hoursPerDay: number; role?: string; - }, ctx: ToolContext) { - assertPermission(ctx, "manageAllocations" as PermissionKey); - const [resource, project] = await Promise.all([ - resolveResourceIdentifier(ctx, params.resourceId), - resolveProjectIdentifier(ctx, params.projectId), - ]); - if ("error" in resource) { - return resource; - } - if ("error" in project) { - return project; - } - - const caller = createAllocationCaller(createScopedCallerContext(ctx)); - try { - const result = await caller.ensureAssignment({ - resourceId: resource.id, - projectId: project.id, - startDate: parseIsoDate(params.startDate, "startDate"), - endDate: 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) { - assertPermission(ctx, "manageAllocations" as PermissionKey); - - const caller = createAllocationCaller(createScopedCallerContext(ctx)); - let resourceId: string | undefined; - let projectId: string | undefined; - if (!params.allocationId && params.resourceName && params.projectCode) { - const [resource, project] = await Promise.all([ - resolveResourceIdentifier(ctx, params.resourceName), - resolveProjectIdentifier(ctx, params.projectCode), - ]); - if ("error" in resource) { - return resource; - } - if ("error" in project) { - return project; - } - resourceId = resource.id; - projectId = project.id; - } - - const startDate = parseOptionalIsoDate(params.startDate, "startDate"); - const endDate = 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 = 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 = 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}), ${fmtDate(assignment.startDate)} to ${fmtDate(assignment.endDate)}`, - }; - }, - - async update_allocation_status(params: { - allocationId?: string; - resourceName?: string; projectCode?: string; - startDate?: string; - newStatus: string; - }, ctx: ToolContext) { - assertPermission(ctx, "manageAllocations" as PermissionKey); - - const validStatuses = ["PROPOSED", "CONFIRMED", "ACTIVE", "COMPLETED", "CANCELLED"]; - if (!validStatuses.includes(params.newStatus)) { - return { error: `Invalid status: ${params.newStatus}. Valid: ${validStatuses.join(", ")}` }; - } - - const caller = createAllocationCaller(createScopedCallerContext(ctx)); - let resourceId: string | undefined; - let projectId: string | undefined; - if (!params.allocationId && params.resourceName && params.projectCode) { - const [resource, project] = await Promise.all([ - resolveResourceIdentifier(ctx, params.resourceName), - resolveProjectIdentifier(ctx, params.projectCode), - ]); - if ("error" in resource) { - return resource; - } - if ("error" in project) { - return project; - } - resourceId = resource.id; - projectId = project.id; - } - - const startDate = 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 = 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 = 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}), ${fmtDate(assignment.startDate)} to ${fmtDate(assignment.endDate)}: ${oldStatus} → ${params.newStatus}`, - }; - }, - async update_resource(params: { id: string; displayName?: string; fte?: number; lcrCents?: number; chapter?: string; chargeabilityTarget?: number; @@ -7500,140 +5660,8 @@ const executors = { // ── ROLES ── - async create_role(params: { - name: string; - description?: string; - color?: string; - }, ctx: ToolContext) { - const caller = createRoleCaller(createScopedCallerContext(ctx)); - let role; - try { - role = await caller.create(CreateRoleSchema.parse(params)); - } catch (error) { - const mapped = toAssistantRoleMutationError(error, "create"); - if (mapped) { - return mapped; - } - throw error; - } - return { __action: "invalidate", scope: ["role"], success: true, message: `Created role: ${role.name}`, roleId: role.id, role }; - }, - - async update_role(params: { - id: string; - name?: string; - description?: string; - color?: string; - isActive?: boolean; - }, ctx: ToolContext) { - const caller = createRoleCaller(createScopedCallerContext(ctx)); - const data = UpdateRoleSchema.parse({ - ...(params.name !== undefined ? { name: params.name } : {}), - ...(params.description !== undefined ? { description: params.description } : {}), - ...(params.color !== undefined ? { color: params.color } : {}), - ...(params.isActive !== undefined ? { isActive: params.isActive } : {}), - }); - if (Object.keys(data).length === 0) return { error: "No fields to update" }; - let role; - try { - role = await caller.update({ id: params.id, data }); - } catch (error) { - const mapped = toAssistantRoleMutationError(error, "update"); - if (mapped) { - return mapped; - } - throw error; - } - return { __action: "invalidate", scope: ["role"], success: true, message: `Updated role: ${role.name}`, roleId: role.id, role }; - }, - - async delete_role(params: { id: string }, ctx: ToolContext) { - const caller = createRoleCaller(createScopedCallerContext(ctx)); - let role; - try { - role = await caller.getById({ id: params.id }); - await caller.delete({ id: params.id }); - } catch (error) { - const mapped = toAssistantRoleMutationError(error, "delete"); - if (mapped) { - return mapped; - } - throw error; - } - return { __action: "invalidate", scope: ["role"], success: true, message: `Deleted role: ${role.name}` }; - }, - // ── CLIENTS ── - async create_client(params: { - name: string; - code?: string; - parentId?: string; - sortOrder?: number; - tags?: string[]; - }, ctx: ToolContext) { - const caller = createClientCaller(createScopedCallerContext(ctx)); - let client; - try { - client = await caller.create(CreateClientSchema.parse(params)); - } catch (error) { - const mapped = toAssistantClientMutationError(error); - if (mapped) { - return mapped; - } - throw error; - } - return { __action: "invalidate", scope: ["client"], success: true, message: `Created client: ${client.name}`, clientId: client.id, client }; - }, - - async update_client(params: { - id: string; - name?: string; - code?: string | null; - sortOrder?: number; - isActive?: boolean; - parentId?: string | null; - tags?: string[]; - }, ctx: ToolContext) { - const caller = createClientCaller(createScopedCallerContext(ctx)); - const data = UpdateClientSchema.parse({ - ...(params.name !== undefined ? { name: params.name } : {}), - ...(params.code !== undefined ? { code: params.code } : {}), - ...(params.sortOrder !== undefined ? { sortOrder: params.sortOrder } : {}), - ...(params.isActive !== undefined ? { isActive: params.isActive } : {}), - ...(params.parentId !== undefined ? { parentId: params.parentId } : {}), - ...(params.tags !== undefined ? { tags: params.tags } : {}), - }); - if (Object.keys(data).length === 0) return { error: "No fields to update" }; - let client; - try { - client = await caller.update({ id: params.id, data }); - } catch (error) { - const mapped = toAssistantClientMutationError(error); - if (mapped) { - return mapped; - } - throw error; - } - return { __action: "invalidate", scope: ["client"], success: true, message: `Updated client: ${client.name}`, clientId: client.id, client }; - }, - - async delete_client(params: { id: string }, ctx: ToolContext) { - const caller = createClientCaller(createScopedCallerContext(ctx)); - let client; - try { - client = await caller.getById({ id: params.id }); - await caller.delete({ id: params.id }); - } catch (error) { - const mapped = toAssistantClientMutationError(error, "delete"); - if (mapped) { - return mapped; - } - throw error; - } - return { __action: "invalidate", scope: ["client"], success: true, message: `Deleted client: ${client.name}` }; - }, - // ── ADMIN / CONFIG ── async list_countries(params: { includeInactive?: boolean; search?: string }, ctx: ToolContext) { @@ -8365,58 +6393,6 @@ const executors = { }, // ── ORG UNIT MANAGEMENT ── - - async create_org_unit(params: { - name: string; - shortName?: string; - level: number; - parentId?: string; - sortOrder?: number; - }, ctx: ToolContext) { - const caller = createOrgUnitCaller(createScopedCallerContext(ctx)); - let ou; - try { - ou = await caller.create(CreateOrgUnitSchema.parse(params)); - } catch (error) { - const mapped = toAssistantOrgUnitMutationError(error); - if (mapped) { - return mapped; - } - throw error; - } - return { __action: "invalidate", scope: ["orgUnit"], success: true, message: `Created org unit: ${ou.name}`, orgUnitId: ou.id, orgUnit: ou }; - }, - - async update_org_unit(params: { - id: string; - name?: string; - shortName?: string | null; - sortOrder?: number; - isActive?: boolean; - parentId?: string | null; - }, ctx: ToolContext) { - const caller = createOrgUnitCaller(createScopedCallerContext(ctx)); - const data = UpdateOrgUnitSchema.parse({ - ...(params.name !== undefined ? { name: params.name } : {}), - ...(params.shortName !== undefined ? { shortName: params.shortName } : {}), - ...(params.sortOrder !== undefined ? { sortOrder: params.sortOrder } : {}), - ...(params.isActive !== undefined ? { isActive: params.isActive } : {}), - ...(params.parentId !== undefined ? { parentId: params.parentId } : {}), - }); - if (Object.keys(data).length === 0) return { error: "No fields to update" }; - let ou; - try { - ou = await caller.update({ id: params.id, data }); - } catch (error) { - const mapped = toAssistantOrgUnitMutationError(error); - if (mapped) { - return mapped; - } - throw error; - } - return { __action: "invalidate", scope: ["orgUnit"], success: true, message: `Updated org unit: ${ou.name}`, orgUnitId: ou.id, orgUnit: ou }; - }, - // ─── Cover Art ─────────────────────────────────────────────────────────── async generate_project_cover(params: { projectId: string; prompt?: string }, ctx: ToolContext) { @@ -8844,6 +6820,9 @@ const executors = { value: string; }>; periodMonth?: string; + groupBy?: string; + sortBy?: string; + sortDir?: "asc" | "desc"; limit?: number; }, ctx: ToolContext) { const entity = params.entity as "resource" | "project" | "assignment" | "resource_month"; @@ -8859,7 +6838,9 @@ const executors = { columns: params.columns, filters: params.filters ?? [], periodMonth: params.periodMonth, - sortDir: "asc", + groupBy: params.groupBy, + sortBy: params.sortBy, + sortDir: params.sortDir ?? "asc", limit: Math.min(params.limit ?? 50, 200), offset: 0, }); @@ -8867,11 +6848,13 @@ const executors = { return { rows: result.rows, rowCount: result.rows.length, + totalCount: result.totalCount, columns: result.columns, + groups: result.groups, }; }, - async list_comments(params: { entityType: "estimate"; entityId: string }, ctx: ToolContext) { + async list_comments(params: { entityType: CommentEntityType; entityId: string }, ctx: ToolContext) { const caller = createCommentCaller(createScopedCallerContext(ctx)); const comments = await caller.list({ entityType: params.entityType, @@ -8973,7 +6956,7 @@ const executors = { }, async create_comment(params: { - entityType: "estimate"; + entityType: CommentEntityType; entityId: string; body: string; }, ctx: ToolContext) { @@ -9305,315 +7288,21 @@ const executors = { }); }, - async get_system_settings(_params: Record, ctx: ToolContext) { - const caller = createSettingsCaller(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 = createSettingsCaller(createScopedCallerContext(ctx)); - return caller.updateSystemSettings(params); - }, - - async clear_stored_runtime_secrets(_params: Record, ctx: ToolContext) { - const caller = createSettingsCaller(createScopedCallerContext(ctx)); - return caller.clearStoredRuntimeSecrets(); - }, - - async test_ai_connection(_params: Record, ctx: ToolContext) { - const caller = createSettingsCaller(createScopedCallerContext(ctx)); - return caller.testAiConnection(); - }, - - async test_smtp_connection(_params: Record, ctx: ToolContext) { - const caller = createSettingsCaller(createScopedCallerContext(ctx)); - return caller.testSmtpConnection(); - }, - - async test_gemini_connection(_params: Record, ctx: ToolContext) { - const caller = createSettingsCaller(createScopedCallerContext(ctx)); - return caller.testGeminiConnection(); - }, - - async get_ai_configured(_params: Record, 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, ctx: ToolContext) { - const caller = createSystemRoleConfigCaller(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 = createSystemRoleConfigCaller(createScopedCallerContext(ctx)); - return caller.update(params); - }, - - async list_webhooks(_params: Record, ctx: ToolContext) { - const caller = createWebhookCaller(createScopedCallerContext(ctx)); - const webhooks = await caller.list(); - return sanitizeWebhookList(webhooks); - }, - - async get_webhook(params: { - id: string; - }, ctx: ToolContext) { - const caller = createWebhookCaller(createScopedCallerContext(ctx)); - let webhook; - try { - webhook = await caller.getById({ id: params.id }); - } catch (error) { - const mapped = toAssistantWebhookNotFoundError(error); - if (mapped) { - return mapped; - } - throw error; - } - return sanitizeWebhook(webhook); - }, - - async create_webhook(params: { - name: string; - url: string; - secret?: string; - events: string[]; - isActive?: boolean; - }, ctx: ToolContext) { - const caller = createWebhookCaller(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 = toAssistantWebhookMutationError(error, "create"); - if (mapped) { - return mapped; - } - throw error; - } - return sanitizeWebhook(webhook); - }, - - async update_webhook(params: { - id: string; - data: { - name?: string; - url?: string; - secret?: string | null; - events?: string[]; - isActive?: boolean; - }; - }, ctx: ToolContext) { - const caller = createWebhookCaller(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 = toAssistantWebhookMutationError(error, "update"); - if (mapped) { - return mapped; - } - throw error; - } - return sanitizeWebhook(webhook); - }, - - async delete_webhook(params: { - id: string; - }, ctx: ToolContext) { - const caller = createWebhookCaller(createScopedCallerContext(ctx)); - try { - await caller.delete({ id: params.id }); - } catch (error) { - const mapped = toAssistantWebhookNotFoundError(error); - if (mapped) { - return mapped; - } - throw error; - } - return { ok: true, id: params.id }; - }, - - async test_webhook(params: { - id: string; - }, ctx: ToolContext) { - const caller = createWebhookCaller(createScopedCallerContext(ctx)); - try { - return await caller.test({ id: params.id }); - } catch (error) { - const mapped = 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 = createAuditLogCaller(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: parseIsoDate(params.startDate, "startDate") } : {}), - ...(params.endDate ? { endDate: 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 = createAuditLogCaller(createScopedCallerContext(ctx)); - try { - return await caller.getByIdDetail({ id: params.id }); - } catch (error) { - const mapped = toAssistantAuditLogEntryNotFoundError(error); - if (mapped) { - return mapped; - } - throw error; - } - }, - - async get_audit_log_timeline(params: { - startDate?: string; - endDate?: string; - limit?: number; - }, ctx: ToolContext) { - const caller = createAuditLogCaller(createScopedCallerContext(ctx)); - return caller.getTimelineDetail({ - ...(params.startDate ? { startDate: parseIsoDate(params.startDate, "startDate") } : {}), - ...(params.endDate ? { endDate: 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 = createAuditLogCaller(createScopedCallerContext(ctx)); - return caller.getActivitySummary({ - ...(params.startDate ? { startDate: parseIsoDate(params.startDate, "startDate") } : {}), - ...(params.endDate ? { endDate: parseIsoDate(params.endDate, "endDate") } : {}), - }); - }, - - async get_shoring_ratio(params: { projectId: string }, ctx: ToolContext) { - const project = await resolveProjectIdentifier(ctx, params.projectId); - if ("error" in project) return project; - - const caller = createProjectCaller(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)` : ""}`; - }, + ...createSettingsAdminExecutors({ + createSettingsCaller, + createSystemRoleConfigCaller, + createWebhookCaller, + createAuditLogCaller, + createProjectCaller, + createScopedCallerContext, + parseIsoDate, + resolveProjectIdentifier, + sanitizeWebhook, + sanitizeWebhookList, + toAssistantWebhookNotFoundError, + toAssistantWebhookMutationError, + toAssistantAuditLogEntryNotFoundError, + }), }; // ─── Executor ─────────────────────────────────────────────────────────────── diff --git a/packages/api/src/router/assistant-tools/clients-org-units.ts b/packages/api/src/router/assistant-tools/clients-org-units.ts new file mode 100644 index 0000000..fe15053 --- /dev/null +++ b/packages/api/src/router/assistant-tools/clients-org-units.ts @@ -0,0 +1,318 @@ +import type { TRPCContext } from "../../trpc.js"; +import { + CreateClientSchema, + CreateOrgUnitSchema, + UpdateClientSchema, + UpdateOrgUnitSchema, +} from "@capakraken/shared"; +import { z } from "zod"; +import type { ToolContext, ToolDef, ToolExecutor } from "./shared.js"; + +type AssistantToolErrorResult = { error: string }; + +type ClientsOrgUnitsDeps = { + createClientCaller: (ctx: TRPCContext) => { + create: (params: z.input) => Promise<{ + id: string; + name: string; + }>; + update: (params: { + id: string; + data: z.input; + }) => Promise<{ + id: string; + name: string; + }>; + getById: (params: { id: string }) => Promise<{ name: string }>; + delete: (params: { id: string }) => Promise; + }; + createOrgUnitCaller: (ctx: TRPCContext) => { + create: (params: z.input) => Promise<{ + id: string; + name: string; + }>; + update: (params: { + id: string; + data: z.input; + }) => Promise<{ + id: string; + name: string; + }>; + }; + createScopedCallerContext: (ctx: ToolContext) => TRPCContext; + toAssistantClientMutationError: ( + error: unknown, + action?: "create" | "update" | "delete", + ) => AssistantToolErrorResult | null; + toAssistantOrgUnitMutationError: ( + error: unknown, + ) => AssistantToolErrorResult | null; +}; + +export const clientMutationToolDefinitions: ToolDef[] = [ + { + type: "function", + function: { + name: "create_client", + description: "Create a new client. Requires manager or admin role. Always confirm first.", + parameters: { + type: "object", + properties: { + name: { type: "string", description: "Client name" }, + code: { type: "string", description: "Client code" }, + parentId: { type: "string", description: "Optional parent client ID" }, + sortOrder: { type: "integer", description: "Sort order. Default: 0" }, + tags: { type: "array", items: { type: "string" }, description: "Optional client tags" }, + }, + required: ["name"], + }, + }, + }, + { + type: "function", + function: { + name: "update_client", + description: "Update a client. Requires manager or admin role. Always confirm first.", + parameters: { + type: "object", + properties: { + id: { type: "string", description: "Client ID" }, + name: { type: "string", description: "New name" }, + code: { type: "string", description: "New code" }, + sortOrder: { type: "integer", description: "New sort order" }, + isActive: { type: "boolean", description: "Set active state" }, + parentId: { type: "string", description: "Parent client ID; use null to clear" }, + tags: { type: "array", items: { type: "string" }, description: "Replacement client tags" }, + }, + required: ["id"], + }, + }, + }, + { + type: "function", + function: { + name: "delete_client", + description: "Delete a client. Requires admin role. Always confirm first.", + parameters: { + type: "object", + properties: { + id: { type: "string", description: "Client ID" }, + }, + required: ["id"], + }, + }, + }, +]; + +export const orgUnitMutationToolDefinitions: ToolDef[] = [ + { + type: "function", + function: { + name: "create_org_unit", + description: "Create a new organizational unit. Admin role required. Always confirm first.", + parameters: { + type: "object", + properties: { + name: { type: "string", description: "Org unit name" }, + shortName: { type: "string", description: "Short name/code" }, + level: { type: "integer", description: "Level (5, 6, or 7)" }, + parentId: { type: "string", description: "Parent org unit ID (optional)" }, + sortOrder: { type: "integer", description: "Sort order. Default: 0" }, + }, + required: ["name", "level"], + }, + }, + }, + { + type: "function", + function: { + name: "update_org_unit", + description: "Update an organizational unit. Admin role required. Always confirm first.", + parameters: { + type: "object", + properties: { + id: { type: "string", description: "Org unit ID" }, + name: { type: "string", description: "New name" }, + shortName: { type: "string", description: "New short name" }, + sortOrder: { type: "integer", description: "New sort order" }, + isActive: { type: "boolean", description: "Set active state" }, + parentId: { type: "string", description: "Parent org unit ID; use null to clear" }, + }, + required: ["id"], + }, + }, + }, +]; + +export function createClientsOrgUnitsExecutors( + deps: ClientsOrgUnitsDeps, +): Record { + return { + async create_client(params: { + name: string; + code?: string; + parentId?: string; + sortOrder?: number; + tags?: string[]; + }, ctx: ToolContext) { + const caller = deps.createClientCaller(deps.createScopedCallerContext(ctx)); + + let client; + try { + client = await caller.create(CreateClientSchema.parse(params)); + } catch (error) { + const mapped = deps.toAssistantClientMutationError(error); + if (mapped) { + return mapped; + } + throw error; + } + + return { + __action: "invalidate" as const, + scope: ["client"], + success: true, + message: `Created client: ${client.name}`, + clientId: client.id, + client, + }; + }, + + async update_client(params: { + id: string; + name?: string; + code?: string | null; + sortOrder?: number; + isActive?: boolean; + parentId?: string | null; + tags?: string[]; + }, ctx: ToolContext) { + const caller = deps.createClientCaller(deps.createScopedCallerContext(ctx)); + const data = UpdateClientSchema.parse({ + ...(params.name !== undefined ? { name: params.name } : {}), + ...(params.code !== undefined ? { code: params.code } : {}), + ...(params.sortOrder !== undefined ? { sortOrder: params.sortOrder } : {}), + ...(params.isActive !== undefined ? { isActive: params.isActive } : {}), + ...(params.parentId !== undefined ? { parentId: params.parentId } : {}), + ...(params.tags !== undefined ? { tags: params.tags } : {}), + }); + if (Object.keys(data).length === 0) { + return { error: "No fields to update" }; + } + + let client; + try { + client = await caller.update({ id: params.id, data }); + } catch (error) { + const mapped = deps.toAssistantClientMutationError(error); + if (mapped) { + return mapped; + } + throw error; + } + + return { + __action: "invalidate" as const, + scope: ["client"], + success: true, + message: `Updated client: ${client.name}`, + clientId: client.id, + client, + }; + }, + + async delete_client(params: { id: string }, ctx: ToolContext) { + const caller = deps.createClientCaller(deps.createScopedCallerContext(ctx)); + + let client; + try { + client = await caller.getById({ id: params.id }); + await caller.delete({ id: params.id }); + } catch (error) { + const mapped = deps.toAssistantClientMutationError(error, "delete"); + if (mapped) { + return mapped; + } + throw error; + } + + return { + __action: "invalidate" as const, + scope: ["client"], + success: true, + message: `Deleted client: ${client.name}`, + }; + }, + + async create_org_unit(params: { + name: string; + shortName?: string; + level: number; + parentId?: string; + sortOrder?: number; + }, ctx: ToolContext) { + const caller = deps.createOrgUnitCaller(deps.createScopedCallerContext(ctx)); + + let orgUnit; + try { + orgUnit = await caller.create(CreateOrgUnitSchema.parse(params)); + } catch (error) { + const mapped = deps.toAssistantOrgUnitMutationError(error); + if (mapped) { + return mapped; + } + throw error; + } + + return { + __action: "invalidate" as const, + scope: ["orgUnit"], + success: true, + message: `Created org unit: ${orgUnit.name}`, + orgUnitId: orgUnit.id, + orgUnit, + }; + }, + + async update_org_unit(params: { + id: string; + name?: string; + shortName?: string | null; + sortOrder?: number; + isActive?: boolean; + parentId?: string | null; + }, ctx: ToolContext) { + const caller = deps.createOrgUnitCaller(deps.createScopedCallerContext(ctx)); + const data = UpdateOrgUnitSchema.parse({ + ...(params.name !== undefined ? { name: params.name } : {}), + ...(params.shortName !== undefined ? { shortName: params.shortName } : {}), + ...(params.sortOrder !== undefined ? { sortOrder: params.sortOrder } : {}), + ...(params.isActive !== undefined ? { isActive: params.isActive } : {}), + ...(params.parentId !== undefined ? { parentId: params.parentId } : {}), + }); + if (Object.keys(data).length === 0) { + return { error: "No fields to update" }; + } + + let orgUnit; + try { + orgUnit = await caller.update({ id: params.id, data }); + } catch (error) { + const mapped = deps.toAssistantOrgUnitMutationError(error); + if (mapped) { + return mapped; + } + throw error; + } + + return { + __action: "invalidate" as const, + scope: ["orgUnit"], + success: true, + message: `Updated org unit: ${orgUnit.name}`, + orgUnitId: orgUnit.id, + orgUnit, + }; + }, + }; +} diff --git a/packages/api/src/router/assistant-tools/roles-analytics.ts b/packages/api/src/router/assistant-tools/roles-analytics.ts new file mode 100644 index 0000000..842eca5 --- /dev/null +++ b/packages/api/src/router/assistant-tools/roles-analytics.ts @@ -0,0 +1,306 @@ +import type { TRPCContext } from "../../trpc.js"; +import { CreateRoleSchema, UpdateRoleSchema } from "@capakraken/shared"; +import { z } from "zod"; +import type { ToolContext, ToolDef, ToolExecutor } from "./shared.js"; + +type AssistantToolErrorResult = { error: string }; + +type ResolvedResource = { + id: string; +}; + +type RolesAnalyticsDeps = { + createRoleCaller: (ctx: TRPCContext) => { + list: (params: Record) => Promise>; + create: (params: z.input) => Promise<{ + id: string; + name: string; + }>; + update: (params: { + id: string; + data: z.input; + }) => Promise<{ + id: string; + name: string; + }>; + getById: (params: { id: string }) => Promise<{ name: string }>; + delete: (params: { id: string }) => Promise; + }; + createResourceCaller: (ctx: TRPCContext) => { + searchBySkills: (params: { + rules: Array<{ skill: string; minProficiency: number }>; + operator: "OR"; + }) => Promise; + }>>; + getChargeabilitySummary: (params: { + resourceId: string; + month: string; + }) => Promise; + }; + createDashboardCaller: (ctx: TRPCContext) => { + getStatisticsDetail: () => Promise; + }; + createScopedCallerContext: (ctx: ToolContext) => TRPCContext; + resolveResourceIdentifier: ( + ctx: ToolContext, + identifier: string, + ) => Promise; + toAssistantRoleMutationError: ( + error: unknown, + action: "create" | "update" | "delete", + ) => AssistantToolErrorResult | null; +}; + +export const rolesAnalyticsReadToolDefinitions: ToolDef[] = [ + { + type: "function", + function: { + name: "list_roles", + description: "List all available roles with their colors.", + parameters: { type: "object", properties: {} }, + }, + }, + { + type: "function", + function: { + name: "search_by_skill", + description: "Find resources that have a specific skill. Controller/manager/admin access required.", + parameters: { + type: "object", + properties: { + skill: { type: "string", description: "Skill name to search for" }, + }, + required: ["skill"], + }, + }, + }, + { + type: "function", + function: { + name: "get_statistics", + description: "Get overview statistics: total resources, projects, active allocations, budget summary, projects by status, chapter breakdown.", + parameters: { type: "object", properties: {} }, + }, + }, + { + type: "function", + function: { + name: "get_chargeability", + description: "Get chargeability data for a resource in a given month: hours booked vs available, chargeability %, target comparison.", + parameters: { + type: "object", + properties: { + resourceId: { type: "string", description: "Resource ID, eid, or name" }, + month: { type: "string", description: "Month in YYYY-MM format, e.g. 2026-03. Default: current month" }, + }, + required: ["resourceId"], + }, + }, + }, +]; + +export const rolesAnalyticsMutationToolDefinitions: ToolDef[] = [ + { + type: "function", + function: { + name: "create_role", + description: "Create a new role. Requires manager or admin role plus manageRoles permission. Always confirm first.", + parameters: { + type: "object", + properties: { + name: { type: "string", description: "Role name" }, + description: { type: "string", description: "Optional role description" }, + color: { type: "string", description: "Hex color (e.g. #3b82f6). Default: #6b7280" }, + }, + required: ["name"], + }, + }, + }, + { + type: "function", + function: { + name: "update_role", + description: "Update a role. Requires manager or admin role plus manageRoles permission. Always confirm first.", + parameters: { + type: "object", + properties: { + id: { type: "string", description: "Role ID" }, + name: { type: "string", description: "New name" }, + description: { type: "string", description: "New description" }, + color: { type: "string", description: "New hex color" }, + isActive: { type: "boolean", description: "Set active state" }, + }, + required: ["id"], + }, + }, + }, + { + type: "function", + function: { + name: "delete_role", + description: "Delete a role. Requires manager or admin role plus manageRoles permission. Always confirm first.", + parameters: { + type: "object", + properties: { + id: { type: "string", description: "Role ID" }, + }, + required: ["id"], + }, + }, + }, +]; + +export function createRolesAnalyticsExecutors( + deps: RolesAnalyticsDeps, +): Record { + return { + async list_roles(_params: Record, ctx: ToolContext) { + const caller = deps.createRoleCaller(deps.createScopedCallerContext(ctx)); + const roles = await caller.list({}); + return roles.map((role) => ({ + id: role.id, + name: role.name, + color: role.color ?? null, + })); + }, + + async search_by_skill(params: { skill: string }, ctx: ToolContext) { + const caller = deps.createResourceCaller(deps.createScopedCallerContext(ctx)); + const matched = await caller.searchBySkills({ + rules: [{ skill: params.skill, minProficiency: 1 }], + operator: "OR", + }); + + return matched.slice(0, 20).map((resource) => ({ + id: resource.id, + eid: resource.eid, + name: resource.displayName, + matchedSkill: resource.matchedSkills[0]?.skill ?? null, + level: resource.matchedSkills[0]?.proficiency ?? null, + chapter: resource.chapter ?? null, + })); + }, + + async get_statistics(_params: Record, ctx: ToolContext) { + const caller = deps.createDashboardCaller(deps.createScopedCallerContext(ctx)); + return caller.getStatisticsDetail(); + }, + + async get_chargeability(params: { resourceId: string; month?: string }, ctx: ToolContext) { + const now = new Date(); + const month = params.month ?? `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; + const resource = await deps.resolveResourceIdentifier(ctx, params.resourceId); + if ("error" in resource) { + return resource; + } + + const caller = deps.createResourceCaller(deps.createScopedCallerContext(ctx)); + return caller.getChargeabilitySummary({ + resourceId: resource.id, + month, + }); + }, + + async create_role(params: { + name: string; + description?: string; + color?: string; + }, ctx: ToolContext) { + const caller = deps.createRoleCaller(deps.createScopedCallerContext(ctx)); + + let role; + try { + role = await caller.create(CreateRoleSchema.parse(params)); + } catch (error) { + const mapped = deps.toAssistantRoleMutationError(error, "create"); + if (mapped) { + return mapped; + } + throw error; + } + + return { + __action: "invalidate" as const, + scope: ["role"], + success: true, + message: `Created role: ${role.name}`, + roleId: role.id, + role, + }; + }, + + async update_role(params: { + id: string; + name?: string; + description?: string; + color?: string; + isActive?: boolean; + }, ctx: ToolContext) { + const caller = deps.createRoleCaller(deps.createScopedCallerContext(ctx)); + const data = UpdateRoleSchema.parse({ + ...(params.name !== undefined ? { name: params.name } : {}), + ...(params.description !== undefined ? { description: params.description } : {}), + ...(params.color !== undefined ? { color: params.color } : {}), + ...(params.isActive !== undefined ? { isActive: params.isActive } : {}), + }); + if (Object.keys(data).length === 0) { + return { error: "No fields to update" }; + } + + let role; + try { + role = await caller.update({ id: params.id, data }); + } catch (error) { + const mapped = deps.toAssistantRoleMutationError(error, "update"); + if (mapped) { + return mapped; + } + throw error; + } + + return { + __action: "invalidate" as const, + scope: ["role"], + success: true, + message: `Updated role: ${role.name}`, + roleId: role.id, + role, + }; + }, + + async delete_role(params: { id: string }, ctx: ToolContext) { + const caller = deps.createRoleCaller(deps.createScopedCallerContext(ctx)); + + let role; + try { + role = await caller.getById({ id: params.id }); + await caller.delete({ id: params.id }); + } catch (error) { + const mapped = deps.toAssistantRoleMutationError(error, "delete"); + if (mapped) { + return mapped; + } + throw error; + } + + return { + __action: "invalidate" as const, + scope: ["role"], + success: true, + message: `Deleted role: ${role.name}`, + }; + }, + }; +} diff --git a/packages/api/src/router/assistant-tools/vacation-holidays.ts b/packages/api/src/router/assistant-tools/vacation-holidays.ts new file mode 100644 index 0000000..7dbd86a --- /dev/null +++ b/packages/api/src/router/assistant-tools/vacation-holidays.ts @@ -0,0 +1,717 @@ +import type { TRPCContext } from "../../trpc.js"; +import { + CreateHolidayCalendarEntrySchema, + CreateHolidayCalendarSchema, + PreviewResolvedHolidaysSchema, + UpdateHolidayCalendarEntrySchema, + UpdateHolidayCalendarSchema, +} from "@capakraken/shared"; +import { z } from "zod"; +import type { ToolContext, ToolDef, ToolExecutor } from "./shared.js"; + +type AssistantToolErrorResult = { error: string }; + +type ResolvedResource = { + id: string; +}; + +type HolidayCalendarEntryRecord = { + id: string; + date: Date; + name: string; + isRecurringAnnual?: boolean | null; + source?: string | null; +}; + +type HolidayCalendarRecord = { + id: string; + name: string; + scopeType: string; + stateCode?: string | null; + isActive?: boolean | null; + priority?: number | null; + country?: { id: string; code: string; name: string } | null; + metroCity?: { id: string; name: string } | null; + _count?: { entries?: number | null } | null; + entries?: HolidayCalendarEntryRecord[] | null; +}; + +type VacationHolidayDeps = { + createEntitlementCaller: (ctx: TRPCContext) => { + getBalanceDetail: (params: { resourceId: string; year: number }) => Promise; + }; + createVacationCaller: (ctx: TRPCContext) => { + list: (params: { + status: "APPROVED"; + startDate: Date; + endDate: Date; + limit: number; + }) => Promise>; + }; + createHolidayCalendarCaller: (ctx: TRPCContext) => { + resolveHolidaysDetail: (params: { + periodStart: Date; + periodEnd: Date; + countryCode: string; + stateCode?: string; + metroCityName?: string; + }) => Promise<{ + locationContext: unknown; + periodStart: string; + periodEnd: string; + count: number; + summary: unknown; + holidays: unknown[]; + }>; + resolveResourceHolidaysDetail: (params: { + resourceId: string; + periodStart: Date; + periodEnd: Date; + }) => Promise<{ + resource: unknown; + periodStart: string; + periodEnd: string; + count: number; + summary: unknown; + holidays: unknown[]; + }>; + listCalendarsDetail: (params: { + includeInactive?: boolean; + countryCode?: string; + scopeType?: "COUNTRY" | "STATE" | "CITY"; + stateCode?: string; + metroCity?: string; + }) => Promise; + getCalendarByIdentifierDetail: (params: { identifier: string }) => Promise; + previewResolvedHolidaysDetail: ( + params: z.input, + ) => Promise; + createCalendar: ( + params: z.input, + ) => Promise; + updateCalendar: (params: { + id: string; + data: z.input; + }) => Promise; + deleteCalendar: (params: { id: string }) => Promise<{ name: string }>; + createEntry: ( + params: z.input, + ) => Promise; + updateEntry: (params: { + id: string; + data: z.input; + }) => Promise; + deleteEntry: (params: { id: string }) => Promise<{ name: string }>; + }; + createScopedCallerContext: (ctx: ToolContext) => TRPCContext; + resolveResourceIdentifier: ( + ctx: ToolContext, + identifier: string, + ) => Promise; + resolveHolidayPeriod: (input: { + year?: number; + periodStart?: string; + periodEnd?: string; + }) => { year: number | null; periodStart: Date; periodEnd: Date }; + resolveEntityOrAssistantError: ( + resolve: () => Promise, + notFoundMessage: string, + ) => Promise; + assertAdminRole: (ctx: ToolContext) => void; + fmtDate: (value: Date | null | undefined) => string | null; + formatHolidayCalendar: (calendar: HolidayCalendarRecord) => unknown; + formatHolidayCalendarEntry: (entry: HolidayCalendarEntryRecord) => unknown; + toAssistantHolidayCalendarMutationError: ( + error: unknown, + ) => AssistantToolErrorResult | null; + toAssistantHolidayCalendarNotFoundError: ( + error: unknown, + ) => AssistantToolErrorResult | null; + toAssistantHolidayEntryMutationError: ( + error: unknown, + ) => AssistantToolErrorResult | null; + toAssistantHolidayEntryNotFoundError: ( + error: unknown, + ) => AssistantToolErrorResult | null; +}; + +export const vacationHolidayReadToolDefinitions: ToolDef[] = [ + { + type: "function", + function: { + name: "get_vacation_balance", + description: "Get the holiday-aware vacation balance for a resource via the real entitlement workflow. Authenticated users can read their own balance; manager/admin/controller can read broader balances.", + parameters: { + type: "object", + properties: { + resourceId: { type: "string", description: "Resource ID, EID, or display name" }, + year: { type: "integer", description: "Year. Default: current year" }, + }, + required: ["resourceId"], + }, + }, + }, + { + type: "function", + function: { + name: "list_vacations_upcoming", + description: "List upcoming vacations across all resources, or for a specific resource/team. Shows who is off when.", + parameters: { + type: "object", + properties: { + resourceName: { type: "string", description: "Filter by resource name (partial match)" }, + chapter: { type: "string", description: "Filter by chapter/team" }, + daysAhead: { type: "integer", description: "How many days ahead to look. Default: 30" }, + limit: { type: "integer", description: "Max results. Default: 30" }, + }, + }, + }, + }, + { + type: "function", + function: { + name: "list_holidays_by_region", + description: "List resolved public holidays for a country, federal state, and optionally a city in a given year or date range. Use this to compare regions such as Bayern vs Hamburg.", + parameters: { + type: "object", + properties: { + countryCode: { type: "string", description: "Country code such as DE, ES, US, IN." }, + federalState: { type: "string", description: "Federal state / region code, e.g. BY, HH, NRW." }, + metroCity: { type: "string", description: "Optional city name for local city-specific holidays, e.g. Augsburg." }, + year: { type: "integer", description: "Full year, e.g. 2026. Default: current year." }, + periodStart: { type: "string", description: "Optional start date in YYYY-MM-DD. Requires periodEnd." }, + periodEnd: { type: "string", description: "Optional end date in YYYY-MM-DD. Requires periodStart." }, + }, + required: ["countryCode"], + }, + }, + }, + { + type: "function", + function: { + name: "get_resource_holidays", + description: "List resolved public holidays for a specific resource based on that person's country, federal state, and city context.", + parameters: { + type: "object", + properties: { + identifier: { type: "string", description: "Resource ID, EID, or display name." }, + year: { type: "integer", description: "Full year, e.g. 2026. Default: current year." }, + periodStart: { type: "string", description: "Optional start date in YYYY-MM-DD. Requires periodEnd." }, + periodEnd: { type: "string", description: "Optional end date in YYYY-MM-DD. Requires periodStart." }, + }, + required: ["identifier"], + }, + }, + }, + { + type: "function", + function: { + name: "list_holiday_calendars", + description: "List holiday calendars including scope, assignment, active flag, priority, and entry count. Useful to inspect the calendar-editor configuration context.", + parameters: { + type: "object", + properties: { + includeInactive: { type: "boolean", description: "Include inactive calendars. Default: false." }, + countryCode: { type: "string", description: "Optional country code filter such as DE or ES." }, + scopeType: { type: "string", description: "Optional scope filter: COUNTRY, STATE, CITY." }, + stateCode: { type: "string", description: "Optional state/region code filter such as BY or NRW." }, + metroCity: { type: "string", description: "Optional city-name filter." }, + }, + }, + }, + }, + { + type: "function", + function: { + name: "get_holiday_calendar", + description: "Get a single holiday calendar including all entries. Accepts either the calendar ID or its name.", + parameters: { + type: "object", + properties: { + identifier: { type: "string", description: "Holiday calendar ID or name." }, + }, + required: ["identifier"], + }, + }, + }, + { + type: "function", + function: { + name: "preview_resolved_holiday_calendar", + description: "Preview the resolved holiday result for a country/state/city scope and year, including which calendar each holiday comes from.", + parameters: { + type: "object", + properties: { + countryId: { type: "string", description: "Country ID." }, + stateCode: { type: "string", description: "Optional state/region code." }, + metroCityId: { type: "string", description: "Optional metro city ID for city-specific preview." }, + year: { type: "integer", description: "Full year, e.g. 2026." }, + }, + required: ["countryId", "year"], + }, + }, + }, +]; + +export const vacationHolidayMutationToolDefinitions: ToolDef[] = [ + { + type: "function", + function: { + name: "create_holiday_calendar", + description: "Create a holiday calendar for a country, state, or city scope. Admin role required. Always confirm first.", + parameters: { + type: "object", + properties: { + name: { type: "string", description: "Calendar name." }, + scopeType: { type: "string", description: "COUNTRY, STATE, or CITY." }, + countryId: { type: "string", description: "Country ID." }, + stateCode: { type: "string", description: "Required for STATE calendars." }, + metroCityId: { type: "string", description: "Required for CITY calendars." }, + isActive: { type: "boolean", description: "Whether the calendar is active. Default: true." }, + priority: { type: "integer", description: "Priority used during calendar resolution. Default: 0." }, + }, + required: ["name", "scopeType", "countryId"], + }, + }, + }, + { + type: "function", + function: { + name: "update_holiday_calendar", + description: "Update an existing holiday calendar. Admin role required. Always confirm first.", + parameters: { + type: "object", + properties: { + id: { type: "string", description: "Holiday calendar ID." }, + data: { + type: "object", + properties: { + name: { type: "string" }, + stateCode: { type: "string" }, + metroCityId: { type: "string" }, + isActive: { type: "boolean" }, + priority: { type: "integer" }, + }, + }, + }, + required: ["id", "data"], + }, + }, + }, + { + type: "function", + function: { + name: "delete_holiday_calendar", + description: "Delete a holiday calendar and all of its entries. Admin role required. Always confirm first.", + parameters: { + type: "object", + properties: { + id: { type: "string", description: "Holiday calendar ID." }, + }, + required: ["id"], + }, + }, + }, + { + type: "function", + function: { + name: "create_holiday_calendar_entry", + description: "Create a holiday entry in an existing calendar. Admin role required. Always confirm first.", + parameters: { + type: "object", + properties: { + holidayCalendarId: { type: "string", description: "Holiday calendar ID." }, + date: { type: "string", description: "Date in YYYY-MM-DD format." }, + name: { type: "string", description: "Holiday name." }, + isRecurringAnnual: { type: "boolean", description: "Whether the holiday repeats every year." }, + source: { type: "string", description: "Optional source or legal basis." }, + }, + required: ["holidayCalendarId", "date", "name"], + }, + }, + }, + { + type: "function", + function: { + name: "update_holiday_calendar_entry", + description: "Update an existing holiday calendar entry. Admin role required. Always confirm first.", + parameters: { + type: "object", + properties: { + id: { type: "string", description: "Holiday calendar entry ID." }, + data: { + type: "object", + properties: { + date: { type: "string", description: "Date in YYYY-MM-DD format." }, + name: { type: "string" }, + isRecurringAnnual: { type: "boolean" }, + source: { type: "string" }, + }, + }, + }, + required: ["id", "data"], + }, + }, + }, + { + type: "function", + function: { + name: "delete_holiday_calendar_entry", + description: "Delete a holiday calendar entry. Admin role required. Always confirm first.", + parameters: { + type: "object", + properties: { + id: { type: "string", description: "Holiday calendar entry ID." }, + }, + required: ["id"], + }, + }, + }, +]; + +export function createVacationHolidayExecutors( + deps: VacationHolidayDeps, +): Record { + return { + async get_vacation_balance(params: { resourceId: string; year?: number }, ctx: ToolContext) { + const year = params.year ?? new Date().getFullYear(); + const resource = await deps.resolveResourceIdentifier(ctx, params.resourceId); + if ("error" in resource) { + return resource; + } + + const caller = deps.createEntitlementCaller(deps.createScopedCallerContext(ctx)); + return caller.getBalanceDetail({ resourceId: resource.id, year }); + }, + + async list_vacations_upcoming(params: { + resourceName?: string; + chapter?: string; + daysAhead?: number; + limit?: number; + }, ctx: ToolContext) { + const daysAhead = params.daysAhead ?? 30; + const limit = Math.min(params.limit ?? 30, 50); + const caller = deps.createVacationCaller(deps.createScopedCallerContext(ctx)); + const now = new Date(); + const until = new Date(now.getTime() + daysAhead * 24 * 60 * 60 * 1000); + + const vacations = await caller.list({ + status: "APPROVED", + startDate: now, + endDate: until, + limit, + }); + + return vacations + .filter((vacation) => { + if (params.resourceName) { + const resourceName = vacation.resource.displayName.toLowerCase(); + if (!resourceName.includes(params.resourceName.toLowerCase())) { + return false; + } + } + if (params.chapter) { + const chapter = vacation.resource.chapter?.toLowerCase() ?? ""; + if (!chapter.includes(params.chapter.toLowerCase())) { + return false; + } + } + return true; + }) + .slice(0, limit) + .map((vacation) => ({ + resource: vacation.resource.displayName, + eid: vacation.resource.eid, + chapter: vacation.resource.chapter ?? null, + type: vacation.type, + start: deps.fmtDate(vacation.startDate), + end: deps.fmtDate(vacation.endDate), + isHalfDay: vacation.isHalfDay, + halfDayPart: vacation.halfDayPart, + })); + }, + + async list_holidays_by_region(params: { + countryCode: string; + federalState?: string; + metroCity?: string; + year?: number; + periodStart?: string; + periodEnd?: string; + }, ctx: ToolContext) { + const { year, periodStart, periodEnd } = deps.resolveHolidayPeriod(params); + const caller = deps.createHolidayCalendarCaller(deps.createScopedCallerContext(ctx)); + const resolved = await caller.resolveHolidaysDetail({ + periodStart, + periodEnd, + countryCode: params.countryCode.trim().toUpperCase(), + ...(params.federalState ? { stateCode: params.federalState } : {}), + ...(params.metroCity ? { metroCityName: params.metroCity } : {}), + }); + + return { + locationContext: resolved.locationContext, + year, + periodStart: resolved.periodStart, + periodEnd: resolved.periodEnd, + count: resolved.count, + summary: resolved.summary, + holidays: resolved.holidays, + }; + }, + + async get_resource_holidays(params: { + identifier: string; + year?: number; + periodStart?: string; + periodEnd?: string; + }, ctx: ToolContext) { + const resource = await deps.resolveResourceIdentifier(ctx, params.identifier); + if ("error" in resource) { + return resource; + } + + const { year, periodStart, periodEnd } = deps.resolveHolidayPeriod(params); + const caller = deps.createHolidayCalendarCaller(deps.createScopedCallerContext(ctx)); + const resolved = await caller.resolveResourceHolidaysDetail({ + resourceId: resource.id, + periodStart, + periodEnd, + }); + + return { + resource: resolved.resource, + year, + periodStart: resolved.periodStart, + periodEnd: resolved.periodEnd, + count: resolved.count, + summary: resolved.summary, + holidays: resolved.holidays, + }; + }, + + async list_holiday_calendars(params: { + includeInactive?: boolean; + countryCode?: string; + scopeType?: "COUNTRY" | "STATE" | "CITY"; + stateCode?: string; + metroCity?: string; + }, ctx: ToolContext) { + const caller = deps.createHolidayCalendarCaller(deps.createScopedCallerContext(ctx)); + return caller.listCalendarsDetail(params); + }, + + async get_holiday_calendar(params: { identifier: string }, ctx: ToolContext) { + const caller = deps.createHolidayCalendarCaller(deps.createScopedCallerContext(ctx)); + const identifier = params.identifier.trim(); + return deps.resolveEntityOrAssistantError( + () => caller.getCalendarByIdentifierDetail({ identifier }), + `Holiday calendar not found: ${identifier}`, + ); + }, + + async preview_resolved_holiday_calendar(params: { + countryId: string; + stateCode?: string; + metroCityId?: string; + year: number; + }, ctx: ToolContext) { + const input = PreviewResolvedHolidaysSchema.parse(params); + const caller = deps.createHolidayCalendarCaller(deps.createScopedCallerContext(ctx)); + return caller.previewResolvedHolidaysDetail(input); + }, + + async create_holiday_calendar(params: { + name: string; + scopeType: "COUNTRY" | "STATE" | "CITY"; + countryId: string; + stateCode?: string; + metroCityId?: string; + isActive?: boolean; + priority?: number; + }, ctx: ToolContext) { + deps.assertAdminRole(ctx); + const caller = deps.createHolidayCalendarCaller(deps.createScopedCallerContext(ctx)); + + let created; + try { + created = await caller.createCalendar(CreateHolidayCalendarSchema.parse(params)); + } catch (error) { + const mapped = deps.toAssistantHolidayCalendarMutationError(error); + if (mapped) { + return mapped; + } + throw error; + } + + return { + __action: "invalidate" as const, + scope: ["holidayCalendar", "vacation"], + success: true, + calendar: deps.formatHolidayCalendar(created), + message: `Created holiday calendar: ${created.name}`, + }; + }, + + async update_holiday_calendar(params: { + id: string; + data: { + name?: string; + stateCode?: string | null; + metroCityId?: string | null; + isActive?: boolean; + priority?: number; + }; + }, ctx: ToolContext) { + deps.assertAdminRole(ctx); + const caller = deps.createHolidayCalendarCaller(deps.createScopedCallerContext(ctx)); + const input = { + id: params.id, + data: UpdateHolidayCalendarSchema.parse(params.data), + }; + + let updated; + try { + updated = await caller.updateCalendar(input); + } catch (error) { + const mapped = deps.toAssistantHolidayCalendarMutationError(error); + if (mapped) { + return mapped; + } + throw error; + } + + return { + __action: "invalidate" as const, + scope: ["holidayCalendar", "vacation"], + success: true, + calendar: deps.formatHolidayCalendar(updated), + message: `Updated holiday calendar: ${updated.name}`, + }; + }, + + async delete_holiday_calendar(params: { id: string }, ctx: ToolContext) { + deps.assertAdminRole(ctx); + const caller = deps.createHolidayCalendarCaller(deps.createScopedCallerContext(ctx)); + + let deleted; + try { + deleted = await caller.deleteCalendar({ id: params.id }); + } catch (error) { + const mapped = deps.toAssistantHolidayCalendarNotFoundError(error); + if (mapped) { + return mapped; + } + throw error; + } + + return { + __action: "invalidate" as const, + scope: ["holidayCalendar", "vacation"], + success: true, + message: `Deleted holiday calendar: ${deleted.name}`, + }; + }, + + async create_holiday_calendar_entry(params: { + holidayCalendarId: string; + date: string; + name: string; + isRecurringAnnual?: boolean; + source?: string; + }, ctx: ToolContext) { + deps.assertAdminRole(ctx); + const caller = deps.createHolidayCalendarCaller(deps.createScopedCallerContext(ctx)); + + let created; + try { + created = await caller.createEntry(CreateHolidayCalendarEntrySchema.parse(params)); + } catch (error) { + const mapped = deps.toAssistantHolidayEntryMutationError(error); + if (mapped) { + return mapped; + } + throw error; + } + + return { + __action: "invalidate" as const, + scope: ["holidayCalendar", "vacation"], + success: true, + entry: deps.formatHolidayCalendarEntry(created), + message: `Created holiday entry: ${created.name}`, + }; + }, + + async update_holiday_calendar_entry(params: { + id: string; + data: { + date?: string; + name?: string; + isRecurringAnnual?: boolean; + source?: string | null; + }; + }, ctx: ToolContext) { + deps.assertAdminRole(ctx); + const caller = deps.createHolidayCalendarCaller(deps.createScopedCallerContext(ctx)); + const input = { + id: params.id, + data: UpdateHolidayCalendarEntrySchema.parse(params.data), + }; + + let updated; + try { + updated = await caller.updateEntry(input); + } catch (error) { + const mapped = deps.toAssistantHolidayEntryMutationError(error); + if (mapped) { + return mapped; + } + throw error; + } + + return { + __action: "invalidate" as const, + scope: ["holidayCalendar", "vacation"], + success: true, + entry: deps.formatHolidayCalendarEntry(updated), + message: `Updated holiday entry: ${updated.name}`, + }; + }, + + async delete_holiday_calendar_entry(params: { id: string }, ctx: ToolContext) { + deps.assertAdminRole(ctx); + const caller = deps.createHolidayCalendarCaller(deps.createScopedCallerContext(ctx)); + + let deleted; + try { + deleted = await caller.deleteEntry({ id: params.id }); + } catch (error) { + const mapped = deps.toAssistantHolidayEntryNotFoundError(error); + if (mapped) { + return mapped; + } + throw error; + } + + return { + __action: "invalidate" as const, + scope: ["holidayCalendar", "vacation"], + success: true, + message: `Deleted holiday entry: ${deleted.name}`, + }; + }, + }; +}