From e1496064e0e43d6774626e43944ce4dd8cbe867a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Mon, 30 Mar 2026 22:13:42 +0200 Subject: [PATCH] refactor(api): extract assistant resource slice --- docs/architecture-hardening-backlog.md | 3 +- packages/api/src/router/assistant-tools.ts | 297 +------------ .../src/router/assistant-tools/resources.ts | 405 ++++++++++++++++++ 3 files changed, 426 insertions(+), 279 deletions(-) create mode 100644 packages/api/src/router/assistant-tools/resources.ts diff --git a/docs/architecture-hardening-backlog.md b/docs/architecture-hardening-backlog.md index 68ce458..d70d5b1 100644 --- a/docs/architecture-hardening-backlog.md +++ b/docs/architecture-hardening-backlog.md @@ -37,12 +37,13 @@ - the estimate read and mutation helpers now live in their own domain module, keeping estimate lifecycle orchestration out of the monolithic assistant router without changing the assistant contract - the project search, lifecycle, and cover-art helpers now live in their own domain module, keeping project orchestration out of the monolithic assistant router without changing the assistant contract - the demand, staffing-suggestion, capacity, and resource-availability assistant helpers now live in their own domain module, keeping staffing orchestration out of the monolithic assistant router without changing the assistant contract +- the resource search, detail, and lifecycle assistant helpers now live in their own domain module, keeping resource CRUD orchestration out of the monolithic assistant 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 the remaining resource-management helpers or other tightly bound CRUD/read-model clusters still in the monolithic router. +The next clean slice should stay adjacent to the extracted domains and target one cohesive block such as blueprint/rate-card/reference-data reads or another tightly bound CRUD/read-model cluster still 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 ad0e2af..8f8072f 100644 --- a/packages/api/src/router/assistant-tools.ts +++ b/packages/api/src/router/assistant-tools.ts @@ -6,14 +6,12 @@ import { Prisma, ImportBatchStatus, StagedRecordStatus, DispoStagedRecordType, VacationType } from "@capakraken/db"; import { CreateAssignmentSchema, - CreateResourceSchema, AllocationStatus, EstimateStatus, type CommentEntityType, COMMENT_ENTITY_TYPE_VALUES, PermissionKey, SystemRole, - UpdateResourceSchema, } from "@capakraken/shared"; import type { WeekdayAvailability } from "@capakraken/shared"; import { TRPCError } from "@trpc/server"; @@ -118,6 +116,11 @@ import { staffingDemandMutationToolDefinitions, staffingDemandReadToolDefinitions, } from "./assistant-tools/staffing-demand.js"; +import { + createResourceExecutors, + resourceMutationToolDefinitions, + resourceReadToolDefinitions, +} from "./assistant-tools/resources.js"; import { withToolAccess, type ToolAccessRequirements, @@ -394,16 +397,12 @@ const MANAGER_ASSISTANT_ROLES = [ const ADMIN_ASSISTANT_ROLES = [SystemRole.ADMIN] as const; const LEGACY_MONOLITHIC_TOOL_ACCESS: Partial> = { - search_resources: { requiresResourceOverview: true }, search_projects: { allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES] }, get_project: { allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES] }, list_clients: { requiresPlanningRead: true }, list_org_units: { requiresResourceOverview: true }, - update_resource: { requiredPermissions: [PermissionKey.MANAGE_RESOURCES] }, update_project: { requiredPermissions: [PermissionKey.MANAGE_PROJECTS] }, create_project: { requiredPermissions: [PermissionKey.MANAGE_PROJECTS] }, - create_resource: { requiredPermissions: [PermissionKey.MANAGE_RESOURCES] }, - deactivate_resource: { requiredPermissions: [PermissionKey.MANAGE_RESOURCES] }, approve_vacation: { allowedSystemRoles: [...MANAGER_ASSISTANT_ROLES] }, reject_vacation: { allowedSystemRoles: [...MANAGER_ASSISTANT_ROLES] }, get_pending_vacation_approvals: { allowedSystemRoles: [...MANAGER_ASSISTANT_ROLES] }, @@ -2016,39 +2015,7 @@ function sanitizeWebhookList(webhooks: T[] export const TOOL_DEFINITIONS: ToolDef[] = withToolAccess([ // ── READ TOOLS ── - { - type: "function", - function: { - name: "search_resources", - description: "Search for resources (employees) by name, employee ID, chapter, country, metro city, org unit, or role. Resource overview access required. Returns a list of matching resources with key details.", - parameters: { - type: "object", - properties: { - query: { type: "string", description: "Search term (matches displayName, eid, chapter)" }, - country: { type: "string", description: "Filter by country name or code (e.g. 'Spain', 'ES', 'Deutschland', 'DE')" }, - metroCity: { type: "string", description: "Filter by metro city name (e.g. 'Madrid', 'München')" }, - orgUnit: { type: "string", description: "Filter by org unit name (partial match)" }, - roleName: { type: "string", description: "Filter by role name (partial match)" }, - isActive: { type: "boolean", description: "Filter by active status. Default: true" }, - limit: { type: "integer", description: "Max results. Default: 50" }, - }, - }, - }, - }, - { - type: "function", - function: { - name: "get_resource", - description: "Get detailed information about a single resource by ID, employee ID (eid), or name.", - parameters: { - type: "object", - properties: { - identifier: { type: "string", description: "Resource ID, employee ID (eid like EMP-001), or display name" }, - }, - required: ["identifier"], - }, - }, - }, + ...resourceReadToolDefinitions, ...projectReadToolDefinitions, ...advancedTimelineToolDefinitions, ...allocationPlanningReadToolDefinitions, @@ -2170,68 +2137,9 @@ export const TOOL_DEFINITIONS: ToolDef[] = withToolAccess([ // ── WRITE TOOLS ── ...allocationPlanningMutationToolDefinitions, - { - type: "function", - function: { - name: "update_resource", - description: "Update a resource's details. Requires manageResources permission. Always confirm with the user before calling this.", - parameters: { - type: "object", - properties: { - id: { type: "string", description: "Resource ID, EID, or display name" }, - displayName: { type: "string", description: "New display name" }, - fte: { type: "number", description: "New FTE (0.0-1.0)" }, - lcrCents: { type: "integer", description: "New LCR in cents (e.g. 8500 = 85.00 EUR/h)" }, - chapter: { type: "string", description: "New chapter" }, - chargeabilityTarget: { type: "number", description: "New chargeability target (0-100)" }, - }, - required: ["id"], - }, - }, - }, + ...resourceMutationToolDefinitions, ...projectMutationToolDefinitions, - // ── RESOURCE MANAGEMENT ── - { - type: "function", - function: { - name: "create_resource", - description: "Create a new resource (employee). Requires manageResources permission. Always confirm with the user before calling.", - parameters: { - type: "object", - properties: { - eid: { type: "string", description: "Employee ID (e.g. EMP-042)" }, - displayName: { type: "string", description: "Full name" }, - email: { type: "string", description: "Email address" }, - fte: { type: "number", description: "FTE 0.0-1.0. Default: 1" }, - lcrCents: { type: "integer", description: "Loaded cost rate in cents (e.g. 8500 = 85 EUR/h)" }, - ucrCents: { type: "integer", description: "Unloaded cost rate in cents" }, - chapter: { type: "string", description: "Chapter/team name" }, - chargeabilityTarget: { type: "number", description: "Chargeability target 0-100. Default: 80" }, - roleName: { type: "string", description: "Role name (partial match)" }, - countryCode: { type: "string", description: "Country code (e.g. DE, ES)" }, - orgUnitName: { type: "string", description: "Org unit name (partial match)" }, - postalCode: { type: "string", description: "Postal code" }, - }, - required: ["eid", "displayName", "email", "lcrCents"], - }, - }, - }, - { - type: "function", - function: { - name: "deactivate_resource", - description: "Deactivate a resource (soft delete). Requires manageResources permission. Always confirm first.", - parameters: { - type: "object", - properties: { - identifier: { type: "string", description: "Resource ID, eid, or name" }, - }, - required: ["identifier"], - }, - }, - }, - // ── VACATION MANAGEMENT ── { type: "function", @@ -3004,27 +2912,18 @@ async function resolveResponsiblePerson( // ─── Tool Executors ───────────────────────────────────────────────────────── const executors = { - async search_resources(params: { - query?: string; country?: string; metroCity?: string; - orgUnit?: string; roleName?: string; - isActive?: boolean; limit?: number; - }, ctx: ToolContext) { - const caller = createResourceCaller(createScopedCallerContext(ctx)); - return caller.listSummariesDetail({ - search: params.query, - country: params.country, - metroCity: params.metroCity, - orgUnit: params.orgUnit, - roleName: params.roleName, - isActive: params.isActive ?? true, - limit: Math.min(params.limit ?? 50, 100), - }); - }, - - async get_resource(params: { identifier: string }, ctx: ToolContext) { - const caller = createResourceCaller(createScopedCallerContext(ctx)); - return caller.getByIdentifierDetail({ identifier: params.identifier }); - }, + ...createResourceExecutors({ + assertPermission, + createResourceCaller, + createRoleCaller, + createCountryCaller, + createOrgUnitCaller, + createScopedCallerContext, + resolveResourceIdentifier, + resolveEntityOrAssistantError, + toAssistantResourceMutationError, + toAssistantResourceCreationError, + }), ...createProjectExecutors({ assertPermission, @@ -3265,164 +3164,6 @@ const executors = { }; }, - // ── WRITE TOOLS ── - - async update_resource(params: { - id: string; displayName?: string; fte?: number; - lcrCents?: number; chapter?: string; chargeabilityTarget?: number; - }, ctx: ToolContext) { - assertPermission(ctx, "manageResources" as PermissionKey); - const resource = await resolveResourceIdentifier(ctx, params.id); - if ("error" in resource) return resource; - - const caller = createResourceCaller(createScopedCallerContext(ctx)); - const data = UpdateResourceSchema.parse({ - ...(params.displayName !== undefined ? { displayName: params.displayName } : {}), - ...(params.fte !== undefined ? { fte: params.fte } : {}), - ...(params.lcrCents !== undefined ? { lcrCents: params.lcrCents } : {}), - ...(params.chapter !== undefined ? { chapter: params.chapter } : {}), - ...(params.chargeabilityTarget !== undefined ? { chargeabilityTarget: params.chargeabilityTarget } : {}), - }); - - const updatedFields = Object.keys(data); - if (updatedFields.length === 0) return { error: "No fields to update" }; - - let updated; - try { - updated = await caller.update({ id: resource.id, data }); - } catch (error) { - const mapped = toAssistantResourceMutationError(error); - if (mapped) { - return mapped; - } - throw error; - } - return { - __action: "invalidate", - scope: ["resource"], - success: true, - message: `Updated resource ${updated.displayName} (${updated.eid})`, - updatedFields, - }; - }, - - // ── RESOURCE MANAGEMENT ── - - async create_resource(params: { - eid: string; displayName: string; email?: string; - fte?: number; lcrCents: number; ucrCents?: number; - chapter?: string; chargeabilityTarget?: number; - roleName?: string; countryCode?: string; orgUnitName?: string; - postalCode?: string; - }, ctx: ToolContext) { - assertPermission(ctx, "manageResources" as PermissionKey); - if (!params.email?.trim()) { - return { error: "email is required to create a resource." }; - } - - const roleCaller = createRoleCaller(createScopedCallerContext(ctx)); - const countryCaller = createCountryCaller(createScopedCallerContext(ctx)); - const orgUnitCaller = createOrgUnitCaller(createScopedCallerContext(ctx)); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const data: Record = { - eid: params.eid, - displayName: params.displayName, - email: params.email, - lcrCents: params.lcrCents, - ucrCents: params.ucrCents ?? Math.round(params.lcrCents * 0.7), - chargeabilityTarget: params.chargeabilityTarget ?? 80, - currency: "EUR", - availability: { - monday: 8, - tuesday: 8, - wednesday: 8, - thursday: 8, - friday: 8, - }, - skills: [], - dynamicFields: {}, - ...(params.fte !== undefined ? { fte: params.fte } : {}), - }; - if (params.chapter) data.chapter = params.chapter; - if (params.postalCode) data.postalCode = params.postalCode; - - if (params.roleName) { - const role = await resolveEntityOrAssistantError( - () => roleCaller.resolveByIdentifier({ identifier: params.roleName! }), - `Role not found: "${params.roleName}"`, - ); - if ("error" in role) { - return role; - } - data.roleId = role.id; - } - if (params.countryCode) { - const country = await resolveEntityOrAssistantError( - () => countryCaller.resolveByIdentifier({ identifier: params.countryCode! }), - `Country not found: "${params.countryCode}"`, - ); - if ("error" in country) { - return country; - } - data.countryId = country.id; - } - if (params.orgUnitName) { - const orgUnit = await resolveEntityOrAssistantError( - () => orgUnitCaller.resolveByIdentifier({ identifier: params.orgUnitName! }), - `Org unit not found: "${params.orgUnitName}"`, - ); - if ("error" in orgUnit) { - return orgUnit; - } - data.orgUnitId = orgUnit.id; - } - - const input = CreateResourceSchema.parse(data); - const caller = createResourceCaller(createScopedCallerContext(ctx)); - let resource; - try { - resource = await caller.create(input); - } catch (error) { - const mapped = toAssistantResourceCreationError(error); - if (mapped) { - return mapped; - } - throw error; - } - - return { - __action: "invalidate", - scope: ["resource"], - success: true, - message: `Created resource: ${resource.displayName} (${resource.eid})`, - resourceId: resource.id, - }; - }, - - async deactivate_resource(params: { identifier: string }, ctx: ToolContext) { - assertPermission(ctx, "manageResources" as PermissionKey); - const resource = await resolveResourceIdentifier(ctx, params.identifier); - if ("error" in resource) return resource; - - const caller = createResourceCaller(createScopedCallerContext(ctx)); - try { - await caller.deactivate({ id: resource.id }); - } catch (error) { - const mapped = toAssistantResourceMutationError(error); - if (mapped) { - return mapped; - } - throw error; - } - return { - __action: "invalidate", - scope: ["resource"], - success: true, - message: `Deactivated resource: ${resource.displayName} (${resource.eid})`, - }; - }, - // ── VACATION MANAGEMENT ── async create_vacation(params: { diff --git a/packages/api/src/router/assistant-tools/resources.ts b/packages/api/src/router/assistant-tools/resources.ts new file mode 100644 index 0000000..d14d55b --- /dev/null +++ b/packages/api/src/router/assistant-tools/resources.ts @@ -0,0 +1,405 @@ +import { CreateResourceSchema, PermissionKey, UpdateResourceSchema } from "@capakraken/shared"; +import type { TRPCContext } from "../../trpc.js"; +import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js"; + +type AssistantToolErrorResult = { error: string }; + +type ResolvedResource = { + id: string; + eid: string; + displayName: string; +}; + +type ResolvedReference = { + id: string; +}; + +type ResourceRecord = { + id: string; + eid: string; + displayName: string; +}; + +type ParsedCreateResourceInput = ReturnType; +type ParsedUpdateResourceInput = ReturnType; + +type ResourceToolsDeps = { + assertPermission: (ctx: ToolContext, perm: PermissionKey) => void; + createResourceCaller: (ctx: TRPCContext) => { + listSummariesDetail: (params: { + search?: string; + country?: string; + metroCity?: string; + orgUnit?: string; + roleName?: string; + isActive?: boolean; + limit: number; + }) => Promise; + getByIdentifierDetail: (params: { identifier: string }) => Promise; + create: (params: ParsedCreateResourceInput) => Promise; + update: (params: { + id: string; + data: ParsedUpdateResourceInput; + }) => Promise; + deactivate: (params: { id: string }) => Promise; + }; + createRoleCaller: (ctx: TRPCContext) => { + resolveByIdentifier: (params: { identifier: string }) => Promise; + }; + createCountryCaller: (ctx: TRPCContext) => { + resolveByIdentifier: (params: { identifier: string }) => Promise; + }; + createOrgUnitCaller: (ctx: TRPCContext) => { + resolveByIdentifier: (params: { identifier: string }) => Promise; + }; + createScopedCallerContext: (ctx: ToolContext) => TRPCContext; + resolveResourceIdentifier: ( + ctx: ToolContext, + identifier: string, + ) => Promise; + resolveEntityOrAssistantError: ( + resolve: () => Promise, + notFoundMessage: string, + ) => Promise; + toAssistantResourceMutationError: ( + error: unknown, + ) => AssistantToolErrorResult | null; + toAssistantResourceCreationError: ( + error: unknown, + ) => AssistantToolErrorResult | null; +}; + +export const resourceReadToolDefinitions: ToolDef[] = withToolAccess([ + { + type: "function", + function: { + name: "search_resources", + description: "Search for resources (employees) by name, employee ID, chapter, country, metro city, org unit, or role. Resource overview access required. Returns a list of matching resources with key details.", + parameters: { + type: "object", + properties: { + query: { type: "string", description: "Search term (matches displayName, eid, chapter)" }, + country: { type: "string", description: "Filter by country name or code (e.g. 'Spain', 'ES', 'Deutschland', 'DE')" }, + metroCity: { type: "string", description: "Filter by metro city name (e.g. 'Madrid', 'München')" }, + orgUnit: { type: "string", description: "Filter by org unit name (partial match)" }, + roleName: { type: "string", description: "Filter by role name (partial match)" }, + isActive: { type: "boolean", description: "Filter by active status. Default: true" }, + limit: { type: "integer", description: "Max results. Default: 50" }, + }, + }, + }, + }, + { + type: "function", + function: { + name: "get_resource", + description: "Get detailed information about a single resource by ID, employee ID (eid), or name.", + parameters: { + type: "object", + properties: { + identifier: { type: "string", description: "Resource ID, employee ID (eid like EMP-001), or display name" }, + }, + required: ["identifier"], + }, + }, + }, +], { + search_resources: { + requiresResourceOverview: true, + }, +}); + +export const resourceMutationToolDefinitions: ToolDef[] = withToolAccess([ + { + type: "function", + function: { + name: "update_resource", + description: "Update a resource's details. Requires manageResources permission. Always confirm with the user before calling this.", + parameters: { + type: "object", + properties: { + id: { type: "string", description: "Resource ID, EID, or display name" }, + displayName: { type: "string", description: "New display name" }, + fte: { type: "number", description: "New FTE (0.0-1.0)" }, + lcrCents: { type: "integer", description: "New LCR in cents (e.g. 8500 = 85.00 EUR/h)" }, + chapter: { type: "string", description: "New chapter" }, + chargeabilityTarget: { type: "number", description: "New chargeability target (0-100)" }, + }, + required: ["id"], + }, + }, + }, + { + type: "function", + function: { + name: "create_resource", + description: "Create a new resource/employee. Requires manageResources permission. Always confirm with the user before calling this.", + parameters: { + type: "object", + properties: { + eid: { type: "string", description: "Employee ID (e.g. EMP-001)" }, + displayName: { type: "string", description: "Full name" }, + email: { type: "string", description: "Email address" }, + fte: { type: "number", description: "Full-time equivalent 0.0-1.0. Default: 1.0" }, + lcrCents: { type: "integer", description: "Labor cost rate in cents/hour (e.g. 8500 = 85.00 EUR/h)" }, + ucrCents: { type: "integer", description: "Utilization cost rate in cents/hour (optional; defaults to 70% of LCR)" }, + chapter: { type: "string", description: "Chapter/department" }, + chargeabilityTarget: { type: "number", description: "Target utilization percentage 0-100. Default: 80" }, + roleName: { type: "string", description: "Role name to assign (e.g. 'Designer')" }, + countryCode: { type: "string", description: "Country code or name (e.g. 'DE', 'Germany')" }, + orgUnitName: { type: "string", description: "Organizational unit name" }, + postalCode: { type: "string", description: "Postal code. If provided without federalState, state may be inferred." }, + }, + required: ["eid", "displayName", "lcrCents"], + }, + }, + }, + { + type: "function", + function: { + name: "deactivate_resource", + description: "Deactivate a resource (soft delete). Requires manageResources permission. Always confirm with the user before calling this.", + parameters: { + type: "object", + properties: { + identifier: { type: "string", description: "Resource ID, EID, or display name" }, + }, + required: ["identifier"], + }, + }, + }, +], { + update_resource: { + requiredPermissions: [PermissionKey.MANAGE_RESOURCES], + }, + create_resource: { + requiredPermissions: [PermissionKey.MANAGE_RESOURCES], + }, + deactivate_resource: { + requiredPermissions: [PermissionKey.MANAGE_RESOURCES], + }, +}); + +export function createResourceExecutors( + deps: ResourceToolsDeps, +): Record { + return { + async search_resources( + params: { + query?: string; + country?: string; + metroCity?: string; + orgUnit?: string; + roleName?: string; + isActive?: boolean; + limit?: number; + }, + ctx: ToolContext, + ) { + const caller = deps.createResourceCaller(deps.createScopedCallerContext(ctx)); + return caller.listSummariesDetail({ + ...(params.query !== undefined ? { search: params.query } : {}), + ...(params.country !== undefined ? { country: params.country } : {}), + ...(params.metroCity !== undefined ? { metroCity: params.metroCity } : {}), + ...(params.orgUnit !== undefined ? { orgUnit: params.orgUnit } : {}), + ...(params.roleName !== undefined ? { roleName: params.roleName } : {}), + isActive: params.isActive ?? true, + limit: Math.min(params.limit ?? 50, 100), + }); + }, + + async get_resource(params: { identifier: string }, ctx: ToolContext) { + const caller = deps.createResourceCaller(deps.createScopedCallerContext(ctx)); + return caller.getByIdentifierDetail({ identifier: params.identifier }); + }, + + async update_resource( + params: { + id: string; + displayName?: string; + fte?: number; + lcrCents?: number; + chapter?: string; + chargeabilityTarget?: number; + }, + ctx: ToolContext, + ) { + deps.assertPermission(ctx, PermissionKey.MANAGE_RESOURCES); + const resource = await deps.resolveResourceIdentifier(ctx, params.id); + if ("error" in resource) { + return resource; + } + + const caller = deps.createResourceCaller(deps.createScopedCallerContext(ctx)); + const data = UpdateResourceSchema.parse({ + ...(params.displayName !== undefined ? { displayName: params.displayName } : {}), + ...(params.fte !== undefined ? { fte: params.fte } : {}), + ...(params.lcrCents !== undefined ? { lcrCents: params.lcrCents } : {}), + ...(params.chapter !== undefined ? { chapter: params.chapter } : {}), + ...(params.chargeabilityTarget !== undefined + ? { chargeabilityTarget: params.chargeabilityTarget } + : {}), + }); + + const updatedFields = Object.keys(data); + if (updatedFields.length === 0) { + return { error: "No fields to update" }; + } + + let updated; + try { + updated = await caller.update({ id: resource.id, data }); + } catch (error) { + const mapped = deps.toAssistantResourceMutationError(error); + if (mapped) { + return mapped; + } + throw error; + } + + return { + __action: "invalidate", + scope: ["resource"], + success: true, + message: `Updated resource ${updated.displayName} (${updated.eid})`, + updatedFields, + }; + }, + + async create_resource( + params: { + eid: string; + displayName: string; + email?: string; + fte?: number; + lcrCents: number; + ucrCents?: number; + chapter?: string; + chargeabilityTarget?: number; + roleName?: string; + countryCode?: string; + orgUnitName?: string; + postalCode?: string; + }, + ctx: ToolContext, + ) { + deps.assertPermission(ctx, PermissionKey.MANAGE_RESOURCES); + if (!params.email?.trim()) { + return { error: "email is required to create a resource." }; + } + + const scopedContext = deps.createScopedCallerContext(ctx); + const roleCaller = deps.createRoleCaller(scopedContext); + const countryCaller = deps.createCountryCaller(scopedContext); + const orgUnitCaller = deps.createOrgUnitCaller(scopedContext); + + const data: Record = { + eid: params.eid, + displayName: params.displayName, + email: params.email, + lcrCents: params.lcrCents, + ucrCents: params.ucrCents ?? Math.round(params.lcrCents * 0.7), + chargeabilityTarget: params.chargeabilityTarget ?? 80, + currency: "EUR", + availability: { + monday: 8, + tuesday: 8, + wednesday: 8, + thursday: 8, + friday: 8, + }, + skills: [], + dynamicFields: {}, + ...(params.fte !== undefined ? { fte: params.fte } : {}), + ...(params.chapter ? { chapter: params.chapter } : {}), + ...(params.postalCode ? { postalCode: params.postalCode } : {}), + }; + + const roleIdentifier = params.roleName?.trim(); + if (roleIdentifier) { + const role = await deps.resolveEntityOrAssistantError( + () => roleCaller.resolveByIdentifier({ identifier: roleIdentifier }), + `Role not found: "${roleIdentifier}"`, + ); + if ("error" in role) { + return role; + } + data.roleId = role.id; + } + + const countryIdentifier = params.countryCode?.trim(); + if (countryIdentifier) { + const country = await deps.resolveEntityOrAssistantError( + () => countryCaller.resolveByIdentifier({ identifier: countryIdentifier }), + `Country not found: "${countryIdentifier}"`, + ); + if ("error" in country) { + return country; + } + data.countryId = country.id; + } + + const orgUnitIdentifier = params.orgUnitName?.trim(); + if (orgUnitIdentifier) { + const orgUnit = await deps.resolveEntityOrAssistantError( + () => orgUnitCaller.resolveByIdentifier({ identifier: orgUnitIdentifier }), + `Org unit not found: "${orgUnitIdentifier}"`, + ); + if ("error" in orgUnit) { + return orgUnit; + } + data.orgUnitId = orgUnit.id; + } + + const input = CreateResourceSchema.parse(data); + const caller = deps.createResourceCaller(scopedContext); + let resource; + try { + resource = await caller.create(input); + } catch (error) { + const mapped = deps.toAssistantResourceCreationError(error); + if (mapped) { + return mapped; + } + throw error; + } + + return { + __action: "invalidate", + scope: ["resource"], + success: true, + message: `Created resource: ${resource.displayName} (${resource.eid})`, + resourceId: resource.id, + }; + }, + + async deactivate_resource( + params: { identifier: string }, + ctx: ToolContext, + ) { + deps.assertPermission(ctx, PermissionKey.MANAGE_RESOURCES); + const resource = await deps.resolveResourceIdentifier(ctx, params.identifier); + if ("error" in resource) { + return resource; + } + + const caller = deps.createResourceCaller(deps.createScopedCallerContext(ctx)); + try { + await caller.deactivate({ id: resource.id }); + } catch (error) { + const mapped = deps.toAssistantResourceMutationError(error); + if (mapped) { + return mapped; + } + throw error; + } + + return { + __action: "invalidate", + scope: ["resource"], + success: true, + message: `Deactivated resource: ${resource.displayName} (${resource.eid})`, + }; + }, + }; +}