refactor(api): extract assistant resource slice
This commit is contained in:
@@ -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 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 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 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
|
## Next Up
|
||||||
|
|
||||||
Pin the next structural cleanup on the API side:
|
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.
|
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
|
## Remaining Major Themes
|
||||||
|
|
||||||
|
|||||||
@@ -6,14 +6,12 @@
|
|||||||
import { Prisma, ImportBatchStatus, StagedRecordStatus, DispoStagedRecordType, VacationType } from "@capakraken/db";
|
import { Prisma, ImportBatchStatus, StagedRecordStatus, DispoStagedRecordType, VacationType } from "@capakraken/db";
|
||||||
import {
|
import {
|
||||||
CreateAssignmentSchema,
|
CreateAssignmentSchema,
|
||||||
CreateResourceSchema,
|
|
||||||
AllocationStatus,
|
AllocationStatus,
|
||||||
EstimateStatus,
|
EstimateStatus,
|
||||||
type CommentEntityType,
|
type CommentEntityType,
|
||||||
COMMENT_ENTITY_TYPE_VALUES,
|
COMMENT_ENTITY_TYPE_VALUES,
|
||||||
PermissionKey,
|
PermissionKey,
|
||||||
SystemRole,
|
SystemRole,
|
||||||
UpdateResourceSchema,
|
|
||||||
} from "@capakraken/shared";
|
} from "@capakraken/shared";
|
||||||
import type { WeekdayAvailability } from "@capakraken/shared";
|
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
@@ -118,6 +116,11 @@ import {
|
|||||||
staffingDemandMutationToolDefinitions,
|
staffingDemandMutationToolDefinitions,
|
||||||
staffingDemandReadToolDefinitions,
|
staffingDemandReadToolDefinitions,
|
||||||
} from "./assistant-tools/staffing-demand.js";
|
} from "./assistant-tools/staffing-demand.js";
|
||||||
|
import {
|
||||||
|
createResourceExecutors,
|
||||||
|
resourceMutationToolDefinitions,
|
||||||
|
resourceReadToolDefinitions,
|
||||||
|
} from "./assistant-tools/resources.js";
|
||||||
import {
|
import {
|
||||||
withToolAccess,
|
withToolAccess,
|
||||||
type ToolAccessRequirements,
|
type ToolAccessRequirements,
|
||||||
@@ -394,16 +397,12 @@ const MANAGER_ASSISTANT_ROLES = [
|
|||||||
const ADMIN_ASSISTANT_ROLES = [SystemRole.ADMIN] as const;
|
const ADMIN_ASSISTANT_ROLES = [SystemRole.ADMIN] as const;
|
||||||
|
|
||||||
const LEGACY_MONOLITHIC_TOOL_ACCESS: Partial<Record<string, ToolAccessRequirements>> = {
|
const LEGACY_MONOLITHIC_TOOL_ACCESS: Partial<Record<string, ToolAccessRequirements>> = {
|
||||||
search_resources: { requiresResourceOverview: true },
|
|
||||||
search_projects: { allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES] },
|
search_projects: { allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES] },
|
||||||
get_project: { allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES] },
|
get_project: { allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES] },
|
||||||
list_clients: { requiresPlanningRead: true },
|
list_clients: { requiresPlanningRead: true },
|
||||||
list_org_units: { requiresResourceOverview: true },
|
list_org_units: { requiresResourceOverview: true },
|
||||||
update_resource: { requiredPermissions: [PermissionKey.MANAGE_RESOURCES] },
|
|
||||||
update_project: { requiredPermissions: [PermissionKey.MANAGE_PROJECTS] },
|
update_project: { requiredPermissions: [PermissionKey.MANAGE_PROJECTS] },
|
||||||
create_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] },
|
approve_vacation: { allowedSystemRoles: [...MANAGER_ASSISTANT_ROLES] },
|
||||||
reject_vacation: { allowedSystemRoles: [...MANAGER_ASSISTANT_ROLES] },
|
reject_vacation: { allowedSystemRoles: [...MANAGER_ASSISTANT_ROLES] },
|
||||||
get_pending_vacation_approvals: { allowedSystemRoles: [...MANAGER_ASSISTANT_ROLES] },
|
get_pending_vacation_approvals: { allowedSystemRoles: [...MANAGER_ASSISTANT_ROLES] },
|
||||||
@@ -2016,39 +2015,7 @@ function sanitizeWebhookList<T extends { secret?: string | null }>(webhooks: T[]
|
|||||||
|
|
||||||
export const TOOL_DEFINITIONS: ToolDef[] = withToolAccess([
|
export const TOOL_DEFINITIONS: ToolDef[] = withToolAccess([
|
||||||
// ── READ TOOLS ──
|
// ── READ TOOLS ──
|
||||||
{
|
...resourceReadToolDefinitions,
|
||||||
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"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...projectReadToolDefinitions,
|
...projectReadToolDefinitions,
|
||||||
...advancedTimelineToolDefinitions,
|
...advancedTimelineToolDefinitions,
|
||||||
...allocationPlanningReadToolDefinitions,
|
...allocationPlanningReadToolDefinitions,
|
||||||
@@ -2170,68 +2137,9 @@ export const TOOL_DEFINITIONS: ToolDef[] = withToolAccess([
|
|||||||
|
|
||||||
// ── WRITE TOOLS ──
|
// ── WRITE TOOLS ──
|
||||||
...allocationPlanningMutationToolDefinitions,
|
...allocationPlanningMutationToolDefinitions,
|
||||||
{
|
...resourceMutationToolDefinitions,
|
||||||
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"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...projectMutationToolDefinitions,
|
...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 ──
|
// ── VACATION MANAGEMENT ──
|
||||||
{
|
{
|
||||||
type: "function",
|
type: "function",
|
||||||
@@ -3004,27 +2912,18 @@ async function resolveResponsiblePerson(
|
|||||||
// ─── Tool Executors ─────────────────────────────────────────────────────────
|
// ─── Tool Executors ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const executors = {
|
const executors = {
|
||||||
async search_resources(params: {
|
...createResourceExecutors({
|
||||||
query?: string; country?: string; metroCity?: string;
|
assertPermission,
|
||||||
orgUnit?: string; roleName?: string;
|
createResourceCaller,
|
||||||
isActive?: boolean; limit?: number;
|
createRoleCaller,
|
||||||
}, ctx: ToolContext) {
|
createCountryCaller,
|
||||||
const caller = createResourceCaller(createScopedCallerContext(ctx));
|
createOrgUnitCaller,
|
||||||
return caller.listSummariesDetail({
|
createScopedCallerContext,
|
||||||
search: params.query,
|
resolveResourceIdentifier,
|
||||||
country: params.country,
|
resolveEntityOrAssistantError,
|
||||||
metroCity: params.metroCity,
|
toAssistantResourceMutationError,
|
||||||
orgUnit: params.orgUnit,
|
toAssistantResourceCreationError,
|
||||||
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 });
|
|
||||||
},
|
|
||||||
|
|
||||||
...createProjectExecutors({
|
...createProjectExecutors({
|
||||||
assertPermission,
|
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<string, any> = {
|
|
||||||
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 ──
|
// ── VACATION MANAGEMENT ──
|
||||||
|
|
||||||
async create_vacation(params: {
|
async create_vacation(params: {
|
||||||
|
|||||||
@@ -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<typeof CreateResourceSchema.parse>;
|
||||||
|
type ParsedUpdateResourceInput = ReturnType<typeof UpdateResourceSchema.parse>;
|
||||||
|
|
||||||
|
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<unknown>;
|
||||||
|
getByIdentifierDetail: (params: { identifier: string }) => Promise<unknown>;
|
||||||
|
create: (params: ParsedCreateResourceInput) => Promise<ResourceRecord>;
|
||||||
|
update: (params: {
|
||||||
|
id: string;
|
||||||
|
data: ParsedUpdateResourceInput;
|
||||||
|
}) => Promise<ResourceRecord>;
|
||||||
|
deactivate: (params: { id: string }) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
createRoleCaller: (ctx: TRPCContext) => {
|
||||||
|
resolveByIdentifier: (params: { identifier: string }) => Promise<ResolvedReference>;
|
||||||
|
};
|
||||||
|
createCountryCaller: (ctx: TRPCContext) => {
|
||||||
|
resolveByIdentifier: (params: { identifier: string }) => Promise<ResolvedReference>;
|
||||||
|
};
|
||||||
|
createOrgUnitCaller: (ctx: TRPCContext) => {
|
||||||
|
resolveByIdentifier: (params: { identifier: string }) => Promise<ResolvedReference>;
|
||||||
|
};
|
||||||
|
createScopedCallerContext: (ctx: ToolContext) => TRPCContext;
|
||||||
|
resolveResourceIdentifier: (
|
||||||
|
ctx: ToolContext,
|
||||||
|
identifier: string,
|
||||||
|
) => Promise<ResolvedResource | AssistantToolErrorResult>;
|
||||||
|
resolveEntityOrAssistantError: <T>(
|
||||||
|
resolve: () => Promise<T>,
|
||||||
|
notFoundMessage: string,
|
||||||
|
) => Promise<T | AssistantToolErrorResult>;
|
||||||
|
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<string, ToolExecutor> {
|
||||||
|
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<string, unknown> = {
|
||||||
|
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})`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user