refactor(api): extract assistant resource slice
This commit is contained in:
@@ -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