From 9571d454d4dda5d3d385c7633798002d5fc5a848 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Mon, 30 Mar 2026 21:19:16 +0200 Subject: [PATCH] refactor(api): extract assistant chargeability and country slices --- docs/architecture-hardening-backlog.md | 4 +- packages/api/src/router/assistant-tools.ts | 383 ++---------------- .../chargeability-computation.ts | 201 +++++++++ .../assistant-tools/country-metro-admin.ts | 296 ++++++++++++++ 4 files changed, 526 insertions(+), 358 deletions(-) create mode 100644 packages/api/src/router/assistant-tools/chargeability-computation.ts create mode 100644 packages/api/src/router/assistant-tools/country-metro-admin.ts diff --git a/docs/architecture-hardening-backlog.md b/docs/architecture-hardening-backlog.md index 53a141c..621be7e 100644 --- a/docs/architecture-hardening-backlog.md +++ b/docs/architecture-hardening-backlog.md @@ -28,12 +28,14 @@ - 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 +- the adjacent chargeability/computation read helpers now live in their own domain module, keeping the advanced financial transparency read models out of the monolithic router without changing the assistant contract +- the neighboring country and metro-city admin mutations now live in their own domain module, keeping more settings-side CRUD wiring out of the monolithic router without changing the assistant contract ## Next Up Pin the next structural cleanup on the API side: 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. +The next clean slice should stay adjacent to the extracted domains and target one cohesive block such as management-level plus utilization-rule configuration reads, or the remaining estimate and project admin helper clusters 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 4b5b44a..3096074 100644 --- a/packages/api/src/router/assistant-tools.ts +++ b/packages/api/src/router/assistant-tools.ts @@ -6,11 +6,9 @@ import { Prisma, ImportBatchStatus, StagedRecordStatus, DispoStagedRecordType, VacationType } from "@capakraken/db"; import { CreateAssignmentSchema, - CreateCountrySchema, type CreateEstimateInput, CreateProjectSchema, CreateResourceSchema, - CreateMetroCitySchema, AllocationStatus, EstimateExportFormat, EstimateStatus, @@ -19,8 +17,6 @@ import { PermissionKey, SystemRole, type UpdateEstimateDraftInput, - UpdateCountrySchema, - UpdateMetroCitySchema, UpdateProjectSchema, UpdateResourceSchema, } from "@capakraken/shared"; @@ -87,6 +83,14 @@ import { createClientsOrgUnitsExecutors, orgUnitMutationToolDefinitions, } from "./assistant-tools/clients-org-units.js"; +import { + chargeabilityComputationReadToolDefinitions, + createChargeabilityComputationExecutors, +} from "./assistant-tools/chargeability-computation.js"; +import { + countryMetroAdminToolDefinitions, + createCountryMetroAdminExecutors, +} from "./assistant-tools/country-metro-admin.js"; import type { ToolContext, ToolDef, ToolExecutor } from "./assistant-tools/shared.js"; import { getCommentToolEntityDescription, getCommentToolScopeSentence } from "../lib/comment-entity-registry.js"; @@ -1971,60 +1975,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ ...vacationHolidayReadToolDefinitions, ...vacationHolidayMutationToolDefinitions, ...rolesAnalyticsReadToolDefinitions, - { - type: "function", - function: { - name: "get_chargeability_report", - description: "Get the detailed chargeability report readmodel for a month range, including group totals and per-resource month series. Requires controller/manager/admin access, viewCosts, and useAssistantAdvancedTools.", - parameters: { - type: "object", - properties: { - startMonth: { type: "string", description: "Start month in YYYY-MM format." }, - endMonth: { type: "string", description: "End month in YYYY-MM format." }, - orgUnitId: { type: "string", description: "Optional org unit filter." }, - managementLevelGroupId: { type: "string", description: "Optional management level group filter." }, - countryId: { type: "string", description: "Optional country filter." }, - includeProposed: { type: "boolean", description: "Whether proposed bookings should count towards chargeability. Default: false." }, - resourceQuery: { type: "string", description: "Optional resource filter by name or eid after loading the report." }, - resourceLimit: { type: "integer", description: "Maximum number of resources returned. Default: 25, max 100." }, - }, - required: ["startMonth", "endMonth"], - }, - }, - }, - { - type: "function", - function: { - name: "get_resource_computation_graph", - description: "Get the resource computation graph with transparent SAH, holiday, absence, allocation, chargeability, and budget derivation factors. Requires controller/manager/admin access, viewCosts, and useAssistantAdvancedTools.", - parameters: { - type: "object", - properties: { - resourceId: { type: "string", description: "Resource ID, eid, or display name." }, - month: { type: "string", description: "Month in YYYY-MM format." }, - domain: { type: "string", enum: ["INPUT", "SAH", "ALLOCATION", "RULES", "CHARGEABILITY", "BUDGET"], description: "Optional domain filter for graph nodes." }, - includeLinks: { type: "boolean", description: "Include graph links for the selected nodes. Default: false." }, - }, - required: ["resourceId", "month"], - }, - }, - }, - { - type: "function", - function: { - name: "get_project_computation_graph", - description: "Get the project computation graph with estimate, commercial, effort, experience, spread, and budget derivation factors. Requires controller/manager/admin access, viewCosts, and useAssistantAdvancedTools.", - parameters: { - type: "object", - properties: { - projectId: { type: "string", description: "Project ID, short code, or project name." }, - domain: { type: "string", enum: ["INPUT", "ESTIMATE", "COMMERCIAL", "EXPERIENCE", "EFFORT", "SPREAD", "BUDGET"], description: "Optional domain filter for graph nodes." }, - includeLinks: { type: "boolean", description: "Include graph links for the selected nodes. Default: false." }, - }, - required: ["projectId"], - }, - }, - }, + ...chargeabilityComputationReadToolDefinitions, { type: "function", function: { @@ -2824,99 +2775,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ }, }, }, - { - type: "function", - function: { - name: "create_country", - description: "Create a country with daily working hours and optional schedule rules. Admin role required. Always confirm first.", - parameters: { - type: "object", - properties: { - code: { type: "string", description: "ISO country code such as DE or ES." }, - name: { type: "string", description: "Country name." }, - dailyWorkingHours: { type: "number", description: "Standard daily working hours." }, - scheduleRules: { - type: "object", - description: "Optional schedule rule object such as the Spain reduced-hours configuration.", - }, - }, - required: ["code", "name"], - }, - }, - }, - { - type: "function", - function: { - name: "update_country", - description: "Update a country including working hours, schedule rules, or active state. Admin role required. Always confirm first.", - parameters: { - type: "object", - properties: { - id: { type: "string", description: "Country ID." }, - data: { - type: "object", - properties: { - code: { type: "string" }, - name: { type: "string" }, - dailyWorkingHours: { type: "number" }, - scheduleRules: { type: ["object", "null"] }, - isActive: { type: "boolean" }, - }, - }, - }, - required: ["id", "data"], - }, - }, - }, - { - type: "function", - function: { - name: "create_metro_city", - description: "Create a metro city for a country. Admin role required. Always confirm first.", - parameters: { - type: "object", - properties: { - countryId: { type: "string", description: "Country ID." }, - name: { type: "string", description: "Metro city name." }, - }, - required: ["countryId", "name"], - }, - }, - }, - { - type: "function", - function: { - name: "update_metro_city", - description: "Rename a metro city. Admin role required. Always confirm first.", - parameters: { - type: "object", - properties: { - id: { type: "string", description: "Metro city ID." }, - data: { - type: "object", - properties: { - name: { type: "string" }, - }, - }, - }, - required: ["id", "data"], - }, - }, - }, - { - type: "function", - function: { - name: "delete_metro_city", - description: "Delete a metro city when no resource is assigned to it. Admin role required. Always confirm first.", - parameters: { - type: "object", - properties: { - id: { type: "string", description: "Metro city ID." }, - }, - required: ["id"], - }, - }, - }, + ...countryMetroAdminToolDefinitions, { type: "function", function: { @@ -4254,76 +4113,14 @@ const executors = { toAssistantClientMutationError, toAssistantOrgUnitMutationError, }), - - async get_chargeability_report(params: { - startMonth: string; - endMonth: string; - orgUnitId?: string; - managementLevelGroupId?: string; - countryId?: string; - includeProposed?: boolean; - resourceQuery?: string; - resourceLimit?: number; - }, ctx: ToolContext) { - assertPermission(ctx, PermissionKey.VIEW_COSTS); - assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS); - - const caller = createChargeabilityReportCaller(createScopedCallerContext(ctx)); - return caller.getDetail({ - startMonth: params.startMonth, - endMonth: params.endMonth, - ...(params.orgUnitId ? { orgUnitId: params.orgUnitId } : {}), - ...(params.managementLevelGroupId ? { managementLevelGroupId: params.managementLevelGroupId } : {}), - ...(params.countryId ? { countryId: params.countryId } : {}), - includeProposed: params.includeProposed ?? false, - ...(params.resourceQuery ? { resourceQuery: params.resourceQuery } : {}), - ...(params.resourceLimit !== undefined ? { resourceLimit: params.resourceLimit } : {}), - }); - }, - - async get_resource_computation_graph(params: { - resourceId: string; - month: string; - domain?: string; - includeLinks?: boolean; - }, ctx: ToolContext) { - assertPermission(ctx, PermissionKey.VIEW_COSTS); - assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS); - - const resource = await resolveResourceIdentifier(ctx, params.resourceId); - if ("error" in resource) { - return resource; - } - - const caller = createComputationGraphCaller(createScopedCallerContext(ctx)); - return caller.getResourceDataDetail({ - resourceId: resource.id, - month: params.month, - ...(params.domain ? { domain: params.domain } : {}), - ...(params.includeLinks !== undefined ? { includeLinks: params.includeLinks } : {}), - }); - }, - - async get_project_computation_graph(params: { - projectId: string; - domain?: string; - includeLinks?: boolean; - }, ctx: ToolContext) { - assertPermission(ctx, PermissionKey.VIEW_COSTS); - assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS); - - const project = await resolveProjectIdentifier(ctx, params.projectId); - if ("error" in project) { - return project; - } - - const caller = createComputationGraphCaller(createScopedCallerContext(ctx)); - return caller.getProjectDataDetail({ - projectId: project.id, - ...(params.domain ? { domain: params.domain } : {}), - ...(params.includeLinks !== undefined ? { includeLinks: params.includeLinks } : {}), - }); - }, + ...createChargeabilityComputationExecutors({ + assertPermission, + createChargeabilityReportCaller, + createComputationGraphCaller, + createScopedCallerContext, + resolveResourceIdentifier, + resolveProjectIdentifier, + }), async search_estimates(params: { projectCode?: string; query?: string; status?: string; limit?: number; @@ -5698,142 +5495,14 @@ const executors = { } return formatCountry(country); }, - - async create_country(params: { - code: string; - name: string; - dailyWorkingHours?: number; - scheduleRules?: Prisma.JsonValue | null; - }, ctx: ToolContext) { - assertAdminRole(ctx); - const caller = createCountryCaller(createScopedCallerContext(ctx)); - let created; - try { - created = await caller.create(CreateCountrySchema.parse(params)); - } catch (error) { - const mapped = toAssistantCountryMutationError(error); - if (mapped) { - return mapped; - } - throw error; - } - - return { - __action: "invalidate", - scope: ["country", "resource", "holidayCalendar", "vacation"], - success: true, - country: formatCountry(created), - message: `Created country: ${created.name}`, - }; - }, - - async update_country(params: { - id: string; - data: { - code?: string; - name?: string; - dailyWorkingHours?: number; - scheduleRules?: Prisma.JsonValue | null; - isActive?: boolean; - }; - }, ctx: ToolContext) { - assertAdminRole(ctx); - const caller = createCountryCaller(createScopedCallerContext(ctx)); - const input = { - id: params.id, - data: UpdateCountrySchema.parse(params.data), - }; - let updated; - try { - updated = await caller.update(input); - } catch (error) { - const mapped = toAssistantCountryMutationError(error); - if (mapped) { - return mapped; - } - throw error; - } - - return { - __action: "invalidate", - scope: ["country", "resource", "holidayCalendar", "vacation"], - success: true, - country: formatCountry(updated), - message: `Updated country: ${updated.name}`, - }; - }, - - async create_metro_city(params: { countryId: string; name: string }, ctx: ToolContext) { - assertAdminRole(ctx); - const caller = createCountryCaller(createScopedCallerContext(ctx)); - let created; - try { - created = await caller.createCity(CreateMetroCitySchema.parse(params)); - } catch (error) { - const mapped = toAssistantMetroCityMutationError(error); - if (mapped) { - return mapped; - } - throw error; - } - - return { - __action: "invalidate", - scope: ["country", "resource", "holidayCalendar", "vacation"], - success: true, - metroCity: created, - message: `Created metro city: ${created.name}`, - }; - }, - - async update_metro_city(params: { id: string; data: { name?: string } }, ctx: ToolContext) { - assertAdminRole(ctx); - const caller = createCountryCaller(createScopedCallerContext(ctx)); - const input = { - id: params.id, - data: UpdateMetroCitySchema.parse(params.data), - }; - let updated; - try { - updated = await caller.updateCity(input); - } catch (error) { - const mapped = toAssistantMetroCityMutationError(error); - if (mapped) { - return mapped; - } - throw error; - } - - return { - __action: "invalidate", - scope: ["country", "resource", "holidayCalendar", "vacation"], - success: true, - metroCity: updated, - message: `Updated metro city: ${updated.name}`, - }; - }, - - async delete_metro_city(params: { id: string }, ctx: ToolContext) { - assertAdminRole(ctx); - const caller = createCountryCaller(createScopedCallerContext(ctx)); - let deleted; - try { - deleted = await caller.deleteCity({ id: params.id }); - } catch (error) { - const mapped = toAssistantMetroCityMutationError(error); - if (mapped) { - return mapped; - } - throw error; - } - - return { - __action: "invalidate", - scope: ["country", "resource", "holidayCalendar", "vacation"], - success: true, - message: `Deleted metro city: ${deleted.name ?? params.id}`, - }; - }, + ...createCountryMetroAdminExecutors({ + createCountryCaller, + createScopedCallerContext, + assertAdminRole, + formatCountry, + toAssistantCountryMutationError, + toAssistantMetroCityMutationError, + }), async list_management_levels(_params: Record, ctx: ToolContext) { const caller = createManagementLevelCaller(createScopedCallerContext(ctx)); diff --git a/packages/api/src/router/assistant-tools/chargeability-computation.ts b/packages/api/src/router/assistant-tools/chargeability-computation.ts new file mode 100644 index 0000000..6a71a74 --- /dev/null +++ b/packages/api/src/router/assistant-tools/chargeability-computation.ts @@ -0,0 +1,201 @@ +import type { TRPCContext } from "../../trpc.js"; +import { PermissionKey } from "@capakraken/shared"; +import type { ToolContext, ToolDef, ToolExecutor } from "./shared.js"; + +type AssistantToolErrorResult = { error: string }; + +type ResolvedResource = { + id: string; +}; + +type ResolvedProject = { + id: string; +}; + +type ResourceComputationDomain = + | "INPUT" + | "SAH" + | "ALLOCATION" + | "RULES" + | "CHARGEABILITY" + | "BUDGET"; + +type ProjectComputationDomain = + | "INPUT" + | "ESTIMATE" + | "COMMERCIAL" + | "EXPERIENCE" + | "EFFORT" + | "SPREAD" + | "BUDGET"; + +export type ChargeabilityComputationDeps = { + assertPermission: (ctx: ToolContext, perm: PermissionKey) => void; + createChargeabilityReportCaller: (ctx: TRPCContext) => { + getDetail: (params: { + startMonth: string; + endMonth: string; + orgUnitId?: string; + managementLevelGroupId?: string; + countryId?: string; + includeProposed: boolean; + resourceQuery?: string; + resourceLimit?: number; + }) => Promise; + }; + createComputationGraphCaller: (ctx: TRPCContext) => { + getResourceDataDetail: (params: { + resourceId: string; + month: string; + domain?: string; + includeLinks?: boolean; + }) => Promise; + getProjectDataDetail: (params: { + projectId: string; + domain?: string; + includeLinks?: boolean; + }) => Promise; + }; + createScopedCallerContext: (ctx: ToolContext) => TRPCContext; + resolveResourceIdentifier: ( + ctx: ToolContext, + identifier: string, + ) => Promise; + resolveProjectIdentifier: ( + ctx: ToolContext, + identifier: string, + ) => Promise; +}; + +export const chargeabilityComputationReadToolDefinitions: ToolDef[] = [ + { + type: "function", + function: { + name: "get_chargeability_report", + description: "Get the detailed chargeability report readmodel for a month range, including group totals and per-resource month series. Requires controller/manager/admin access, viewCosts, and useAssistantAdvancedTools.", + parameters: { + type: "object", + properties: { + startMonth: { type: "string", description: "Start month in YYYY-MM format." }, + endMonth: { type: "string", description: "End month in YYYY-MM format." }, + orgUnitId: { type: "string", description: "Optional org unit filter." }, + managementLevelGroupId: { type: "string", description: "Optional management level group filter." }, + countryId: { type: "string", description: "Optional country filter." }, + includeProposed: { type: "boolean", description: "Whether proposed bookings should count towards chargeability. Default: false." }, + resourceQuery: { type: "string", description: "Optional resource filter by name or eid after loading the report." }, + resourceLimit: { type: "integer", description: "Maximum number of resources returned. Default: 25, max 100." }, + }, + required: ["startMonth", "endMonth"], + }, + }, + }, + { + type: "function", + function: { + name: "get_resource_computation_graph", + description: "Get the resource computation graph with transparent SAH, holiday, absence, allocation, chargeability, and budget derivation factors. Requires controller/manager/admin access, viewCosts, and useAssistantAdvancedTools.", + parameters: { + type: "object", + properties: { + resourceId: { type: "string", description: "Resource ID, eid, or display name." }, + month: { type: "string", description: "Month in YYYY-MM format." }, + domain: { type: "string", enum: ["INPUT", "SAH", "ALLOCATION", "RULES", "CHARGEABILITY", "BUDGET"], description: "Optional domain filter for graph nodes." }, + includeLinks: { type: "boolean", description: "Include graph links for the selected nodes. Default: false." }, + }, + required: ["resourceId", "month"], + }, + }, + }, + { + type: "function", + function: { + name: "get_project_computation_graph", + description: "Get the project computation graph with estimate, commercial, effort, experience, spread, and budget derivation factors. Requires controller/manager/admin access, viewCosts, and useAssistantAdvancedTools.", + parameters: { + type: "object", + properties: { + projectId: { type: "string", description: "Project ID, short code, or project name." }, + domain: { type: "string", enum: ["INPUT", "ESTIMATE", "COMMERCIAL", "EXPERIENCE", "EFFORT", "SPREAD", "BUDGET"], description: "Optional domain filter for graph nodes." }, + includeLinks: { type: "boolean", description: "Include graph links for the selected nodes. Default: false." }, + }, + required: ["projectId"], + }, + }, + }, +]; + +export function createChargeabilityComputationExecutors( + deps: ChargeabilityComputationDeps, +): Record { + return { + async get_chargeability_report(params: { + startMonth: string; + endMonth: string; + orgUnitId?: string; + managementLevelGroupId?: string; + countryId?: string; + includeProposed?: boolean; + resourceQuery?: string; + resourceLimit?: number; + }, ctx: ToolContext) { + deps.assertPermission(ctx, PermissionKey.VIEW_COSTS); + deps.assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS); + + const caller = deps.createChargeabilityReportCaller(deps.createScopedCallerContext(ctx)); + return caller.getDetail({ + startMonth: params.startMonth, + endMonth: params.endMonth, + ...(params.orgUnitId ? { orgUnitId: params.orgUnitId } : {}), + ...(params.managementLevelGroupId ? { managementLevelGroupId: params.managementLevelGroupId } : {}), + ...(params.countryId ? { countryId: params.countryId } : {}), + includeProposed: params.includeProposed ?? false, + ...(params.resourceQuery ? { resourceQuery: params.resourceQuery } : {}), + ...(params.resourceLimit !== undefined ? { resourceLimit: params.resourceLimit } : {}), + }); + }, + + async get_resource_computation_graph(params: { + resourceId: string; + month: string; + domain?: ResourceComputationDomain; + includeLinks?: boolean; + }, ctx: ToolContext) { + deps.assertPermission(ctx, PermissionKey.VIEW_COSTS); + deps.assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS); + + const resource = await deps.resolveResourceIdentifier(ctx, params.resourceId); + if ("error" in resource) { + return resource; + } + + const caller = deps.createComputationGraphCaller(deps.createScopedCallerContext(ctx)); + return caller.getResourceDataDetail({ + resourceId: resource.id, + month: params.month, + ...(params.domain ? { domain: params.domain } : {}), + ...(params.includeLinks !== undefined ? { includeLinks: params.includeLinks } : {}), + }); + }, + + async get_project_computation_graph(params: { + projectId: string; + domain?: ProjectComputationDomain; + includeLinks?: boolean; + }, ctx: ToolContext) { + deps.assertPermission(ctx, PermissionKey.VIEW_COSTS); + deps.assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS); + + const project = await deps.resolveProjectIdentifier(ctx, params.projectId); + if ("error" in project) { + return project; + } + + const caller = deps.createComputationGraphCaller(deps.createScopedCallerContext(ctx)); + return caller.getProjectDataDetail({ + projectId: project.id, + ...(params.domain ? { domain: params.domain } : {}), + ...(params.includeLinks !== undefined ? { includeLinks: params.includeLinks } : {}), + }); + }, + }; +} diff --git a/packages/api/src/router/assistant-tools/country-metro-admin.ts b/packages/api/src/router/assistant-tools/country-metro-admin.ts new file mode 100644 index 0000000..1f62444 --- /dev/null +++ b/packages/api/src/router/assistant-tools/country-metro-admin.ts @@ -0,0 +1,296 @@ +import type { Prisma } from "@capakraken/db"; +import type { TRPCContext } from "../../trpc.js"; +import { + CreateCountrySchema, + CreateMetroCitySchema, + UpdateCountrySchema, + UpdateMetroCitySchema, +} from "@capakraken/shared"; +import { z } from "zod"; +import type { ToolContext, ToolDef, ToolExecutor } from "./shared.js"; + +type AssistantToolErrorResult = { error: string }; + +type CountryRecord = { + id: string; + code: string; + name: string; + dailyWorkingHours: number; + scheduleRules?: Prisma.JsonValue | null; + isActive?: boolean | null; + metroCities?: Array<{ id: string; name: string }> | null; + _count?: { resources?: number | null } | null; +}; + +type MetroCityRecord = { + id: string; + name: string; +}; + +type CountryMetroAdminDeps = { + createCountryCaller: (ctx: TRPCContext) => { + create: (params: z.input) => Promise; + update: (params: { + id: string; + data: z.input; + }) => Promise; + createCity: (params: z.input) => Promise; + updateCity: (params: { + id: string; + data: z.input; + }) => Promise; + deleteCity: (params: { id: string }) => Promise<{ name?: string | null }>; + }; + createScopedCallerContext: (ctx: ToolContext) => TRPCContext; + assertAdminRole: (ctx: ToolContext) => void; + formatCountry: (country: CountryRecord) => unknown; + toAssistantCountryMutationError: ( + error: unknown, + ) => AssistantToolErrorResult | null; + toAssistantMetroCityMutationError: ( + error: unknown, + ) => AssistantToolErrorResult | null; +}; + +export const countryMetroAdminToolDefinitions: ToolDef[] = [ + { + type: "function", + function: { + name: "create_country", + description: "Create a country with daily working hours and optional schedule rules. Admin role required. Always confirm first.", + parameters: { + type: "object", + properties: { + code: { type: "string", description: "ISO country code such as DE or ES." }, + name: { type: "string", description: "Country name." }, + dailyWorkingHours: { type: "number", description: "Standard daily working hours." }, + scheduleRules: { + type: "object", + description: "Optional schedule rule object such as the Spain reduced-hours configuration.", + }, + }, + required: ["code", "name"], + }, + }, + }, + { + type: "function", + function: { + name: "update_country", + description: "Update a country including working hours, schedule rules, or active state. Admin role required. Always confirm first.", + parameters: { + type: "object", + properties: { + id: { type: "string", description: "Country ID." }, + data: { + type: "object", + properties: { + code: { type: "string" }, + name: { type: "string" }, + dailyWorkingHours: { type: "number" }, + scheduleRules: { type: ["object", "null"] }, + isActive: { type: "boolean" }, + }, + }, + }, + required: ["id", "data"], + }, + }, + }, + { + type: "function", + function: { + name: "create_metro_city", + description: "Create a metro city for a country. Admin role required. Always confirm first.", + parameters: { + type: "object", + properties: { + countryId: { type: "string", description: "Country ID." }, + name: { type: "string", description: "Metro city name." }, + }, + required: ["countryId", "name"], + }, + }, + }, + { + type: "function", + function: { + name: "update_metro_city", + description: "Rename a metro city. Admin role required. Always confirm first.", + parameters: { + type: "object", + properties: { + id: { type: "string", description: "Metro city ID." }, + data: { + type: "object", + properties: { + name: { type: "string" }, + }, + }, + }, + required: ["id", "data"], + }, + }, + }, + { + type: "function", + function: { + name: "delete_metro_city", + description: "Delete a metro city when no resource is assigned to it. Admin role required. Always confirm first.", + parameters: { + type: "object", + properties: { + id: { type: "string", description: "Metro city ID." }, + }, + required: ["id"], + }, + }, + }, +]; + +export function createCountryMetroAdminExecutors( + deps: CountryMetroAdminDeps, +): Record { + return { + async create_country(params: { + code: string; + name: string; + dailyWorkingHours?: number; + scheduleRules?: Prisma.JsonValue | null; + }, ctx: ToolContext) { + deps.assertAdminRole(ctx); + const caller = deps.createCountryCaller(deps.createScopedCallerContext(ctx)); + + let created; + try { + created = await caller.create(CreateCountrySchema.parse(params)); + } catch (error) { + const mapped = deps.toAssistantCountryMutationError(error); + if (mapped) { + return mapped; + } + throw error; + } + + return { + __action: "invalidate" as const, + scope: ["country", "resource", "holidayCalendar", "vacation"], + success: true, + country: deps.formatCountry(created), + message: `Created country: ${created.name}`, + }; + }, + + async update_country(params: { + id: string; + data: { + code?: string; + name?: string; + dailyWorkingHours?: number; + scheduleRules?: Prisma.JsonValue | null; + isActive?: boolean; + }; + }, ctx: ToolContext) { + deps.assertAdminRole(ctx); + const caller = deps.createCountryCaller(deps.createScopedCallerContext(ctx)); + const input = { + id: params.id, + data: UpdateCountrySchema.parse(params.data), + }; + + let updated; + try { + updated = await caller.update(input); + } catch (error) { + const mapped = deps.toAssistantCountryMutationError(error); + if (mapped) { + return mapped; + } + throw error; + } + + return { + __action: "invalidate" as const, + scope: ["country", "resource", "holidayCalendar", "vacation"], + success: true, + country: deps.formatCountry(updated), + message: `Updated country: ${updated.name}`, + }; + }, + + async create_metro_city(params: { countryId: string; name: string }, ctx: ToolContext) { + deps.assertAdminRole(ctx); + const caller = deps.createCountryCaller(deps.createScopedCallerContext(ctx)); + + let created; + try { + created = await caller.createCity(CreateMetroCitySchema.parse(params)); + } catch (error) { + const mapped = deps.toAssistantMetroCityMutationError(error); + if (mapped) { + return mapped; + } + throw error; + } + + return { + __action: "invalidate" as const, + scope: ["country", "resource", "holidayCalendar", "vacation"], + success: true, + metroCity: created, + message: `Created metro city: ${created.name}`, + }; + }, + + async update_metro_city(params: { id: string; data: { name?: string } }, ctx: ToolContext) { + deps.assertAdminRole(ctx); + const caller = deps.createCountryCaller(deps.createScopedCallerContext(ctx)); + const input = { + id: params.id, + data: UpdateMetroCitySchema.parse(params.data), + }; + + let updated; + try { + updated = await caller.updateCity(input); + } catch (error) { + const mapped = deps.toAssistantMetroCityMutationError(error); + if (mapped) { + return mapped; + } + throw error; + } + + return { + __action: "invalidate" as const, + scope: ["country", "resource", "holidayCalendar", "vacation"], + success: true, + metroCity: updated, + message: `Updated metro city: ${updated.name}`, + }; + }, + + async delete_metro_city(params: { id: string }, ctx: ToolContext) { + deps.assertAdminRole(ctx); + const caller = deps.createCountryCaller(deps.createScopedCallerContext(ctx)); + + let deleted; + try { + deleted = await caller.deleteCity({ id: params.id }); + } catch (error) { + const mapped = deps.toAssistantMetroCityMutationError(error); + if (mapped) { + return mapped; + } + throw error; + } + + return { + __action: "invalidate" as const, + scope: ["country", "resource", "holidayCalendar", "vacation"], + success: true, + message: `Deleted metro city: ${deleted.name ?? params.id}`, + }; + }, + }; +}