refactor(api): extract assistant resource slice

This commit is contained in:
2026-03-30 22:13:42 +02:00
parent 279eb24e5a
commit e1496064e0
3 changed files with 426 additions and 279 deletions
+19 -278
View File
@@ -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<Record<string, ToolAccessRequirements>> = {
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<T extends { secret?: string | null }>(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<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 ──
async create_vacation(params: {