9692 lines
332 KiB
TypeScript
9692 lines
332 KiB
TypeScript
/**
|
||
* AI Assistant Tool definitions for OpenAI Function Calling.
|
||
* Each tool has a JSON schema (for the AI) and an execute function (for the server).
|
||
*/
|
||
|
||
import { prisma, Prisma, ImportBatchStatus, StagedRecordStatus, DispoStagedRecordType, VacationType } from "@capakraken/db";
|
||
import {
|
||
CreateAssignmentSchema,
|
||
CreateClientSchema,
|
||
CreateCountrySchema,
|
||
type CreateEstimateInput,
|
||
CreateHolidayCalendarEntrySchema,
|
||
CreateHolidayCalendarSchema,
|
||
CreateProjectSchema,
|
||
CreateResourceSchema,
|
||
CreateMetroCitySchema,
|
||
CreateOrgUnitSchema,
|
||
CreateRoleSchema,
|
||
AllocationStatus,
|
||
EstimateExportFormat,
|
||
EstimateStatus,
|
||
PermissionKey,
|
||
PreviewResolvedHolidaysSchema,
|
||
SystemRole,
|
||
UpdateClientSchema,
|
||
type UpdateEstimateDraftInput,
|
||
UpdateCountrySchema,
|
||
UpdateHolidayCalendarEntrySchema,
|
||
UpdateHolidayCalendarSchema,
|
||
UpdateAssignmentSchema,
|
||
UpdateMetroCitySchema,
|
||
UpdateOrgUnitSchema,
|
||
UpdateProjectSchema,
|
||
UpdateRoleSchema,
|
||
UpdateResourceSchema,
|
||
} from "@capakraken/shared";
|
||
import type { WeekdayAvailability } from "@capakraken/shared";
|
||
import { TRPCError } from "@trpc/server";
|
||
import { ZodError } from "zod";
|
||
import { fmtEur } from "../lib/format-utils.js";
|
||
import { isAiConfigured } from "../ai-client.js";
|
||
import { timelineRouter } from "./timeline.js";
|
||
import { logger } from "../lib/logger.js";
|
||
import { createCallerFactory, type TRPCContext } from "../trpc.js";
|
||
import { auditLogRouter } from "./audit-log.js";
|
||
import { chargeabilityReportRouter } from "./chargeability-report.js";
|
||
import { computationGraphRouter } from "./computation-graph.js";
|
||
import { dispoRouter } from "./dispo.js";
|
||
import { importExportRouter } from "./import-export.js";
|
||
import { resourceRouter } from "./resource.js";
|
||
import { settingsRouter } from "./settings.js";
|
||
import { systemRoleConfigRouter } from "./system-role-config.js";
|
||
import { userRouter } from "./user.js";
|
||
import { notificationRouter } from "./notification.js";
|
||
import { estimateRouter } from "./estimate.js";
|
||
import { webhookRouter } from "./webhook.js";
|
||
import { countryRouter } from "./country.js";
|
||
import { holidayCalendarRouter } from "./holiday-calendar.js";
|
||
import { blueprintRouter } from "./blueprint.js";
|
||
import { roleRouter } from "./role.js";
|
||
import { clientRouter } from "./client.js";
|
||
import { orgUnitRouter } from "./org-unit.js";
|
||
import { projectRouter } from "./project.js";
|
||
import { rateCardRouter } from "./rate-card.js";
|
||
import { reportRouter } from "./report.js";
|
||
import { vacationRouter } from "./vacation.js";
|
||
import { entitlementRouter } from "./entitlement.js";
|
||
import { commentRouter } from "./comment.js";
|
||
import { managementLevelRouter } from "./management-level.js";
|
||
import { utilizationCategoryRouter } from "./utilization-category.js";
|
||
import { calculationRuleRouter } from "./calculation-rules.js";
|
||
import { effortRuleRouter } from "./effort-rule.js";
|
||
import { experienceMultiplierRouter } from "./experience-multiplier.js";
|
||
import { dashboardRouter } from "./dashboard.js";
|
||
import { insightsRouter } from "./insights.js";
|
||
import { scenarioRouter } from "./scenario.js";
|
||
import { allocationRouter } from "./allocation.js";
|
||
import { staffingRouter } from "./staffing.js";
|
||
|
||
// ─── Mutation tool set for audit logging (EGAI 4.1.3.1 / IAAI 3.6.26) ──────
|
||
|
||
export const MUTATION_TOOLS = new Set([
|
||
"import_csv_data",
|
||
"update_system_settings",
|
||
"test_ai_connection",
|
||
"test_smtp_connection",
|
||
"test_gemini_connection",
|
||
"update_system_role_config",
|
||
"create_webhook",
|
||
"update_webhook",
|
||
"delete_webhook",
|
||
"test_webhook",
|
||
"stage_dispo_import_batch",
|
||
"cancel_dispo_import_batch",
|
||
"resolve_dispo_staged_record",
|
||
"commit_dispo_import_batch",
|
||
"create_allocation", "cancel_allocation", "update_allocation_status",
|
||
"update_timeline_allocation_inline", "apply_timeline_project_shift",
|
||
"quick_assign_timeline_resource", "batch_quick_assign_timeline_resources",
|
||
"batch_shift_timeline_allocations",
|
||
"update_resource", "deactivate_resource", "create_resource",
|
||
"update_project", "create_project", "delete_project",
|
||
"create_vacation", "approve_vacation", "reject_vacation", "cancel_vacation",
|
||
"set_entitlement", "create_demand", "fill_demand",
|
||
"generate_project_cover", "remove_project_cover",
|
||
"create_role", "update_role", "delete_role",
|
||
"create_client", "update_client", "delete_client",
|
||
"create_org_unit", "update_org_unit",
|
||
"create_country", "update_country",
|
||
"create_metro_city", "update_metro_city", "delete_metro_city",
|
||
"create_holiday_calendar", "update_holiday_calendar", "delete_holiday_calendar",
|
||
"create_holiday_calendar_entry", "update_holiday_calendar_entry", "delete_holiday_calendar_entry",
|
||
"send_broadcast", "create_task_for_user", "create_reminder",
|
||
"update_task_status", "execute_task_action",
|
||
"create_comment", "resolve_comment", "mark_notification_read",
|
||
"save_dashboard_layout", "toggle_favorite_project",
|
||
"set_column_preferences", "generate_totp_secret", "verify_and_enable_totp",
|
||
"create_user", "set_user_password", "update_user_role", "update_user_name",
|
||
"link_user_resource", "auto_link_users_by_email", "set_user_permissions",
|
||
"reset_user_permissions", "disable_user_totp",
|
||
"create_notification", "update_reminder", "delete_reminder",
|
||
"delete_notification", "assign_task",
|
||
"clone_estimate", "update_estimate_draft", "submit_estimate_version",
|
||
"approve_estimate_version", "create_estimate_revision",
|
||
"create_estimate_export", "create_estimate_planning_handoff",
|
||
"generate_estimate_weekly_phasing", "update_estimate_commercial_terms",
|
||
]);
|
||
|
||
export const ADVANCED_ASSISTANT_TOOLS = new Set([
|
||
"find_best_project_resource",
|
||
"get_timeline_entries_view",
|
||
"get_timeline_holiday_overlays",
|
||
"get_project_timeline_context",
|
||
"preview_project_shift",
|
||
"update_timeline_allocation_inline",
|
||
"apply_timeline_project_shift",
|
||
"quick_assign_timeline_resource",
|
||
"batch_quick_assign_timeline_resources",
|
||
"batch_shift_timeline_allocations",
|
||
"get_chargeability_report",
|
||
"get_resource_computation_graph",
|
||
"get_project_computation_graph",
|
||
]);
|
||
|
||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||
|
||
export type ToolContext = {
|
||
db: typeof prisma;
|
||
userId: string;
|
||
userRole: string;
|
||
permissions: Set<PermissionKey>;
|
||
session?: TRPCContext["session"];
|
||
dbUser?: TRPCContext["dbUser"];
|
||
roleDefaults?: TRPCContext["roleDefaults"];
|
||
};
|
||
|
||
export interface ToolDef {
|
||
type: "function";
|
||
function: {
|
||
name: string;
|
||
description: string;
|
||
parameters: Record<string, unknown>;
|
||
};
|
||
}
|
||
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
type ToolExecutor = (params: any, ctx: ToolContext) => Promise<unknown>;
|
||
|
||
const createChargeabilityReportCaller = createCallerFactory(chargeabilityReportRouter);
|
||
const createComputationGraphCaller = createCallerFactory(computationGraphRouter);
|
||
const createTimelineCaller = createCallerFactory(timelineRouter);
|
||
const createAuditLogCaller = createCallerFactory(auditLogRouter);
|
||
const createImportExportCaller = createCallerFactory(importExportRouter);
|
||
const createDispoCaller = createCallerFactory(dispoRouter);
|
||
const createResourceCaller = createCallerFactory(resourceRouter);
|
||
const createSettingsCaller = createCallerFactory(settingsRouter);
|
||
const createSystemRoleConfigCaller = createCallerFactory(systemRoleConfigRouter);
|
||
const createUserCaller = createCallerFactory(userRouter);
|
||
const createNotificationCaller = createCallerFactory(notificationRouter);
|
||
const createEstimateCaller = createCallerFactory(estimateRouter);
|
||
const createWebhookCaller = createCallerFactory(webhookRouter);
|
||
const createCountryCaller = createCallerFactory(countryRouter);
|
||
const createHolidayCalendarCaller = createCallerFactory(holidayCalendarRouter);
|
||
const createBlueprintCaller = createCallerFactory(blueprintRouter);
|
||
const createRoleCaller = createCallerFactory(roleRouter);
|
||
const createClientCaller = createCallerFactory(clientRouter);
|
||
const createOrgUnitCaller = createCallerFactory(orgUnitRouter);
|
||
const createProjectCaller = createCallerFactory(projectRouter);
|
||
const createRateCardCaller = createCallerFactory(rateCardRouter);
|
||
const createReportCaller = createCallerFactory(reportRouter);
|
||
const createVacationCaller = createCallerFactory(vacationRouter);
|
||
const createEntitlementCaller = createCallerFactory(entitlementRouter);
|
||
const createCommentCaller = createCallerFactory(commentRouter);
|
||
const createManagementLevelCaller = createCallerFactory(managementLevelRouter);
|
||
const createUtilizationCategoryCaller = createCallerFactory(utilizationCategoryRouter);
|
||
const createCalculationRuleCaller = createCallerFactory(calculationRuleRouter);
|
||
const createEffortRuleCaller = createCallerFactory(effortRuleRouter);
|
||
const createExperienceMultiplierCaller = createCallerFactory(experienceMultiplierRouter);
|
||
const createDashboardCaller = createCallerFactory(dashboardRouter);
|
||
const createInsightsCaller = createCallerFactory(insightsRouter);
|
||
const createScenarioCaller = createCallerFactory(scenarioRouter);
|
||
const createAllocationCaller = createCallerFactory(allocationRouter);
|
||
const createStaffingCaller = createCallerFactory(staffingRouter);
|
||
|
||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||
|
||
function fmtDate(d: Date | null | undefined): string | null {
|
||
return d ? d.toISOString().slice(0, 10) : null;
|
||
}
|
||
|
||
class AssistantVisibleError extends Error {
|
||
constructor(message: string) {
|
||
super(message);
|
||
this.name = "AssistantVisibleError";
|
||
}
|
||
}
|
||
|
||
function assertPermission(ctx: ToolContext, perm: PermissionKey): void {
|
||
if (!ctx.permissions.has(perm)) {
|
||
throw new AssistantVisibleError(`Permission denied: you need the "${perm}" permission to perform this action.`);
|
||
}
|
||
}
|
||
|
||
function assertAdminRole(ctx: ToolContext): void {
|
||
if (ctx.userRole !== SystemRole.ADMIN) {
|
||
throw new AssistantVisibleError("Admin role required to perform this action.");
|
||
}
|
||
}
|
||
|
||
function formatHolidayCalendarEntry(entry: {
|
||
id: string;
|
||
date: Date;
|
||
name: string;
|
||
isRecurringAnnual?: boolean | null;
|
||
source?: string | null;
|
||
}) {
|
||
return {
|
||
id: entry.id,
|
||
date: fmtDate(entry.date),
|
||
name: entry.name,
|
||
isRecurringAnnual: entry.isRecurringAnnual ?? false,
|
||
source: entry.source ?? null,
|
||
};
|
||
}
|
||
|
||
function formatHolidayCalendar(calendar: {
|
||
id: string;
|
||
name: string;
|
||
scopeType: string;
|
||
stateCode?: string | null;
|
||
isActive?: boolean | null;
|
||
priority?: number | null;
|
||
country?: { id: string; code: string; name: string } | null;
|
||
metroCity?: { id: string; name: string } | null;
|
||
_count?: { entries?: number | null } | null;
|
||
entries?: Array<{
|
||
id: string;
|
||
date: Date;
|
||
name: string;
|
||
isRecurringAnnual?: boolean | null;
|
||
source?: string | null;
|
||
}> | null;
|
||
}) {
|
||
const entries = calendar.entries?.map(formatHolidayCalendarEntry) ?? [];
|
||
|
||
return {
|
||
id: calendar.id,
|
||
name: calendar.name,
|
||
scopeType: calendar.scopeType,
|
||
stateCode: calendar.stateCode ?? null,
|
||
isActive: calendar.isActive ?? true,
|
||
priority: calendar.priority ?? 0,
|
||
country: calendar.country
|
||
? {
|
||
id: calendar.country.id,
|
||
code: calendar.country.code,
|
||
name: calendar.country.name,
|
||
}
|
||
: null,
|
||
metroCity: calendar.metroCity
|
||
? {
|
||
id: calendar.metroCity.id,
|
||
name: calendar.metroCity.name,
|
||
}
|
||
: null,
|
||
entryCount: calendar._count?.entries ?? entries.length,
|
||
entries,
|
||
};
|
||
}
|
||
|
||
function formatCountry(country: {
|
||
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;
|
||
}) {
|
||
return {
|
||
id: country.id,
|
||
code: country.code,
|
||
name: country.name,
|
||
dailyWorkingHours: country.dailyWorkingHours,
|
||
scheduleRules: country.scheduleRules ?? null,
|
||
isActive: country.isActive ?? true,
|
||
resourceCount: country._count?.resources ?? null,
|
||
metroCities: (country.metroCities ?? []).map((city) => ({
|
||
id: city.id,
|
||
name: city.name,
|
||
})),
|
||
cities: (country.metroCities ?? []).map((city) => city.name),
|
||
};
|
||
}
|
||
|
||
function createUtcDate(year: number, monthIndex: number, day: number): Date {
|
||
return new Date(Date.UTC(year, monthIndex, day));
|
||
}
|
||
|
||
function resolveHolidayPeriod(input: {
|
||
year?: number;
|
||
periodStart?: string;
|
||
periodEnd?: string;
|
||
}): { year: number | null; periodStart: Date; periodEnd: Date } {
|
||
if (input.periodStart || input.periodEnd) {
|
||
if (!input.periodStart || !input.periodEnd) {
|
||
throw new AssistantVisibleError("periodStart and periodEnd must both be provided when using a custom holiday range.");
|
||
}
|
||
|
||
const periodStart = new Date(`${input.periodStart}T00:00:00.000Z`);
|
||
const periodEnd = new Date(`${input.periodEnd}T00:00:00.000Z`);
|
||
if (Number.isNaN(periodStart.getTime())) {
|
||
throw new AssistantVisibleError(`Invalid periodStart: ${input.periodStart}`);
|
||
}
|
||
if (Number.isNaN(periodEnd.getTime())) {
|
||
throw new AssistantVisibleError(`Invalid periodEnd: ${input.periodEnd}`);
|
||
}
|
||
if (periodEnd < periodStart) {
|
||
throw new AssistantVisibleError("periodEnd must be on or after periodStart.");
|
||
}
|
||
|
||
return { year: null, periodStart, periodEnd };
|
||
}
|
||
|
||
const year = input.year ?? new Date().getUTCFullYear();
|
||
return {
|
||
year,
|
||
periodStart: createUtcDate(year, 0, 1),
|
||
periodEnd: createUtcDate(year, 11, 31),
|
||
};
|
||
}
|
||
|
||
const ASSISTANT_VACATION_REQUEST_TYPES = [
|
||
VacationType.ANNUAL,
|
||
VacationType.SICK,
|
||
VacationType.OTHER,
|
||
] as const;
|
||
|
||
function parseAssistantVacationRequestType(input: string): VacationType {
|
||
const normalized = input.trim().toUpperCase();
|
||
if (normalized === VacationType.PUBLIC_HOLIDAY) {
|
||
throw new AssistantVisibleError("PUBLIC_HOLIDAY requests cannot be created manually. Manage public holidays through holiday calendars instead.");
|
||
}
|
||
|
||
if ((ASSISTANT_VACATION_REQUEST_TYPES as readonly string[]).includes(normalized)) {
|
||
return normalized as VacationType;
|
||
}
|
||
|
||
throw new AssistantVisibleError(`Invalid vacation type: ${input}. Valid types: ${ASSISTANT_VACATION_REQUEST_TYPES.join(", ")}.`);
|
||
}
|
||
|
||
function parseIsoDate(value: string, fieldName: string): Date {
|
||
const parsed = new Date(`${value}T00:00:00.000Z`);
|
||
if (Number.isNaN(parsed.getTime())) {
|
||
throw new AssistantVisibleError(`Invalid ${fieldName}: ${value}`);
|
||
}
|
||
return parsed;
|
||
}
|
||
|
||
function parseOptionalIsoDate(
|
||
value: string | undefined,
|
||
fieldName: string,
|
||
): Date | undefined {
|
||
return value ? parseIsoDate(value, fieldName) : undefined;
|
||
}
|
||
|
||
function parseDateTime(value: string, fieldName: string): Date {
|
||
const parsed = new Date(value);
|
||
if (Number.isNaN(parsed.getTime())) {
|
||
throw new AssistantVisibleError(`Invalid ${fieldName}: ${value}`);
|
||
}
|
||
return parsed;
|
||
}
|
||
|
||
function parseOptionalDateTime(
|
||
value: string | undefined,
|
||
fieldName: string,
|
||
): Date | undefined {
|
||
return value ? parseDateTime(value, fieldName) : undefined;
|
||
}
|
||
|
||
function toDate(value: Date | string): Date {
|
||
return value instanceof Date ? value : new Date(value);
|
||
}
|
||
|
||
type AssistantToolErrorResult = { error: string };
|
||
type AssistantIndexedFieldErrorResult = AssistantToolErrorResult & {
|
||
field: string;
|
||
index: number;
|
||
};
|
||
type BatchQuickAssignmentInput = {
|
||
resourceId: string;
|
||
projectId: string;
|
||
startDate: Date;
|
||
endDate: Date;
|
||
hoursPerDay?: number;
|
||
role?: string;
|
||
status?: AllocationStatus;
|
||
};
|
||
|
||
function toAssistantNotFoundError(
|
||
error: unknown,
|
||
message: string,
|
||
): AssistantToolErrorResult | null {
|
||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||
return { error: message };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function toAssistantAllocationNotFoundError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
if (error instanceof TRPCError) {
|
||
if (error.code === "NOT_FOUND") {
|
||
return { error: "Allocation not found with the given criteria." };
|
||
}
|
||
if (error.message === "Record not found" || error.message.includes("Assignment not found")) {
|
||
return { error: "Allocation not found with the given criteria." };
|
||
}
|
||
}
|
||
|
||
const prismaError = getPrismaRequestErrorMetadata(error);
|
||
if (prismaError?.code === "P2025") {
|
||
return { error: "Allocation not found with the given criteria." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantProjectNotFoundError(
|
||
error: unknown,
|
||
identifier: string,
|
||
): AssistantToolErrorResult | null {
|
||
return toAssistantNotFoundError(
|
||
error,
|
||
`Project not found: ${identifier}`,
|
||
);
|
||
}
|
||
|
||
function toAssistantTimelineMutationError(
|
||
error: unknown,
|
||
context: "updateInline" | "applyShift" | "quickAssign" | "batchShift",
|
||
): AssistantToolErrorResult | null {
|
||
if (error instanceof TRPCError) {
|
||
if (error.code === "NOT_FOUND") {
|
||
if (error.message.includes("Resource not found")) {
|
||
return { error: "Resource not found with the given criteria." };
|
||
}
|
||
if (error.message.includes("Project not found")) {
|
||
return { error: "Project not found with the given criteria." };
|
||
}
|
||
if (error.message.includes("Demand requirement not found")) {
|
||
return { error: "Demand requirement not found with the given criteria." };
|
||
}
|
||
if (error.message.includes("No allocations found")) {
|
||
return { error: "Allocation not found with the given criteria." };
|
||
}
|
||
}
|
||
|
||
if (error.code === "BAD_REQUEST" || error.code === "CONFLICT") {
|
||
return { error: error.message };
|
||
}
|
||
}
|
||
|
||
const allocationNotFound = toAssistantAllocationNotFoundError(error);
|
||
if (allocationNotFound && (context === "updateInline" || context === "batchShift")) {
|
||
return allocationNotFound;
|
||
}
|
||
|
||
const prismaError = getPrismaRequestErrorMetadata(error);
|
||
if (!prismaError) {
|
||
return null;
|
||
}
|
||
|
||
const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase();
|
||
if (errorText.includes("resource")) {
|
||
return { error: "Resource not found with the given criteria." };
|
||
}
|
||
if (errorText.includes("project")) {
|
||
return { error: "Project not found with the given criteria." };
|
||
}
|
||
if (errorText.includes("demand")) {
|
||
return { error: "Demand requirement not found with the given criteria." };
|
||
}
|
||
if (prismaError.code === "P2025" && (context === "updateInline" || context === "batchShift")) {
|
||
return { error: "Allocation not found with the given criteria." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantVacationNotFoundError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
return toAssistantNotFoundError(
|
||
error,
|
||
"Vacation not found with the given criteria.",
|
||
);
|
||
}
|
||
|
||
function toAssistantVacationMutationError(
|
||
error: unknown,
|
||
action: "approve" | "reject" | "cancel",
|
||
): AssistantToolErrorResult | null {
|
||
const notFound = toAssistantVacationNotFoundError(error);
|
||
if (notFound) {
|
||
return notFound;
|
||
}
|
||
|
||
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
|
||
if (action === "approve") {
|
||
return { error: "Vacation cannot be approved in its current status." };
|
||
}
|
||
if (action === "reject") {
|
||
return { error: "Vacation cannot be rejected in its current status." };
|
||
}
|
||
return { error: "Vacation cannot be cancelled in its current status." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantProjectCreationError(
|
||
error: unknown,
|
||
shortCode: string,
|
||
): AssistantToolErrorResult | null {
|
||
if (error instanceof TRPCError) {
|
||
if (error.code === "CONFLICT") {
|
||
return { error: `A project with short code "${shortCode}" already exists.` };
|
||
}
|
||
|
||
if (error.code === "NOT_FOUND") {
|
||
if (error.message.includes("Blueprint")) {
|
||
return { error: "Blueprint not found with the given criteria." };
|
||
}
|
||
if (error.message.includes("Client")) {
|
||
return { error: "Client not found with the given criteria." };
|
||
}
|
||
}
|
||
|
||
if (error.code === "BAD_REQUEST" || error.code === "UNPROCESSABLE_CONTENT") {
|
||
return { error: error.message };
|
||
}
|
||
}
|
||
|
||
const prismaError = getPrismaRequestErrorMetadata(error);
|
||
if (!prismaError) {
|
||
return null;
|
||
}
|
||
|
||
if (prismaError.code !== "P2003" && prismaError.code !== "P2025") {
|
||
return null;
|
||
}
|
||
|
||
const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase();
|
||
if (errorText.includes("blueprint")) {
|
||
return { error: "Blueprint not found with the given criteria." };
|
||
}
|
||
if (errorText.includes("client")) {
|
||
return { error: "Client not found with the given criteria." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantDemandNotFoundError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
return toAssistantNotFoundError(
|
||
error,
|
||
"Demand not found with the given criteria.",
|
||
);
|
||
}
|
||
|
||
function toAssistantDemandFillError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
const notFound = toAssistantDemandNotFoundError(error);
|
||
if (notFound) {
|
||
return notFound;
|
||
}
|
||
|
||
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
|
||
return { error: "Demand cannot be filled in its current status." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantEstimateNotFoundError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||
if (error.message.includes("version")) {
|
||
return { error: "Estimate version not found with the given criteria." };
|
||
}
|
||
return { error: "Estimate not found with the given criteria." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantEstimateReadError(
|
||
error: unknown,
|
||
context: "weeklyPhasing" | "commercialTerms",
|
||
): AssistantToolErrorResult | null {
|
||
const notFound = toAssistantEstimateNotFoundError(error);
|
||
if (notFound) {
|
||
return notFound;
|
||
}
|
||
|
||
if (
|
||
context === "weeklyPhasing"
|
||
&& error instanceof TRPCError
|
||
&& error.code === "PRECONDITION_FAILED"
|
||
&& error.message === "Estimate has no versions"
|
||
) {
|
||
return { error: "Estimate version not found with the given criteria." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantHolidayCalendarNotFoundError(
|
||
error: unknown,
|
||
identifier?: string,
|
||
): AssistantToolErrorResult | null {
|
||
return toAssistantNotFoundError(
|
||
error,
|
||
identifier
|
||
? `Holiday calendar not found: ${identifier}`
|
||
: "Holiday calendar not found with the given criteria.",
|
||
);
|
||
}
|
||
|
||
function toAssistantHolidayCalendarMutationError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
const notFound = toAssistantHolidayCalendarNotFoundError(error);
|
||
if (notFound) {
|
||
return notFound;
|
||
}
|
||
|
||
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
|
||
return { error: "Holiday calendar scope is invalid." };
|
||
}
|
||
|
||
if (error instanceof TRPCError && error.code === "CONFLICT") {
|
||
return { error: "A holiday calendar for this scope already exists." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantHolidayEntryNotFoundError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
return toAssistantNotFoundError(
|
||
error,
|
||
"Holiday calendar entry not found with the given criteria.",
|
||
);
|
||
}
|
||
|
||
function toAssistantHolidayEntryMutationError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
const calendarNotFound = toAssistantHolidayCalendarNotFoundError(error);
|
||
if (calendarNotFound) {
|
||
return calendarNotFound;
|
||
}
|
||
|
||
const entryNotFound = toAssistantHolidayEntryNotFoundError(error);
|
||
if (entryNotFound) {
|
||
return entryNotFound;
|
||
}
|
||
|
||
if (error instanceof TRPCError && error.code === "CONFLICT") {
|
||
return { error: "A holiday entry for this calendar and date already exists." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantRoleNotFoundError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
return toAssistantNotFoundError(
|
||
error,
|
||
"Role not found with the given criteria.",
|
||
);
|
||
}
|
||
|
||
function toAssistantRoleMutationError(
|
||
error: unknown,
|
||
action: "create" | "update" | "delete",
|
||
): AssistantToolErrorResult | null {
|
||
const notFound = toAssistantRoleNotFoundError(error);
|
||
if (notFound) {
|
||
return notFound;
|
||
}
|
||
|
||
if (error instanceof TRPCError && error.code === "CONFLICT") {
|
||
return { error: "A role with this name already exists." };
|
||
}
|
||
|
||
if (action === "delete" && error instanceof TRPCError && error.code === "PRECONDITION_FAILED") {
|
||
return { error: "Role cannot be deleted while it is still assigned. Deactivate it instead." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantClientMutationError(
|
||
error: unknown,
|
||
action: "create" | "update" | "delete" = "update",
|
||
): AssistantToolErrorResult | null {
|
||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||
if (error.message.includes("Parent client")) {
|
||
return { error: "Parent client not found with the given criteria." };
|
||
}
|
||
return { error: "Client not found with the given criteria." };
|
||
}
|
||
|
||
if (error instanceof TRPCError && error.code === "CONFLICT") {
|
||
return { error: "A client with this code already exists." };
|
||
}
|
||
|
||
if (action === "delete" && error instanceof TRPCError && error.code === "PRECONDITION_FAILED") {
|
||
if (error.message.includes("project")) {
|
||
return { error: "Client cannot be deleted while it still has projects. Deactivate it instead." };
|
||
}
|
||
if (error.message.includes("child client")) {
|
||
return { error: "Client cannot be deleted while it still has child clients. Remove or reassign them first." };
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantOrgUnitNotFoundError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||
if (error.message.includes("Parent org unit")) {
|
||
return { error: "Parent org unit not found with the given criteria." };
|
||
}
|
||
return { error: "Org unit not found with the given criteria." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantOrgUnitMutationError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
const notFound = toAssistantOrgUnitNotFoundError(error);
|
||
if (notFound) {
|
||
return notFound;
|
||
}
|
||
|
||
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
|
||
if (error.message.includes("must be greater than parent level")) {
|
||
return { error: "Org unit level must be greater than the parent org unit level." };
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantCountryNotFoundError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||
return { error: "Country not found with the given criteria." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantCountryMutationError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
const notFound = toAssistantCountryNotFoundError(error);
|
||
if (notFound) {
|
||
return notFound;
|
||
}
|
||
|
||
if (error instanceof TRPCError && error.code === "CONFLICT") {
|
||
return { error: "A country with this code already exists." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantResourceCreationError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
if (error instanceof TRPCError) {
|
||
if (error.code === "CONFLICT") {
|
||
return { error: "A resource with this EID or email already exists." };
|
||
}
|
||
|
||
if (error.code === "BAD_REQUEST" || error.code === "UNPROCESSABLE_CONTENT") {
|
||
return { error: error.message };
|
||
}
|
||
|
||
if (error.code === "NOT_FOUND") {
|
||
if (error.message.includes("Role")) {
|
||
return { error: "Role not found with the given criteria." };
|
||
}
|
||
if (error.message.includes("Country")) {
|
||
return { error: "Country not found with the given criteria." };
|
||
}
|
||
if (error.message.includes("Org unit")) {
|
||
return { error: "Org unit not found with the given criteria." };
|
||
}
|
||
}
|
||
}
|
||
|
||
const prismaError = getPrismaRequestErrorMetadata(error);
|
||
if (!prismaError) {
|
||
return null;
|
||
}
|
||
|
||
if (prismaError.code !== "P2003" && prismaError.code !== "P2025") {
|
||
return null;
|
||
}
|
||
|
||
const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase();
|
||
if (errorText.includes("role")) {
|
||
return { error: "Role not found with the given criteria." };
|
||
}
|
||
if (errorText.includes("country")) {
|
||
return { error: "Country not found with the given criteria." };
|
||
}
|
||
if (errorText.includes("orgunit") || errorText.includes("org_unit") || errorText.includes("org unit")) {
|
||
return { error: "Org unit not found with the given criteria." };
|
||
}
|
||
|
||
return { error: "The selected role, country, or org unit no longer exists." };
|
||
}
|
||
|
||
function toAssistantResourceMutationError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||
return { error: "Resource not found with the given criteria." };
|
||
}
|
||
|
||
const prismaError = getPrismaRequestErrorMetadata(error);
|
||
if (!prismaError) {
|
||
return null;
|
||
}
|
||
|
||
if (prismaError.code === "P2025") {
|
||
return { error: "Resource not found with the given criteria." };
|
||
}
|
||
|
||
if (prismaError.code !== "P2003") {
|
||
return null;
|
||
}
|
||
|
||
const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase();
|
||
if (errorText.includes("role")) {
|
||
return { error: "Role not found with the given criteria." };
|
||
}
|
||
if (errorText.includes("country")) {
|
||
return { error: "Country not found with the given criteria." };
|
||
}
|
||
if (errorText.includes("orgunit") || errorText.includes("org_unit") || errorText.includes("org unit")) {
|
||
return { error: "Org unit not found with the given criteria." };
|
||
}
|
||
|
||
return { error: "Resource not found with the given criteria." };
|
||
}
|
||
|
||
function toAssistantProjectMutationError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||
return { error: "Project not found with the given criteria." };
|
||
}
|
||
|
||
const prismaError = getPrismaRequestErrorMetadata(error);
|
||
if (!prismaError) {
|
||
return null;
|
||
}
|
||
|
||
if (prismaError.code === "P2025") {
|
||
return { error: "Project not found with the given criteria." };
|
||
}
|
||
|
||
if (prismaError.code !== "P2003") {
|
||
return null;
|
||
}
|
||
|
||
const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase();
|
||
if (errorText.includes("blueprint")) {
|
||
return { error: "Blueprint not found with the given criteria." };
|
||
}
|
||
if (errorText.includes("client")) {
|
||
return { error: "Client not found with the given criteria." };
|
||
}
|
||
|
||
return { error: "Project not found with the given criteria." };
|
||
}
|
||
|
||
function toAssistantMetroCityMutationError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||
if (error.message.includes("Country")) {
|
||
return { error: "Country not found with the given criteria." };
|
||
}
|
||
return { error: "Metro city not found with the given criteria." };
|
||
}
|
||
|
||
if (error instanceof TRPCError && error.code === "PRECONDITION_FAILED") {
|
||
return { error: "Metro city cannot be deleted while it is still assigned to resources." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantDemandCreationError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||
if (error.message.includes("Role")) {
|
||
return { error: "Role not found with the given criteria." };
|
||
}
|
||
return { error: "Project not found with the given criteria." };
|
||
}
|
||
|
||
const prismaError = getPrismaRequestErrorMetadata(error);
|
||
if (!prismaError) {
|
||
return null;
|
||
}
|
||
|
||
if (prismaError.code !== "P2003" && prismaError.code !== "P2025") {
|
||
return null;
|
||
}
|
||
|
||
const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase();
|
||
if (errorText.includes("role")) {
|
||
return { error: "Role not found with the given criteria." };
|
||
}
|
||
if (errorText.includes("project")) {
|
||
return { error: "Project not found with the given criteria." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantVacationCreationError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
if (error instanceof TRPCError) {
|
||
if (error.code === "FORBIDDEN") {
|
||
return { error: "You can only create vacation requests for your own resource." };
|
||
}
|
||
|
||
if (error.code === "BAD_REQUEST") {
|
||
return { error: error.message };
|
||
}
|
||
}
|
||
|
||
const prismaError = getPrismaRequestErrorMetadata(error);
|
||
if (!prismaError) {
|
||
return null;
|
||
}
|
||
|
||
if (prismaError.code === "P2025") {
|
||
return { error: "Resource not found with the given criteria." };
|
||
}
|
||
|
||
if (prismaError.code !== "P2003") {
|
||
return null;
|
||
}
|
||
|
||
const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase();
|
||
if (errorText.includes("resource")) {
|
||
return { error: "Resource not found with the given criteria." };
|
||
}
|
||
|
||
return { error: "Resource not found with the given criteria." };
|
||
}
|
||
|
||
function toAssistantEntitlementMutationError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||
return { error: "Resource not found with the given criteria." };
|
||
}
|
||
|
||
const prismaError = getPrismaRequestErrorMetadata(error);
|
||
if (!prismaError) {
|
||
return null;
|
||
}
|
||
|
||
if (prismaError.code === "P2025") {
|
||
return { error: "Resource not found with the given criteria." };
|
||
}
|
||
|
||
if (prismaError.code !== "P2003") {
|
||
return null;
|
||
}
|
||
|
||
return { error: "Resource not found with the given criteria." };
|
||
}
|
||
|
||
function toAssistantEstimateCreationError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||
return { error: "Project not found with the given criteria." };
|
||
}
|
||
|
||
const prismaError = getPrismaRequestErrorMetadata(error);
|
||
if (!prismaError) {
|
||
return null;
|
||
}
|
||
|
||
if (prismaError.code !== "P2003" && prismaError.code !== "P2025") {
|
||
return null;
|
||
}
|
||
|
||
const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase();
|
||
if (errorText.includes("project")) {
|
||
return { error: "Project not found with the given criteria." };
|
||
}
|
||
if (errorText.includes("role")) {
|
||
return { error: "Role not found with the given criteria." };
|
||
}
|
||
if (errorText.includes("resource")) {
|
||
return { error: "Resource not found with the given criteria." };
|
||
}
|
||
if (errorText.includes("scopeitem") || errorText.includes("scope_item") || errorText.includes("scope item")) {
|
||
return { error: "Estimate scope item not found with the given criteria." };
|
||
}
|
||
|
||
return { error: "One of the referenced project, role, resource, or scope items no longer exists." };
|
||
}
|
||
|
||
function toAssistantEstimateMutationError(
|
||
error: unknown,
|
||
action:
|
||
| "clone"
|
||
| "updateDraft"
|
||
| "submitVersion"
|
||
| "approveVersion"
|
||
| "createRevision"
|
||
| "createExport"
|
||
| "createPlanningHandoff"
|
||
| "generateWeeklyPhasing"
|
||
| "updateCommercialTerms",
|
||
): AssistantToolErrorResult | null {
|
||
if (error instanceof TRPCError) {
|
||
if (error.code === "NOT_FOUND") {
|
||
if (error.message.includes("Linked project")) {
|
||
return { error: "Project not found with the given criteria." };
|
||
}
|
||
if (action === "clone" && error.message === "Source estimate has no versions") {
|
||
return { error: "Source estimate has no versions and cannot be cloned." };
|
||
}
|
||
if (error.message.includes("version") || error.message.includes("versions")) {
|
||
return { error: "Estimate version not found with the given criteria." };
|
||
}
|
||
return { error: "Estimate not found with the given criteria." };
|
||
}
|
||
|
||
if (error.code === "PRECONDITION_FAILED") {
|
||
switch (error.message) {
|
||
case "Estimate has no working version":
|
||
return { error: "Estimate has no working version." };
|
||
case "Only working versions can be submitted":
|
||
return { error: "Only working versions can be submitted." };
|
||
case "Estimate has no submitted version":
|
||
return { error: "Estimate has no submitted version." };
|
||
case "Only submitted versions can be approved":
|
||
return { error: "Only submitted versions can be approved." };
|
||
case "Estimate already has a working version":
|
||
return { error: "Estimate already has a working version." };
|
||
case "Estimate has no locked version to revise":
|
||
return { error: "Estimate has no locked version to revise." };
|
||
case "Source version must be locked before creating a revision":
|
||
return { error: "Source version must be locked before creating a revision." };
|
||
case "Estimate has no approved version":
|
||
return { error: "Estimate has no approved version." };
|
||
case "Only approved versions can be handed off to planning":
|
||
return { error: "Only approved versions can be handed off to planning." };
|
||
case "Estimate must be linked to a project before planning handoff":
|
||
return { error: "Estimate must be linked to a project before planning handoff." };
|
||
case "Planning handoff already exists for this approved version":
|
||
return { error: "Planning handoff already exists for this approved version." };
|
||
case "Linked project has an invalid date range":
|
||
return { error: "The linked project has an invalid date range for planning handoff." };
|
||
case "Commercial terms can only be edited on working versions":
|
||
return { error: "Commercial terms can only be edited on working versions." };
|
||
default:
|
||
if (error.message.startsWith("Project window has no working days for demand line")) {
|
||
return { error: "The linked project window has no working days for at least one demand line." };
|
||
}
|
||
}
|
||
}
|
||
|
||
if (error.code === "BAD_REQUEST" && action === "updateCommercialTerms") {
|
||
return { error: "Commercial terms input is invalid." };
|
||
}
|
||
}
|
||
|
||
const prismaError = getPrismaRequestErrorMetadata(error);
|
||
if (!prismaError) {
|
||
return null;
|
||
}
|
||
|
||
if (prismaError.code === "P2003") {
|
||
const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase();
|
||
if (errorText.includes("project")) {
|
||
return { error: "Project not found with the given criteria." };
|
||
}
|
||
if (errorText.includes("role")) {
|
||
return { error: "Role not found with the given criteria." };
|
||
}
|
||
if (errorText.includes("resource")) {
|
||
return { error: "Resource not found with the given criteria." };
|
||
}
|
||
if (errorText.includes("scopeitem") || errorText.includes("scope_item") || errorText.includes("scope item")) {
|
||
return { error: "Estimate scope item not found with the given criteria." };
|
||
}
|
||
return { error: "One of the referenced project, role, resource, or scope items no longer exists." };
|
||
}
|
||
|
||
if (prismaError.code === "P2025") {
|
||
const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase();
|
||
if (errorText.includes("estimatedemandline") || errorText.includes("estimate_demand_line") || errorText.includes("estimate demand line")) {
|
||
return { error: "Estimate demand line not found with the given criteria." };
|
||
}
|
||
if (errorText.includes("estimateversion") || errorText.includes("estimate_version") || errorText.includes("estimate version")) {
|
||
return { error: "Estimate version not found with the given criteria." };
|
||
}
|
||
if (errorText.includes("estimate")) {
|
||
return { error: "Estimate not found with the given criteria." };
|
||
}
|
||
switch (action) {
|
||
case "generateWeeklyPhasing":
|
||
return { error: "Estimate demand line not found with the given criteria." };
|
||
case "updateCommercialTerms":
|
||
case "submitVersion":
|
||
case "approveVersion":
|
||
case "createRevision":
|
||
case "createExport":
|
||
return { error: "Estimate version not found with the given criteria." };
|
||
default:
|
||
return { error: "Estimate not found with the given criteria." };
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantUserMutationError(
|
||
error: unknown,
|
||
action: "create" | "update" | "password" = "update",
|
||
): AssistantToolErrorResult | null {
|
||
if (error instanceof TRPCError && error.code === "CONFLICT" && action === "create") {
|
||
return { error: "User with this email already exists." };
|
||
}
|
||
|
||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||
return { error: "User not found with the given criteria." };
|
||
}
|
||
|
||
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
|
||
const validationIssues = getTrpcValidationIssues(error);
|
||
for (const issue of validationIssues) {
|
||
const field = issue.path[0];
|
||
if (field === "password" && issue.code === "too_small") {
|
||
return { error: "Password must be at least 8 characters." };
|
||
}
|
||
|
||
if (field === "name" && issue.code === "too_small") {
|
||
return { error: "Name is required." };
|
||
}
|
||
|
||
if (field === "name" && issue.code === "too_big") {
|
||
return { error: "Name must be at most 200 characters." };
|
||
}
|
||
}
|
||
|
||
if (error.message.includes("Password must be at least 8 characters")) {
|
||
return { error: "Password must be at least 8 characters." };
|
||
}
|
||
|
||
if (error.message.includes("Name is required")) {
|
||
return { error: "Name is required." };
|
||
}
|
||
}
|
||
|
||
const prismaError = getPrismaRequestErrorMetadata(error);
|
||
if (prismaError?.code === "P2025") {
|
||
return { error: "User not found with the given criteria." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function getTrpcValidationIssues(error: TRPCError): Array<{
|
||
code?: string;
|
||
path: string[];
|
||
}> {
|
||
if (error.cause instanceof ZodError) {
|
||
return error.cause.issues.map((issue) => ({
|
||
code: issue.code,
|
||
path: issue.path.map((segment) => String(segment)),
|
||
}));
|
||
}
|
||
|
||
try {
|
||
const parsed = JSON.parse(error.message);
|
||
if (!Array.isArray(parsed)) {
|
||
return [];
|
||
}
|
||
|
||
return parsed
|
||
.filter((issue): issue is { code?: unknown; path?: unknown } => issue !== null && typeof issue === "object")
|
||
.map((issue) => (
|
||
typeof issue.code === "string"
|
||
? {
|
||
code: issue.code,
|
||
path: Array.isArray(issue.path) ? issue.path.map((segment) => String(segment)) : [],
|
||
}
|
||
: {
|
||
path: Array.isArray(issue.path) ? issue.path.map((segment) => String(segment)) : [],
|
||
}
|
||
));
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function toAssistantUserResourceLinkError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||
if (error.message.includes("Resource")) {
|
||
return { error: "Resource not found with the given criteria." };
|
||
}
|
||
return { error: "User not found with the given criteria." };
|
||
}
|
||
|
||
const prismaError = getPrismaRequestErrorMetadata(error);
|
||
if (!prismaError) {
|
||
return null;
|
||
}
|
||
|
||
const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase();
|
||
const pointsToUser =
|
||
errorText.includes("userid")
|
||
|| errorText.includes("user_id")
|
||
|| errorText.includes(" user ");
|
||
const pointsToResource =
|
||
errorText.includes("resourceid")
|
||
|| errorText.includes("resource_id")
|
||
|| errorText.includes(" resource ");
|
||
|
||
if (prismaError.code === "P2025") {
|
||
return { error: "Resource not found with the given criteria." };
|
||
}
|
||
|
||
if (prismaError.code === "P2003") {
|
||
if (pointsToUser) {
|
||
return { error: "User not found with the given criteria." };
|
||
}
|
||
if (pointsToResource || errorText.includes("resource")) {
|
||
return { error: "Resource not found with the given criteria." };
|
||
}
|
||
return { error: "User not found with the given criteria." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantTotpEnableError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
|
||
if (error.message.includes("No TOTP secret generated")) {
|
||
return { error: "No TOTP secret generated. Call generate_totp_secret first." };
|
||
}
|
||
if (error.message.includes("already enabled")) {
|
||
return { error: "TOTP is already enabled." };
|
||
}
|
||
if (error.message.includes("Invalid TOTP token")) {
|
||
return { error: "Invalid TOTP token." };
|
||
}
|
||
}
|
||
|
||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||
return { error: "User not found with the given criteria." };
|
||
}
|
||
|
||
const prismaError = getPrismaRequestErrorMetadata(error);
|
||
if (prismaError?.code === "P2025") {
|
||
return { error: "User not found with the given criteria." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantWebhookNotFoundError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
return toAssistantNotFoundError(
|
||
error,
|
||
"Webhook not found with the given criteria.",
|
||
);
|
||
}
|
||
|
||
function toAssistantWebhookMutationError(
|
||
error: unknown,
|
||
action: "create" | "update" = "update",
|
||
): AssistantToolErrorResult | null {
|
||
const notFound = toAssistantWebhookNotFoundError(error);
|
||
if (notFound) {
|
||
return notFound;
|
||
}
|
||
|
||
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
|
||
return {
|
||
error: action === "create"
|
||
? "Webhook input is invalid."
|
||
: "Webhook update input is invalid.",
|
||
};
|
||
}
|
||
|
||
const prismaError = getPrismaRequestErrorMetadata(error);
|
||
if (prismaError?.code === "P2025") {
|
||
return { error: "Webhook not found with the given criteria." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantAuditLogEntryNotFoundError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
return toAssistantNotFoundError(
|
||
error,
|
||
"Audit log entry not found with the given criteria.",
|
||
);
|
||
}
|
||
|
||
function toAssistantTaskNotFoundError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
return toAssistantNotFoundError(
|
||
error,
|
||
"Task not found with the given criteria.",
|
||
);
|
||
}
|
||
|
||
function toAssistantTaskActionError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
const notFound = toAssistantTaskNotFoundError(error);
|
||
if (notFound) {
|
||
return notFound;
|
||
}
|
||
|
||
if (error instanceof TRPCError && error.code === "PRECONDITION_FAILED") {
|
||
if (error.message.includes("no executable action")) {
|
||
return { error: "Task has no executable action." };
|
||
}
|
||
if (error.message.includes("already completed")) {
|
||
return { error: "Task is already completed." };
|
||
}
|
||
}
|
||
|
||
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
|
||
if (error.message.includes("Invalid taskAction format") || error.message.includes("Unknown action")) {
|
||
return { error: "Task action is invalid and cannot be executed." };
|
||
}
|
||
if (error.message === "Vacation not found") {
|
||
return { error: "Vacation not found with the given criteria." };
|
||
}
|
||
if (error.message.startsWith("Vacation is ") && error.message.includes(", not PENDING")) {
|
||
return { error: "Vacation is not pending and cannot be approved or rejected via this task action." };
|
||
}
|
||
if (error.message === "Assignment not found") {
|
||
return { error: "Assignment not found with the given criteria." };
|
||
}
|
||
if (error.message === "Assignment is already CONFIRMED") {
|
||
return { error: "Assignment is already confirmed." };
|
||
}
|
||
return { error: error.message };
|
||
}
|
||
|
||
if (error instanceof TRPCError && error.code === "FORBIDDEN") {
|
||
return { error: "You do not have permission to execute this task action." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantTaskAssignmentError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
const notFound = toAssistantTaskNotFoundError(error);
|
||
if (notFound) {
|
||
return notFound;
|
||
}
|
||
|
||
const prismaError = getPrismaRequestErrorMetadata(error);
|
||
if (prismaError && (prismaError.code === "P2003" || prismaError.code === "P2025")) {
|
||
const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase();
|
||
if (errorText.includes("assignee")) {
|
||
return { error: "Assignee user not found with the given criteria." };
|
||
}
|
||
}
|
||
|
||
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
|
||
return { error: "Only tasks and approvals can be assigned." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantBroadcastNotFoundError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
return toAssistantNotFoundError(
|
||
error,
|
||
"Broadcast not found with the given criteria.",
|
||
);
|
||
}
|
||
|
||
function toAssistantDispoImportBatchNotFoundError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
return toAssistantNotFoundError(
|
||
error,
|
||
"Import batch not found with the given criteria.",
|
||
);
|
||
}
|
||
|
||
function toAssistantReminderNotFoundError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
return toAssistantNotFoundError(
|
||
error,
|
||
"Reminder not found with the given criteria.",
|
||
);
|
||
}
|
||
|
||
function toAssistantNotificationNotFoundError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||
return { error: "Notification not found with the given criteria." };
|
||
}
|
||
|
||
const prismaError = getPrismaRequestErrorMetadata(error);
|
||
if (prismaError?.code === "P2025") {
|
||
return { error: "Notification not found with the given criteria." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantNotificationReadError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
return toAssistantNotificationNotFoundError(error);
|
||
}
|
||
|
||
function toAssistantNotificationDeletionError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
const notFound = toAssistantNotificationNotFoundError(error);
|
||
if (notFound) {
|
||
return notFound;
|
||
}
|
||
|
||
if (error instanceof TRPCError && error.code === "FORBIDDEN") {
|
||
return { error: "Tasks created by other users cannot be deleted." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantReminderCreationError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
|
||
return { error: "Reminder input is invalid." };
|
||
}
|
||
|
||
const prismaError = getPrismaRequestErrorMetadata(error);
|
||
if (!prismaError) {
|
||
return null;
|
||
}
|
||
|
||
if (prismaError.code !== "P2003" && prismaError.code !== "P2025") {
|
||
return null;
|
||
}
|
||
|
||
const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase();
|
||
if (errorText.includes("user")) {
|
||
return { error: "Authenticated user not found with the given criteria." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantCommentResolveError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||
return { error: "Comment not found with the given criteria." };
|
||
}
|
||
|
||
if (error instanceof TRPCError && error.code === "FORBIDDEN") {
|
||
return { error: "Only the comment author or an admin can resolve comments." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantCommentCreationError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult | null {
|
||
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
|
||
if (error.message.includes("at least 1 character")) {
|
||
return { error: "Comment body is required." };
|
||
}
|
||
|
||
if (error.message.includes("at most 10000")) {
|
||
return { error: "Comment body must be at most 10000 characters." };
|
||
}
|
||
}
|
||
|
||
const prismaError = getPrismaRequestErrorMetadata(error);
|
||
if (!prismaError) {
|
||
return null;
|
||
}
|
||
|
||
if (prismaError.code !== "P2003" && prismaError.code !== "P2025") {
|
||
return null;
|
||
}
|
||
|
||
const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase();
|
||
if (errorText.includes("author") || errorText.includes("sender")) {
|
||
return { error: "Comment author not found with the given criteria." };
|
||
}
|
||
|
||
if (errorText.includes("user") || errorText.includes("recipient")) {
|
||
return { error: "Mentioned user not found with the given criteria." };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function getPrismaRequestErrorMetadata(error: unknown): {
|
||
code: string;
|
||
message: string;
|
||
metaText: string;
|
||
} | null {
|
||
const collectMetaStrings = (value: unknown): string[] => {
|
||
if (typeof value === "string") {
|
||
return [value];
|
||
}
|
||
if (Array.isArray(value)) {
|
||
return value.flatMap((entry) => collectMetaStrings(entry));
|
||
}
|
||
if (value && typeof value === "object") {
|
||
return Object.values(value).flatMap((entry) => collectMetaStrings(entry));
|
||
}
|
||
return [];
|
||
};
|
||
|
||
const queue: unknown[] = [error];
|
||
const visited = new Set<unknown>();
|
||
|
||
while (queue.length > 0) {
|
||
const current = queue.shift();
|
||
if (current === undefined || current === null || visited.has(current)) {
|
||
continue;
|
||
}
|
||
visited.add(current);
|
||
|
||
if (current instanceof Prisma.PrismaClientKnownRequestError) {
|
||
const metaText = Object.values(current.meta ?? {})
|
||
.flatMap((value) => collectMetaStrings(value))
|
||
.join(" ");
|
||
return {
|
||
code: current.code,
|
||
message: current.message,
|
||
metaText,
|
||
};
|
||
}
|
||
|
||
if (typeof current !== "object") {
|
||
continue;
|
||
}
|
||
|
||
const candidate = current as {
|
||
code?: unknown;
|
||
message?: unknown;
|
||
meta?: Record<string, unknown>;
|
||
cause?: unknown;
|
||
};
|
||
|
||
if (typeof candidate.code === "string" && /^P\d{4}$/.test(candidate.code)) {
|
||
const metaText = Object.values(candidate.meta ?? {})
|
||
.flatMap((value) => collectMetaStrings(value))
|
||
.join(" ");
|
||
|
||
return {
|
||
code: candidate.code,
|
||
message: typeof candidate.message === "string" ? candidate.message : "",
|
||
metaText,
|
||
};
|
||
}
|
||
|
||
if ("cause" in candidate) {
|
||
queue.push(candidate.cause);
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function getTrpcErrorMetadata(error: unknown): {
|
||
code: string;
|
||
message: string;
|
||
} | null {
|
||
const queue: unknown[] = [error];
|
||
const visited = new Set<unknown>();
|
||
|
||
while (queue.length > 0) {
|
||
const current = queue.shift();
|
||
if (current === undefined || current === null || visited.has(current)) {
|
||
continue;
|
||
}
|
||
visited.add(current);
|
||
|
||
if (current instanceof TRPCError) {
|
||
return {
|
||
code: current.code,
|
||
message: current.message,
|
||
};
|
||
}
|
||
|
||
if (typeof current !== "object") {
|
||
continue;
|
||
}
|
||
|
||
const candidate = current as {
|
||
code?: unknown;
|
||
message?: unknown;
|
||
cause?: unknown;
|
||
data?: { code?: unknown };
|
||
shape?: { code?: unknown; message?: unknown };
|
||
};
|
||
|
||
const candidateCode = typeof candidate.code === "string"
|
||
? candidate.code
|
||
: typeof candidate.data?.code === "string"
|
||
? candidate.data.code
|
||
: typeof candidate.shape?.code === "string"
|
||
? candidate.shape.code
|
||
: null;
|
||
const candidateMessage = typeof candidate.message === "string"
|
||
? candidate.message
|
||
: typeof candidate.shape?.message === "string"
|
||
? candidate.shape.message
|
||
: "";
|
||
|
||
if (candidateCode && /^[A-Z_]+$/.test(candidateCode)) {
|
||
return {
|
||
code: candidateCode,
|
||
message: candidateMessage,
|
||
};
|
||
}
|
||
|
||
if ("cause" in candidate) {
|
||
queue.push(candidate.cause);
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function toAssistantNotificationCreationError(
|
||
error: unknown,
|
||
context: "notification" | "task" | "broadcast",
|
||
): AssistantToolErrorResult | null {
|
||
const trpcError = getTrpcErrorMetadata(error);
|
||
|
||
if (
|
||
context === "broadcast"
|
||
&& trpcError?.code === "BAD_REQUEST"
|
||
&& trpcError.message === "No recipients matched the broadcast target."
|
||
) {
|
||
return { error: "No recipients matched the broadcast target." };
|
||
}
|
||
|
||
if (trpcError?.code === "NOT_FOUND") {
|
||
if (trpcError.message.includes("broadcast")) {
|
||
return { error: "Broadcast not found with the given criteria." };
|
||
}
|
||
if (trpcError.message.includes("Sender user not found")) {
|
||
return { error: "Sender user not found with the given criteria." };
|
||
}
|
||
if (trpcError.message.includes("Assignee user not found")) {
|
||
return { error: "Assignee user not found with the given criteria." };
|
||
}
|
||
if (trpcError.message.includes("recipient")) {
|
||
return context === "broadcast"
|
||
? { error: "Broadcast recipient user not found with the given criteria." }
|
||
: context === "task"
|
||
? { error: "Task recipient user not found with the given criteria." }
|
||
: { error: "Notification recipient user not found with the given criteria." };
|
||
}
|
||
}
|
||
|
||
const prismaError = getPrismaRequestErrorMetadata(error);
|
||
if (!prismaError) {
|
||
return null;
|
||
}
|
||
|
||
if (prismaError.code !== "P2003" && prismaError.code !== "P2025") {
|
||
return null;
|
||
}
|
||
|
||
const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase();
|
||
|
||
if (errorText.includes("assignee")) {
|
||
return { error: "Assignee user not found with the given criteria." };
|
||
}
|
||
|
||
if (errorText.includes("sender")) {
|
||
return { error: "Sender user not found with the given criteria." };
|
||
}
|
||
|
||
if (context === "broadcast" && (errorText.includes("notificationbroadcast") || errorText.includes("broadcast"))) {
|
||
return { error: "Broadcast not found with the given criteria." };
|
||
}
|
||
|
||
if (context === "task") {
|
||
return { error: "Task recipient user not found with the given criteria." };
|
||
}
|
||
|
||
if (context === "broadcast") {
|
||
return { error: "Broadcast recipient user not found with the given criteria." };
|
||
}
|
||
|
||
return { error: "Notification recipient user not found with the given criteria." };
|
||
}
|
||
|
||
function normalizeAssistantExecutionError(
|
||
error: unknown,
|
||
): AssistantToolErrorResult {
|
||
if (error instanceof AssistantVisibleError) {
|
||
return { error: error.message };
|
||
}
|
||
|
||
const trpcError = getTrpcErrorMetadata(error);
|
||
if (trpcError) {
|
||
if (trpcError.code === "INTERNAL_SERVER_ERROR") {
|
||
return {
|
||
error: "The tool could not complete due to an internal error.",
|
||
};
|
||
}
|
||
|
||
if (trpcError.code === "UNAUTHORIZED") {
|
||
return {
|
||
error: "Authentication is required to use this tool.",
|
||
};
|
||
}
|
||
|
||
if (trpcError.code === "FORBIDDEN") {
|
||
return {
|
||
error: "You do not have permission to perform this action.",
|
||
};
|
||
}
|
||
|
||
return { error: "The tool could not complete due to a request error." };
|
||
}
|
||
|
||
if (error instanceof Error) {
|
||
return { error: "The tool could not complete due to an unexpected error." };
|
||
}
|
||
|
||
return { error: "The tool could not complete due to an unexpected error." };
|
||
}
|
||
|
||
function isAssistantToolErrorResult(
|
||
value: unknown,
|
||
): value is AssistantToolErrorResult {
|
||
return value !== null && typeof value === "object" && "error" in value;
|
||
}
|
||
|
||
function toAssistantIndexedFieldError(
|
||
index: number,
|
||
field: string,
|
||
message: string,
|
||
): AssistantIndexedFieldErrorResult {
|
||
return {
|
||
error: `assignments[${index}].${field}: ${message}`,
|
||
field: `assignments[${index}].${field}`,
|
||
index,
|
||
};
|
||
}
|
||
|
||
async function resolveEntityOrAssistantError<T>(
|
||
resolve: () => Promise<T>,
|
||
notFoundMessage: string,
|
||
): Promise<T | AssistantToolErrorResult> {
|
||
try {
|
||
return await resolve();
|
||
} catch (error) {
|
||
const mapped = toAssistantNotFoundError(error, notFoundMessage);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
if (error instanceof TRPCError && error.code === "INTERNAL_SERVER_ERROR") {
|
||
return normalizeAssistantExecutionError(error);
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async function resolveProjectIdentifier(
|
||
ctx: ToolContext,
|
||
identifier: string,
|
||
) {
|
||
const caller = createProjectCaller(createScopedCallerContext(ctx));
|
||
return resolveEntityOrAssistantError(
|
||
() => caller.resolveByIdentifier({ identifier }),
|
||
`Project not found: ${identifier}`,
|
||
);
|
||
}
|
||
|
||
async function resolveResourceIdentifier(
|
||
ctx: ToolContext,
|
||
identifier: string,
|
||
) {
|
||
const caller = createResourceCaller(createScopedCallerContext(ctx));
|
||
return resolveEntityOrAssistantError(
|
||
() => caller.resolveByIdentifier({ identifier }),
|
||
`Resource not found: ${identifier}`,
|
||
);
|
||
}
|
||
|
||
function createScopedCallerContext(ctx: ToolContext): TRPCContext {
|
||
if (!ctx.session?.user || !ctx.dbUser) {
|
||
throw new AssistantVisibleError("Authenticated assistant context is required for this tool.");
|
||
}
|
||
|
||
return {
|
||
session: ctx.session,
|
||
db: ctx.db,
|
||
dbUser: ctx.dbUser,
|
||
roleDefaults: ctx.roleDefaults ?? null,
|
||
};
|
||
}
|
||
|
||
function sanitizeWebhook<T extends { secret?: string | null }>(webhook: T) {
|
||
const { secret: _secret, ...rest } = webhook;
|
||
return {
|
||
...rest,
|
||
hasSecret: Boolean(webhook.secret),
|
||
};
|
||
}
|
||
|
||
function sanitizeWebhookList<T extends { secret?: string | null }>(webhooks: T[]) {
|
||
return webhooks.map((webhook) => sanitizeWebhook(webhook));
|
||
}
|
||
|
||
// ─── Tool Definitions ───────────────────────────────────────────────────────
|
||
|
||
export const TOOL_DEFINITIONS: ToolDef[] = [
|
||
// ── 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. 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"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "search_projects",
|
||
description: "Search for projects by name, short code, status, or client.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
query: { type: "string", description: "Search term (matches name, shortCode)" },
|
||
status: { type: "string", description: "Filter by status: DRAFT, ACTIVE, ON_HOLD, COMPLETED, CANCELLED" },
|
||
limit: { type: "integer", description: "Max results. Default: 20" },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_project",
|
||
description: "Get detailed information about a single project by ID or short code, including top allocations.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
identifier: { type: "string", description: "Project ID or short code (e.g. Z033T593)" },
|
||
},
|
||
required: ["identifier"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "find_best_project_resource",
|
||
description: "Advanced assistant tool: find the best already-assigned resource on a project for a given period, ranked by remaining capacity or LCR. Holiday- and vacation-aware. Requires viewCosts and advanced assistant permissions.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
projectIdentifier: { type: "string", description: "Project ID, short code, or project name." },
|
||
startDate: { type: "string", description: "Optional start date in YYYY-MM-DD. Default: today." },
|
||
endDate: { type: "string", description: "Optional end date in YYYY-MM-DD. If omitted, durationDays is used." },
|
||
durationDays: { type: "integer", description: "Optional duration in calendar days when endDate is omitted. Default: 21." },
|
||
minHoursPerDay: { type: "number", description: "Minimum remaining availability per effective working day. Default: 3." },
|
||
rankingMode: { type: "string", description: "Ranking mode: lowest_lcr, highest_remaining_hours_per_day, or highest_remaining_hours. Default: lowest_lcr." },
|
||
chapter: { type: "string", description: "Optional chapter filter for candidate resources." },
|
||
roleName: { type: "string", description: "Optional role filter for candidate resources." },
|
||
},
|
||
required: ["projectIdentifier"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_timeline_entries_view",
|
||
description: "Advanced assistant tool: read-only timeline entries view with the same timeline/disposition readmodel used by the app. Returns allocations, demands, assignments, and matching holiday overlays for a period.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
startDate: { type: "string", description: "Optional start date in YYYY-MM-DD. Default: today." },
|
||
endDate: { type: "string", description: "Optional end date in YYYY-MM-DD. If omitted, durationDays is used." },
|
||
durationDays: { type: "integer", description: "Optional duration in calendar days when endDate is omitted. Default: 21." },
|
||
resourceIds: { type: "array", items: { type: "string" }, description: "Optional resource IDs to scope the view." },
|
||
projectIds: { type: "array", items: { type: "string" }, description: "Optional project IDs to scope the view." },
|
||
clientIds: { type: "array", items: { type: "string" }, description: "Optional client IDs to scope the view." },
|
||
chapters: { type: "array", items: { type: "string" }, description: "Optional chapter filters." },
|
||
eids: { type: "array", items: { type: "string" }, description: "Optional employee IDs to scope the view." },
|
||
countryCodes: { type: "array", items: { type: "string" }, description: "Optional country codes such as DE or ES." },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_timeline_holiday_overlays",
|
||
description: "Advanced assistant tool: read-only holiday overlays for the timeline, resolved with the same holiday logic as the app. Useful to explain regional holiday differences for assigned or filtered resources.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
startDate: { type: "string", description: "Optional start date in YYYY-MM-DD. Default: today." },
|
||
endDate: { type: "string", description: "Optional end date in YYYY-MM-DD. If omitted, durationDays is used." },
|
||
durationDays: { type: "integer", description: "Optional duration in calendar days when endDate is omitted. Default: 21." },
|
||
resourceIds: { type: "array", items: { type: "string" }, description: "Optional resource IDs to scope the overlays." },
|
||
projectIds: { type: "array", items: { type: "string" }, description: "Optional project IDs to scope the overlays via matching assignments." },
|
||
clientIds: { type: "array", items: { type: "string" }, description: "Optional client IDs to scope the overlays via matching projects." },
|
||
chapters: { type: "array", items: { type: "string" }, description: "Optional chapter filters." },
|
||
eids: { type: "array", items: { type: "string" }, description: "Optional employee IDs to scope the overlays." },
|
||
countryCodes: { type: "array", items: { type: "string" }, description: "Optional country codes such as DE or ES." },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_project_timeline_context",
|
||
description: "Advanced assistant tool: read-only project timeline/disposition context. Reuses the same project context readmodel as the app and adds holiday overlays plus cross-project overlap summaries for assigned resources.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
projectIdentifier: { type: "string", description: "Project ID, short code, or project name." },
|
||
startDate: { type: "string", description: "Optional holiday/conflict window start date in YYYY-MM-DD. Defaults to the project start date when available." },
|
||
endDate: { type: "string", description: "Optional holiday/conflict window end date in YYYY-MM-DD. Defaults to the project end date when available." },
|
||
durationDays: { type: "integer", description: "Optional duration in calendar days when endDate is omitted." },
|
||
},
|
||
required: ["projectIdentifier"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "preview_project_shift",
|
||
description: "Advanced assistant tool: read-only preview of the timeline shift validation for a project. Uses the same preview logic as the timeline router and does not write changes.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
projectIdentifier: { type: "string", description: "Project ID, short code, or project name." },
|
||
newStartDate: { type: "string", description: "New start date in YYYY-MM-DD." },
|
||
newEndDate: { type: "string", description: "New end date in YYYY-MM-DD." },
|
||
},
|
||
required: ["projectIdentifier", "newStartDate", "newEndDate"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "update_timeline_allocation_inline",
|
||
description: "Advanced assistant mutation: update a timeline allocation inline with the same manager/admin + manageAllocations validation as the timeline API. Supports hours/day, dates, includeSaturday, and role changes. Requires useAssistantAdvancedTools.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
allocationId: { type: "string", description: "Allocation, assignment, or demand row ID to update." },
|
||
hoursPerDay: { type: "number", description: "Optional new booked hours per day." },
|
||
startDate: { type: "string", description: "Optional new start date in YYYY-MM-DD." },
|
||
endDate: { type: "string", description: "Optional new end date in YYYY-MM-DD." },
|
||
includeSaturday: { type: "boolean", description: "Optional Saturday-working flag stored in metadata." },
|
||
role: { type: "string", description: "Optional new role label." },
|
||
},
|
||
required: ["allocationId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "apply_timeline_project_shift",
|
||
description: "Advanced assistant mutation: apply the real timeline project shift mutation, including validation, date movement, cost recalculation, audit logging, and SSE. Requires manager/admin, manageAllocations, and useAssistantAdvancedTools.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
projectIdentifier: { type: "string", description: "Project ID, short code, or project name." },
|
||
newStartDate: { type: "string", description: "New project start date in YYYY-MM-DD." },
|
||
newEndDate: { type: "string", description: "New project end date in YYYY-MM-DD." },
|
||
},
|
||
required: ["projectIdentifier", "newStartDate", "newEndDate"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "quick_assign_timeline_resource",
|
||
description: "Advanced assistant mutation: create a timeline quick-assignment with the same manager/admin + manageAllocations rules as the timeline UI. Resolves resource and project identifiers before calling the real mutation. Requires useAssistantAdvancedTools.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
resourceIdentifier: { type: "string", description: "Resource ID, eid, or display name." },
|
||
projectIdentifier: { type: "string", description: "Project ID, short code, or project name." },
|
||
startDate: { type: "string", description: "Start date in YYYY-MM-DD." },
|
||
endDate: { type: "string", description: "End date in YYYY-MM-DD." },
|
||
hoursPerDay: { type: "number", description: "Hours per day. Default: 8." },
|
||
role: { type: "string", description: "Role label. Default: Team Member." },
|
||
roleId: { type: "string", description: "Optional concrete role ID." },
|
||
status: { type: "string", enum: ["PROPOSED", "CONFIRMED", "ACTIVE", "COMPLETED", "CANCELLED"], description: "Assignment status. Default: PROPOSED." },
|
||
},
|
||
required: ["resourceIdentifier", "projectIdentifier", "startDate", "endDate"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "batch_quick_assign_timeline_resources",
|
||
description: "Advanced assistant mutation: batch-create timeline quick-assignments using the same timeline router logic, permission checks, and audit/SSE side effects as the app. Requires manager/admin, manageAllocations, and useAssistantAdvancedTools.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
assignments: {
|
||
type: "array",
|
||
minItems: 1,
|
||
maxItems: 50,
|
||
items: {
|
||
type: "object",
|
||
properties: {
|
||
resourceIdentifier: { type: "string", description: "Resource ID, eid, or display name." },
|
||
projectIdentifier: { type: "string", description: "Project ID, short code, or project name." },
|
||
startDate: { type: "string", description: "Start date in YYYY-MM-DD." },
|
||
endDate: { type: "string", description: "End date in YYYY-MM-DD." },
|
||
hoursPerDay: { type: "number", description: "Hours per day. Default: 8." },
|
||
role: { type: "string", description: "Role label. Default: Team Member." },
|
||
status: { type: "string", enum: ["PROPOSED", "CONFIRMED", "ACTIVE", "COMPLETED", "CANCELLED"], description: "Assignment status. Default: PROPOSED." },
|
||
},
|
||
required: ["resourceIdentifier", "projectIdentifier", "startDate", "endDate"],
|
||
},
|
||
description: "Assignment rows to create in one batch.",
|
||
},
|
||
},
|
||
required: ["assignments"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "batch_shift_timeline_allocations",
|
||
description: "Advanced assistant mutation: shift multiple timeline allocations by a shared day delta using the real timeline batch move/resize mutation. Requires manager/admin, manageAllocations, and useAssistantAdvancedTools.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
allocationIds: { type: "array", items: { type: "string" }, description: "Allocation IDs to shift." },
|
||
daysDelta: { type: "integer", description: "Signed day delta to apply." },
|
||
mode: { type: "string", enum: ["move", "resize-start", "resize-end"], description: "Shift mode. Default: move." },
|
||
},
|
||
required: ["allocationIds", "daysDelta"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_allocations",
|
||
description: "List assignments/allocations, optionally filtered by resource or project. Shows who is assigned where, hours/day, dates, and cost.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
resourceId: { type: "string", description: "Filter by resource ID" },
|
||
projectId: { type: "string", description: "Filter by project ID" },
|
||
resourceName: { type: "string", description: "Filter by resource name (partial match)" },
|
||
projectCode: { type: "string", description: "Filter by project short code (partial match)" },
|
||
status: { type: "string", description: "Filter by status: PROPOSED, CONFIRMED, ACTIVE, COMPLETED, CANCELLED" },
|
||
limit: { type: "integer", description: "Max results. Default: 30" },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_budget_status",
|
||
description: "Get the budget status of a project: total budget, confirmed/proposed costs, remaining, utilization percentage.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
projectId: { type: "string", description: "Project ID or short code" },
|
||
},
|
||
required: ["projectId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_vacation_balance",
|
||
description: "Get the holiday-aware vacation balance for a resource via the real entitlement workflow. Authenticated users can read their own balance; manager/admin/controller can read broader balances.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
resourceId: { type: "string", description: "Resource ID, EID, or display name" },
|
||
year: { type: "integer", description: "Year. Default: current year" },
|
||
},
|
||
required: ["resourceId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_vacations_upcoming",
|
||
description: "List upcoming vacations across all resources, or for a specific resource/team. Shows who is off when.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
resourceName: { type: "string", description: "Filter by resource name (partial match)" },
|
||
chapter: { type: "string", description: "Filter by chapter/team" },
|
||
daysAhead: { type: "integer", description: "How many days ahead to look. Default: 30" },
|
||
limit: { type: "integer", description: "Max results. Default: 30" },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_holidays_by_region",
|
||
description: "List resolved public holidays for a country, federal state, and optionally a city in a given year or date range. Use this to compare regions such as Bayern vs Hamburg.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
countryCode: { type: "string", description: "Country code such as DE, ES, US, IN." },
|
||
federalState: { type: "string", description: "Federal state / region code, e.g. BY, HH, NRW." },
|
||
metroCity: { type: "string", description: "Optional city name for local city-specific holidays, e.g. Augsburg." },
|
||
year: { type: "integer", description: "Full year, e.g. 2026. Default: current year." },
|
||
periodStart: { type: "string", description: "Optional start date in YYYY-MM-DD. Requires periodEnd." },
|
||
periodEnd: { type: "string", description: "Optional end date in YYYY-MM-DD. Requires periodStart." },
|
||
},
|
||
required: ["countryCode"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_resource_holidays",
|
||
description: "List resolved public holidays for a specific resource based on that person's country, federal state, and city context.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
identifier: { type: "string", description: "Resource ID, EID, or display name." },
|
||
year: { type: "integer", description: "Full year, e.g. 2026. Default: current year." },
|
||
periodStart: { type: "string", description: "Optional start date in YYYY-MM-DD. Requires periodEnd." },
|
||
periodEnd: { type: "string", description: "Optional end date in YYYY-MM-DD. Requires periodStart." },
|
||
},
|
||
required: ["identifier"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_holiday_calendars",
|
||
description: "List holiday calendars including scope, assignment, active flag, priority, and entry count. Useful to inspect the calendar-editor configuration context.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
includeInactive: { type: "boolean", description: "Include inactive calendars. Default: false." },
|
||
countryCode: { type: "string", description: "Optional country code filter such as DE or ES." },
|
||
scopeType: { type: "string", description: "Optional scope filter: COUNTRY, STATE, CITY." },
|
||
stateCode: { type: "string", description: "Optional state/region code filter such as BY or NRW." },
|
||
metroCity: { type: "string", description: "Optional city-name filter." },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_holiday_calendar",
|
||
description: "Get a single holiday calendar including all entries. Accepts either the calendar ID or its name.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
identifier: { type: "string", description: "Holiday calendar ID or name." },
|
||
},
|
||
required: ["identifier"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "preview_resolved_holiday_calendar",
|
||
description: "Preview the resolved holiday result for a country/state/city scope and year, including which calendar each holiday comes from.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
countryId: { type: "string", description: "Country ID." },
|
||
stateCode: { type: "string", description: "Optional state/region code." },
|
||
metroCityId: { type: "string", description: "Optional metro city ID for city-specific preview." },
|
||
year: { type: "integer", description: "Full year, e.g. 2026." },
|
||
},
|
||
required: ["countryId", "year"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "create_holiday_calendar",
|
||
description: "Create a holiday calendar for a country, state, or city scope. Admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
name: { type: "string", description: "Calendar name." },
|
||
scopeType: { type: "string", description: "COUNTRY, STATE, or CITY." },
|
||
countryId: { type: "string", description: "Country ID." },
|
||
stateCode: { type: "string", description: "Required for STATE calendars." },
|
||
metroCityId: { type: "string", description: "Required for CITY calendars." },
|
||
isActive: { type: "boolean", description: "Whether the calendar is active. Default: true." },
|
||
priority: { type: "integer", description: "Priority used during calendar resolution. Default: 0." },
|
||
},
|
||
required: ["name", "scopeType", "countryId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "update_holiday_calendar",
|
||
description: "Update an existing holiday calendar. Admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
id: { type: "string", description: "Holiday calendar ID." },
|
||
data: {
|
||
type: "object",
|
||
properties: {
|
||
name: { type: "string" },
|
||
stateCode: { type: "string" },
|
||
metroCityId: { type: "string" },
|
||
isActive: { type: "boolean" },
|
||
priority: { type: "integer" },
|
||
},
|
||
},
|
||
},
|
||
required: ["id", "data"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "delete_holiday_calendar",
|
||
description: "Delete a holiday calendar and all of its entries. Admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
id: { type: "string", description: "Holiday calendar ID." },
|
||
},
|
||
required: ["id"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "create_holiday_calendar_entry",
|
||
description: "Create a holiday entry in an existing calendar. Admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
holidayCalendarId: { type: "string", description: "Holiday calendar ID." },
|
||
date: { type: "string", description: "Date in YYYY-MM-DD format." },
|
||
name: { type: "string", description: "Holiday name." },
|
||
isRecurringAnnual: { type: "boolean", description: "Whether the holiday repeats every year." },
|
||
source: { type: "string", description: "Optional source or legal basis." },
|
||
},
|
||
required: ["holidayCalendarId", "date", "name"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "update_holiday_calendar_entry",
|
||
description: "Update an existing holiday calendar entry. Admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
id: { type: "string", description: "Holiday calendar entry ID." },
|
||
data: {
|
||
type: "object",
|
||
properties: {
|
||
date: { type: "string", description: "Date in YYYY-MM-DD format." },
|
||
name: { type: "string" },
|
||
isRecurringAnnual: { type: "boolean" },
|
||
source: { type: "string" },
|
||
},
|
||
},
|
||
},
|
||
required: ["id", "data"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "delete_holiday_calendar_entry",
|
||
description: "Delete a holiday calendar entry. Admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
id: { type: "string", description: "Holiday calendar entry ID." },
|
||
},
|
||
required: ["id"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_roles",
|
||
description: "List all available roles with their colors.",
|
||
parameters: { type: "object", properties: {} },
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "search_by_skill",
|
||
description: "Find resources that have a specific skill.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
skill: { type: "string", description: "Skill name to search for" },
|
||
},
|
||
required: ["skill"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_statistics",
|
||
description: "Get overview statistics: total resources, projects, active allocations, budget summary, projects by status, chapter breakdown.",
|
||
parameters: { type: "object", properties: {} },
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_chargeability",
|
||
description: "Get chargeability data for a resource in a given month: hours booked vs available, chargeability %, target comparison.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
resourceId: { type: "string", description: "Resource ID, eid, or name" },
|
||
month: { type: "string", description: "Month in YYYY-MM format, e.g. 2026-03. Default: current month" },
|
||
},
|
||
required: ["resourceId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
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"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "search_estimates",
|
||
description: "Search for estimates (cost/effort estimates) by project or name. Returns estimate name, status, version count.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
projectCode: { type: "string", description: "Project short code to filter by" },
|
||
query: { type: "string", description: "Search term (matches estimate name)" },
|
||
status: { type: "string", description: "Filter by status: DRAFT, IN_REVIEW, APPROVED, ARCHIVED" },
|
||
limit: { type: "integer", description: "Max results. Default: 20" },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_clients",
|
||
description: "List clients/customers. Can search by name or code.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
query: { type: "string", description: "Search term (matches name or code)" },
|
||
limit: { type: "integer", description: "Max results. Default: 20" },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_org_units",
|
||
description: "List organizational units (departments, teams) with their hierarchy.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
level: { type: "integer", description: "Filter by org level (5, 6, or 7)" },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
|
||
// ── NAVIGATION TOOLS ──
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "navigate_to_page",
|
||
description: "Navigate the user to a specific page in CapaKraken, optionally with filters. Use this when the user wants to see data on a specific page (e.g. 'show me on the timeline', 'open the resources page').",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
page: {
|
||
type: "string",
|
||
description: "Page name: timeline, dashboard, resources, projects, allocations, staffing, estimates, vacations, my-vacations, roles, skills-analytics, chargeability, computation-graph",
|
||
},
|
||
eids: { type: "string", description: "Comma-separated employee IDs to filter (for timeline)" },
|
||
chapters: { type: "string", description: "Comma-separated chapters to filter (for timeline)" },
|
||
projectIds: { type: "string", description: "Comma-separated project IDs to filter (for timeline)" },
|
||
clientIds: { type: "string", description: "Comma-separated client IDs to filter (for timeline)" },
|
||
countryCodes: { type: "string", description: "Comma-separated country codes to filter (e.g. 'ES,DE' for Spain and Germany, for timeline)" },
|
||
startDate: { type: "string", description: "Start date YYYY-MM-DD (for timeline)" },
|
||
days: { type: "integer", description: "Number of days to show (for timeline)" },
|
||
},
|
||
required: ["page"],
|
||
},
|
||
},
|
||
},
|
||
|
||
// ── WRITE TOOLS ──
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "create_allocation",
|
||
description: "Create a new allocation/booking for a resource on a project. Requires manageAllocations permission. Always confirm with the user before calling this. Created with PROPOSED status.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
resourceId: { type: "string", description: "Resource ID" },
|
||
projectId: { type: "string", description: "Project ID" },
|
||
startDate: { type: "string", description: "Start date YYYY-MM-DD" },
|
||
endDate: { type: "string", description: "End date YYYY-MM-DD" },
|
||
hoursPerDay: { type: "number", description: "Hours per day (e.g. 8)" },
|
||
role: { type: "string", description: "Optional role name" },
|
||
},
|
||
required: ["resourceId", "projectId", "startDate", "endDate", "hoursPerDay"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "cancel_allocation",
|
||
description: "Cancel an existing allocation. Can find by allocation ID, or by resource name + project code + date range. Requires manageAllocations permission. Always confirm with the user before calling this.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
allocationId: { type: "string", description: "Allocation ID (if known)" },
|
||
resourceName: { type: "string", description: "Resource name (partial match)" },
|
||
projectCode: { type: "string", description: "Project short code (partial match)" },
|
||
startDate: { type: "string", description: "Filter by start date YYYY-MM-DD" },
|
||
endDate: { type: "string", description: "Filter by end date YYYY-MM-DD" },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
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: "update_allocation_status",
|
||
description: "Change the status of an existing allocation. Can reactivate cancelled allocations, confirm proposed ones, etc. Requires manageAllocations permission. Always confirm with the user before calling.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
allocationId: { type: "string", description: "Allocation ID" },
|
||
resourceName: { type: "string", description: "Resource name (partial match, used if no allocationId)" },
|
||
projectCode: { type: "string", description: "Project short code (partial match, used if no allocationId)" },
|
||
startDate: { type: "string", description: "Start date filter YYYY-MM-DD (used if no allocationId)" },
|
||
newStatus: { type: "string", description: "New status: PROPOSED, CONFIRMED, ACTIVE, COMPLETED, CANCELLED" },
|
||
},
|
||
required: ["newStatus"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "update_project",
|
||
description: "Update a project's details. Requires manageProjects permission. Always confirm with the user before calling this.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
id: { type: "string", description: "Project ID, short code, or project name" },
|
||
name: { type: "string", description: "New project name" },
|
||
budgetCents: { type: "integer", description: "New budget in cents (e.g. 10000000 = 100,000 EUR)" },
|
||
winProbability: { type: "integer", description: "Win probability 0-100" },
|
||
status: { type: "string", description: "New status: DRAFT, ACTIVE, ON_HOLD, COMPLETED, CANCELLED" },
|
||
responsiblePerson: { type: "string", description: "Name of the responsible person. Must match an existing resource's display name (case-insensitive search)." },
|
||
},
|
||
required: ["id"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "create_project",
|
||
description: "Create a new project. Requires manageProjects permission. Always confirm with the user before calling this. The project is created in DRAFT status by default.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
shortCode: { type: "string", description: "Unique project code, uppercase alphanumeric with hyphens/underscores (e.g. 'PROJ-001')" },
|
||
name: { type: "string", description: "Project name" },
|
||
orderType: { type: "string", description: "Order type: BD, CHARGEABLE, INTERNAL, OVERHEAD" },
|
||
allocationType: { type: "string", description: "Allocation type: INT or EXT. Default: INT" },
|
||
budgetCents: { type: "integer", description: "Budget in cents (e.g. 10000000 = 100,000 EUR)" },
|
||
startDate: { type: "string", description: "Start date (YYYY-MM-DD)" },
|
||
endDate: { type: "string", description: "End date (YYYY-MM-DD)" },
|
||
winProbability: { type: "integer", description: "Win probability 0-100. Default: 100" },
|
||
status: { type: "string", description: "Initial status: DRAFT, ACTIVE, ON_HOLD. Default: DRAFT" },
|
||
responsiblePerson: { type: "string", description: "Name of the responsible person. Must match an existing resource's display name (case-insensitive search)." },
|
||
color: { type: "string", description: "Hex color (e.g. '#3b82f6')" },
|
||
blueprintName: { type: "string", description: "Blueprint name to look up and attach (partial match)" },
|
||
clientName: { type: "string", description: "Client name to look up and attach (partial match)" },
|
||
},
|
||
required: ["shortCode", "name", "orderType", "budgetCents", "startDate", "endDate", "responsiblePerson"],
|
||
},
|
||
},
|
||
},
|
||
|
||
// ── 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",
|
||
function: {
|
||
name: "create_vacation",
|
||
description: "Create a vacation/leave request through the real vacation workflow. Any authenticated user can request leave for their own resource; manager/admin can create requests for others. Always confirm with the user.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
resourceId: { type: "string", description: "Resource ID, EID, or display name" },
|
||
type: {
|
||
type: "string",
|
||
enum: ["ANNUAL", "SICK", "OTHER"],
|
||
description: "Vacation type. PUBLIC_HOLIDAY requests are managed through holiday calendars, not manual vacation requests.",
|
||
},
|
||
startDate: { type: "string", description: "Start date YYYY-MM-DD" },
|
||
endDate: { type: "string", description: "End date YYYY-MM-DD" },
|
||
isHalfDay: { type: "boolean", description: "Half day? Default: false" },
|
||
halfDayPart: { type: "string", description: "MORNING or AFTERNOON (if half day)" },
|
||
note: { type: "string", description: "Optional note" },
|
||
},
|
||
required: ["resourceId", "type", "startDate", "endDate"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "approve_vacation",
|
||
description: "Approve a vacation request through the real vacation workflow. Manager or admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
vacationId: { type: "string", description: "Vacation ID" },
|
||
},
|
||
required: ["vacationId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "reject_vacation",
|
||
description: "Reject a pending vacation request through the real vacation workflow. Manager or admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
vacationId: { type: "string", description: "Vacation ID" },
|
||
reason: { type: "string", description: "Rejection reason" },
|
||
},
|
||
required: ["vacationId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "cancel_vacation",
|
||
description: "Cancel a vacation request through the real vacation workflow. Users can cancel their own requests; manager/admin can cancel any request. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
vacationId: { type: "string", description: "Vacation ID" },
|
||
},
|
||
required: ["vacationId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_pending_vacation_approvals",
|
||
description: "List vacation requests awaiting approval.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
limit: { type: "integer", description: "Max results. Default: 20" },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_team_vacation_overlap",
|
||
description: "Check if team members have overlapping vacations in a date range.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
resourceId: { type: "string", description: "Resource ID to check overlap for" },
|
||
startDate: { type: "string", description: "Start date YYYY-MM-DD" },
|
||
endDate: { type: "string", description: "End date YYYY-MM-DD" },
|
||
},
|
||
required: ["resourceId", "startDate", "endDate"],
|
||
},
|
||
},
|
||
},
|
||
|
||
// ── ENTITLEMENT ──
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_entitlement_summary",
|
||
description: "Get vacation entitlement year summary for all resources or a specific resource.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
year: { type: "integer", description: "Year. Default: current year" },
|
||
resourceName: { type: "string", description: "Filter by resource name (optional)" },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "set_entitlement",
|
||
description: "Set the annual vacation entitlement for a resource/year through the real entitlement workflow. Manager or admin role required. Carryover is computed automatically. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
resourceId: { type: "string", description: "Resource ID, EID, or display name" },
|
||
year: { type: "integer", description: "Year" },
|
||
entitledDays: { type: "number", description: "Number of entitled vacation days" },
|
||
},
|
||
required: ["resourceId", "year", "entitledDays"],
|
||
},
|
||
},
|
||
},
|
||
|
||
// ── DEMAND / STAFFING ──
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_demands",
|
||
description: "List staffing demand requirements for projects. Shows open positions that need to be filled.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
projectId: { type: "string", description: "Filter by project ID or short code" },
|
||
status: { type: "string", description: "Filter by status: OPEN, PARTIALLY_FILLED, FILLED, CANCELLED" },
|
||
limit: { type: "integer", description: "Max results. Default: 30" },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "create_demand",
|
||
description: "Create a staffing demand requirement on a project. Requires manageAllocations permission. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
projectId: { type: "string", description: "Project ID or short code" },
|
||
roleName: { type: "string", description: "Role name for the demand" },
|
||
headcount: { type: "integer", description: "Number of people needed. Default: 1" },
|
||
hoursPerDay: { type: "number", description: "Hours per day required" },
|
||
startDate: { type: "string", description: "Start date YYYY-MM-DD" },
|
||
endDate: { type: "string", description: "End date YYYY-MM-DD" },
|
||
},
|
||
required: ["projectId", "roleName", "hoursPerDay", "startDate", "endDate"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "fill_demand",
|
||
description: "Fill/assign a resource to an open demand requirement. Requires manageAllocations permission. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
demandId: { type: "string", description: "Demand requirement ID" },
|
||
resourceId: { type: "string", description: "Resource ID or name to assign" },
|
||
},
|
||
required: ["demandId", "resourceId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "check_resource_availability",
|
||
description: "Check if a resource is available in a given date range (no conflicting allocations or vacations).",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
resourceId: { type: "string", description: "Resource ID, eid, or name" },
|
||
startDate: { type: "string", description: "Start date YYYY-MM-DD" },
|
||
endDate: { type: "string", description: "End date YYYY-MM-DD" },
|
||
},
|
||
required: ["resourceId", "startDate", "endDate"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_staffing_suggestions",
|
||
description: "Get AI-powered staffing suggestions for a project based on required skills, availability, and cost.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
projectId: { type: "string", description: "Project ID or short code" },
|
||
roleName: { type: "string", description: "Role to find candidates for" },
|
||
startDate: { type: "string", description: "Start date YYYY-MM-DD" },
|
||
endDate: { type: "string", description: "End date YYYY-MM-DD" },
|
||
limit: { type: "integer", description: "Max suggestions. Default: 5" },
|
||
},
|
||
required: ["projectId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "find_capacity",
|
||
description: "Find resources with available capacity in a date range.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
startDate: { type: "string", description: "Start date YYYY-MM-DD" },
|
||
endDate: { type: "string", description: "End date YYYY-MM-DD" },
|
||
minHoursPerDay: { type: "number", description: "Minimum available hours/day. Default: 4" },
|
||
roleName: { type: "string", description: "Filter by role name" },
|
||
chapter: { type: "string", description: "Filter by chapter" },
|
||
limit: { type: "integer", description: "Max results. Default: 20" },
|
||
},
|
||
required: ["startDate", "endDate"],
|
||
},
|
||
},
|
||
},
|
||
|
||
// ── BLUEPRINT ──
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_blueprints",
|
||
description: "List available project blueprints with their field definitions.",
|
||
parameters: { type: "object", properties: {} },
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_blueprint",
|
||
description: "Get detailed blueprint with all field definitions and role presets.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
identifier: { type: "string", description: "Blueprint ID or name (partial match)" },
|
||
},
|
||
required: ["identifier"],
|
||
},
|
||
},
|
||
},
|
||
|
||
// ── RATE CARDS ──
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_rate_cards",
|
||
description: "List rate cards with their effective dates and line items.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
query: { type: "string", description: "Search by name" },
|
||
limit: { type: "integer", description: "Max results. Default: 20" },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "resolve_rate",
|
||
description: "Look up the applicable rate for a resource, role, or management level from rate cards.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
resourceId: { type: "string", description: "Resource ID or name" },
|
||
roleName: { type: "string", description: "Role name" },
|
||
date: { type: "string", description: "Date to check rate for (YYYY-MM-DD). Default: today" },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
|
||
// ── ESTIMATES ──
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_estimate_detail",
|
||
description: "Get one estimate via the real estimate router, including versions, demand lines, metrics, assumptions, and linked project data. Controller/manager/admin access and viewCosts required.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
estimateId: { type: "string", description: "Estimate ID" },
|
||
},
|
||
required: ["estimateId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_estimate_versions",
|
||
description: "List estimate versions via the real estimate router, including status, timestamps, and artifact counts. Controller/manager/admin access required.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
estimateId: { type: "string", description: "Estimate ID" },
|
||
},
|
||
required: ["estimateId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_estimate_version_snapshot",
|
||
description: "Get an estimate version snapshot via the real estimate router, including totals, breakdowns, exports, and resource snapshots. Controller/manager/admin access and viewCosts required.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
estimateId: { type: "string", description: "Estimate ID" },
|
||
versionId: { type: "string", description: "Optional explicit version ID. Defaults to the latest version." },
|
||
},
|
||
required: ["estimateId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "create_estimate",
|
||
description: "Create a new estimate via the real estimate router. Manager/admin role and manageProjects permission required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
projectId: { type: "string", description: "Optional project ID." },
|
||
projectCode: { type: "string", description: "Optional project short code convenience alias." },
|
||
name: { type: "string", description: "Estimate name." },
|
||
opportunityId: { type: "string", description: "Optional opportunity/reference ID." },
|
||
baseCurrency: { type: "string", description: "Base currency, e.g. EUR." },
|
||
status: { type: "string", enum: ["DRAFT", "IN_REVIEW", "APPROVED", "ARCHIVED"] },
|
||
versionLabel: { type: "string", description: "Optional working version label." },
|
||
versionNotes: { type: "string", description: "Optional working version notes." },
|
||
assumptions: { type: "array", items: { type: "object" }, description: "Estimate assumptions." },
|
||
scopeItems: { type: "array", items: { type: "object" }, description: "Estimate scope items." },
|
||
demandLines: { type: "array", items: { type: "object" }, description: "Estimate demand lines." },
|
||
resourceSnapshots: { type: "array", items: { type: "object" }, description: "Resource cost snapshots." },
|
||
metrics: { type: "array", items: { type: "object" }, description: "Optional metric overrides." },
|
||
},
|
||
required: ["name"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "clone_estimate",
|
||
description: "Clone an existing estimate via the real estimate router. Manager/admin role and manageProjects permission required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
sourceEstimateId: { type: "string", description: "Source estimate ID." },
|
||
name: { type: "string", description: "Optional cloned estimate name." },
|
||
projectId: { type: "string", description: "Optional target project ID." },
|
||
projectCode: { type: "string", description: "Optional target project short code convenience alias." },
|
||
},
|
||
required: ["sourceEstimateId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "update_estimate_draft",
|
||
description: "Update the working draft of an estimate via the real estimate router. Manager/admin role and manageProjects permission required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
id: { type: "string", description: "Estimate ID." },
|
||
projectId: { type: "string", description: "Optional linked project ID." },
|
||
projectCode: { type: "string", description: "Optional linked project short code convenience alias." },
|
||
name: { type: "string" },
|
||
opportunityId: { type: "string" },
|
||
baseCurrency: { type: "string" },
|
||
status: { type: "string", enum: ["DRAFT", "IN_REVIEW", "APPROVED", "ARCHIVED"] },
|
||
versionLabel: { type: "string" },
|
||
versionNotes: { type: "string" },
|
||
assumptions: { type: "array", items: { type: "object" } },
|
||
scopeItems: { type: "array", items: { type: "object" } },
|
||
demandLines: { type: "array", items: { type: "object" } },
|
||
resourceSnapshots: { type: "array", items: { type: "object" } },
|
||
metrics: { type: "array", items: { type: "object" } },
|
||
},
|
||
required: ["id"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "submit_estimate_version",
|
||
description: "Submit an estimate working version for review via the real estimate router. Manager/admin role and manageProjects permission required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
estimateId: { type: "string", description: "Estimate ID." },
|
||
versionId: { type: "string", description: "Optional explicit version ID." },
|
||
},
|
||
required: ["estimateId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "approve_estimate_version",
|
||
description: "Approve a submitted estimate version via the real estimate router. Manager/admin role and manageProjects permission required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
estimateId: { type: "string", description: "Estimate ID." },
|
||
versionId: { type: "string", description: "Optional explicit version ID." },
|
||
},
|
||
required: ["estimateId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "create_estimate_revision",
|
||
description: "Create a new working revision from the latest locked estimate version via the real estimate router. Manager/admin role and manageProjects permission required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
estimateId: { type: "string", description: "Estimate ID." },
|
||
sourceVersionId: { type: "string", description: "Optional source version ID." },
|
||
label: { type: "string", description: "Optional revision label." },
|
||
notes: { type: "string", description: "Optional revision notes." },
|
||
},
|
||
required: ["estimateId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "create_estimate_export",
|
||
description: "Create an estimate export artifact via the real estimate router. Manager/admin role and manageProjects permission required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
estimateId: { type: "string", description: "Estimate ID." },
|
||
versionId: { type: "string", description: "Optional explicit version ID." },
|
||
format: { type: "string", enum: ["XLSX", "CSV", "JSON", "SAP", "MMP"], description: "Export format." },
|
||
},
|
||
required: ["estimateId", "format"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "create_estimate_planning_handoff",
|
||
description: "Create planning allocations from an approved estimate version via the real estimate router. Manager/admin role and manageAllocations permission required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
estimateId: { type: "string", description: "Estimate ID." },
|
||
versionId: { type: "string", description: "Optional explicit approved version ID." },
|
||
},
|
||
required: ["estimateId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "generate_estimate_weekly_phasing",
|
||
description: "Generate weekly phasing for the working estimate version via the real estimate router. Manager/admin role and manageProjects permission required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
estimateId: { type: "string", description: "Estimate ID." },
|
||
startDate: { type: "string", description: "Start date in YYYY-MM-DD." },
|
||
endDate: { type: "string", description: "End date in YYYY-MM-DD." },
|
||
pattern: { type: "string", enum: ["even", "front_loaded", "back_loaded", "custom"], description: "Distribution pattern." },
|
||
},
|
||
required: ["estimateId", "startDate", "endDate"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_estimate_weekly_phasing",
|
||
description: "Get generated weekly phasing for an estimate via the real estimate router. Controller/manager/admin access required.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
estimateId: { type: "string", description: "Estimate ID." },
|
||
},
|
||
required: ["estimateId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_estimate_commercial_terms",
|
||
description: "Get estimate commercial terms via the real estimate router. Controller/manager/admin access required.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
estimateId: { type: "string", description: "Estimate ID." },
|
||
versionId: { type: "string", description: "Optional explicit version ID." },
|
||
},
|
||
required: ["estimateId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "update_estimate_commercial_terms",
|
||
description: "Update estimate commercial terms on a working version via the real estimate router. Manager/admin role and manageProjects permission required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
estimateId: { type: "string", description: "Estimate ID." },
|
||
versionId: { type: "string", description: "Optional explicit version ID." },
|
||
terms: { type: "object", description: "Commercial terms payload." },
|
||
},
|
||
required: ["estimateId", "terms"],
|
||
},
|
||
},
|
||
},
|
||
|
||
// ── ROLES ──
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "create_role",
|
||
description: "Create a new role. Requires manager or admin role plus manageRoles permission. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
name: { type: "string", description: "Role name" },
|
||
description: { type: "string", description: "Optional role description" },
|
||
color: { type: "string", description: "Hex color (e.g. #3b82f6). Default: #6b7280" },
|
||
},
|
||
required: ["name"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "update_role",
|
||
description: "Update a role. Requires manager or admin role plus manageRoles permission. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
id: { type: "string", description: "Role ID" },
|
||
name: { type: "string", description: "New name" },
|
||
description: { type: "string", description: "New description" },
|
||
color: { type: "string", description: "New hex color" },
|
||
isActive: { type: "boolean", description: "Set active state" },
|
||
},
|
||
required: ["id"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "delete_role",
|
||
description: "Delete a role. Requires manager or admin role plus manageRoles permission. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
id: { type: "string", description: "Role ID" },
|
||
},
|
||
required: ["id"],
|
||
},
|
||
},
|
||
},
|
||
|
||
// ── CLIENTS ──
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "create_client",
|
||
description: "Create a new client. Requires manager or admin role. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
name: { type: "string", description: "Client name" },
|
||
code: { type: "string", description: "Client code" },
|
||
parentId: { type: "string", description: "Optional parent client ID" },
|
||
sortOrder: { type: "integer", description: "Sort order. Default: 0" },
|
||
tags: { type: "array", items: { type: "string" }, description: "Optional client tags" },
|
||
},
|
||
required: ["name"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "update_client",
|
||
description: "Update a client. Requires manager or admin role. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
id: { type: "string", description: "Client ID" },
|
||
name: { type: "string", description: "New name" },
|
||
code: { type: "string", description: "New code" },
|
||
sortOrder: { type: "integer", description: "New sort order" },
|
||
isActive: { type: "boolean", description: "Set active state" },
|
||
parentId: { type: "string", description: "Parent client ID; use null to clear" },
|
||
tags: { type: "array", items: { type: "string" }, description: "Replacement client tags" },
|
||
},
|
||
required: ["id"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "delete_client",
|
||
description: "Delete a client. Requires admin role. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
id: { type: "string", description: "Client ID" },
|
||
},
|
||
required: ["id"],
|
||
},
|
||
},
|
||
},
|
||
|
||
// ── ADMIN / CONFIG READ TOOLS ──
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_countries",
|
||
description: "List countries including working hours, schedule rules, active flag, and metro cities.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
includeInactive: { type: "boolean", description: "Include inactive countries. Default: false." },
|
||
search: { type: "string", description: "Optional country code or name search." },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_country",
|
||
description: "Get one country with schedule rules, active flag, metro cities, and resource count. Accepts ID, code, or name.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
identifier: { type: "string", description: "Country ID, code, or name." },
|
||
},
|
||
required: ["identifier"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
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"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_management_levels",
|
||
description: "List management level groups and their levels with target percentages.",
|
||
parameters: { type: "object", properties: {} },
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_utilization_categories",
|
||
description: "List utilization categories (cost classification for projects).",
|
||
parameters: { type: "object", properties: {} },
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_calculation_rules",
|
||
description: "List calculation rules for cost attribution and chargeability.",
|
||
parameters: { type: "object", properties: {} },
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_effort_rules",
|
||
description: "List effort estimation rules with their formulas and conditions.",
|
||
parameters: { type: "object", properties: {} },
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_experience_multipliers",
|
||
description: "List experience multipliers that adjust effort estimates based on seniority.",
|
||
parameters: { type: "object", properties: {} },
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_users",
|
||
description: "List all system users via the admin user router, including role and MFA state. Admin role required.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
limit: { type: "integer", description: "Max results. Default: 50" },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_assignable_users",
|
||
description: "List lightweight users available for assignment workflows. Manager or admin role required.",
|
||
parameters: { type: "object", properties: {} },
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_current_user",
|
||
description: "Get the authenticated user's own profile, role, and permission overrides.",
|
||
parameters: { type: "object", properties: {} },
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_dashboard_layout",
|
||
description: "Get the authenticated user's saved dashboard widget layout and last update timestamp.",
|
||
parameters: { type: "object", properties: {} },
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "save_dashboard_layout",
|
||
description: "Save the authenticated user's dashboard layout. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
layout: {
|
||
type: "array",
|
||
description: "Dashboard layout items as stored by the user router.",
|
||
items: { type: "object" },
|
||
},
|
||
},
|
||
required: ["layout"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_favorite_project_ids",
|
||
description: "Get the authenticated user's favorite project IDs.",
|
||
parameters: { type: "object", properties: {} },
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "toggle_favorite_project",
|
||
description: "Add or remove a project from the authenticated user's favorites. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
projectId: { type: "string", description: "Project ID." },
|
||
},
|
||
required: ["projectId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_column_preferences",
|
||
description: "Get the authenticated user's saved table column preferences for all supported views.",
|
||
parameters: { type: "object", properties: {} },
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "set_column_preferences",
|
||
description: "Update the authenticated user's table column preferences for one view. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
view: {
|
||
type: "string",
|
||
enum: ["resources", "projects", "allocations", "vacations", "roles", "users", "blueprints"],
|
||
description: "View key to update.",
|
||
},
|
||
visible: {
|
||
type: "array",
|
||
items: { type: "string" },
|
||
description: "Visible column IDs.",
|
||
},
|
||
sort: {
|
||
type: ["object", "null"],
|
||
properties: {
|
||
field: { type: "string" },
|
||
dir: { type: "string", enum: ["asc", "desc"] },
|
||
},
|
||
description: "Sort state. Use null to clear it.",
|
||
},
|
||
rowOrder: {
|
||
type: ["array", "null"],
|
||
items: { type: "string" },
|
||
description: "Optional row order. Use null to clear it.",
|
||
},
|
||
},
|
||
required: ["view"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "generate_totp_secret",
|
||
description: "Generate a new MFA TOTP secret and provisioning URI for the authenticated user. Always confirm first. The secret is sensitive.",
|
||
parameters: { type: "object", properties: {} },
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "verify_and_enable_totp",
|
||
description: "Verify a 6-digit MFA TOTP token and enable MFA for the authenticated user. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
token: { type: "string", description: "6-digit TOTP token." },
|
||
},
|
||
required: ["token"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_mfa_status",
|
||
description: "Get the authenticated user's MFA status.",
|
||
parameters: { type: "object", properties: {} },
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_active_user_count",
|
||
description: "Get the number of users active in the last five minutes. Admin role required.",
|
||
parameters: { type: "object", properties: {} },
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "create_user",
|
||
description: "Create a new system user and auto-link a matching resource by email when possible. Admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
email: { type: "string", description: "User email address." },
|
||
name: { type: "string", description: "Display name." },
|
||
systemRole: { type: "string", enum: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"], description: "Initial system role." },
|
||
password: { type: "string", description: "Initial password, minimum 8 characters." },
|
||
},
|
||
required: ["email", "name", "password"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "set_user_password",
|
||
description: "Reset a user's password. Admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
userId: { type: "string", description: "User ID." },
|
||
password: { type: "string", description: "New password, minimum 8 characters." },
|
||
},
|
||
required: ["userId", "password"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "update_user_role",
|
||
description: "Change a user's system role. Admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
id: { type: "string", description: "User ID." },
|
||
systemRole: { type: "string", enum: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
|
||
},
|
||
required: ["id", "systemRole"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "update_user_name",
|
||
description: "Rename a user. Admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
id: { type: "string", description: "User ID." },
|
||
name: { type: "string", description: "New display name." },
|
||
},
|
||
required: ["id", "name"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "link_user_resource",
|
||
description: "Link or unlink a user to a resource. Admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
userId: { type: "string", description: "User ID." },
|
||
resourceId: { type: ["string", "null"], description: "Resource ID or null to unlink." },
|
||
},
|
||
required: ["userId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "auto_link_users_by_email",
|
||
description: "Auto-link all users without a resource to matching resources by email. Admin role required. Always confirm first.",
|
||
parameters: { type: "object", properties: {} },
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "set_user_permissions",
|
||
description: "Set explicit permission overrides for a user. Admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
userId: { type: "string", description: "User ID." },
|
||
overrides: {
|
||
type: ["object", "null"],
|
||
properties: {
|
||
granted: { type: "array", items: { type: "string" } },
|
||
denied: { type: "array", items: { type: "string" } },
|
||
chapterIds: { type: "array", items: { type: "string" } },
|
||
},
|
||
description: "Permission override object or null to clear.",
|
||
},
|
||
},
|
||
required: ["userId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "reset_user_permissions",
|
||
description: "Reset a user's permission overrides back to role defaults. Admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
userId: { type: "string", description: "User ID." },
|
||
},
|
||
required: ["userId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_effective_user_permissions",
|
||
description: "Get a user's resolved permissions, role, and explicit overrides. Admin role required.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
userId: { type: "string", description: "User ID." },
|
||
},
|
||
required: ["userId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "disable_user_totp",
|
||
description: "Disable MFA TOTP for a user as an admin override. Admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
userId: { type: "string", description: "User ID." },
|
||
},
|
||
required: ["userId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_notifications",
|
||
description: "List recent notifications for the current user.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
unreadOnly: { type: "boolean", description: "Only show unread. Default: false" },
|
||
limit: { type: "integer", description: "Max results. Default: 20" },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "mark_notification_read",
|
||
description: "Mark one notification as read, or all unread notifications when no notificationId is provided. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
notificationId: { type: "string", description: "Notification ID. Omit to mark all unread notifications as read." },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_unread_notification_count",
|
||
description: "Count unread notifications for the current user.",
|
||
parameters: { type: "object", properties: {} },
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "create_notification",
|
||
description: "Create a notification or task-style notification for a specific user. Manager or admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
userId: { type: "string", description: "Target user ID." },
|
||
type: { type: "string", description: "Notification type code." },
|
||
title: { type: "string", description: "Title." },
|
||
body: { type: "string", description: "Optional body text." },
|
||
entityId: { type: "string", description: "Optional linked entity ID." },
|
||
entityType: { type: "string", description: "Optional linked entity type." },
|
||
category: { type: "string", enum: ["NOTIFICATION", "REMINDER", "TASK", "APPROVAL"] },
|
||
priority: { type: "string", enum: ["LOW", "NORMAL", "HIGH", "URGENT"] },
|
||
link: { type: "string", description: "Optional deep link." },
|
||
taskStatus: { type: "string", enum: ["OPEN", "IN_PROGRESS", "DONE", "DISMISSED"] },
|
||
taskAction: { type: "string", description: "Optional machine-readable task action." },
|
||
assigneeId: { type: "string", description: "Optional assignee user ID." },
|
||
dueDate: { type: "string", format: "date-time", description: "Optional due date." },
|
||
channel: { type: "string", enum: ["in_app", "email", "both"] },
|
||
senderId: { type: "string", description: "Optional sender override." },
|
||
},
|
||
required: ["userId", "type", "title"],
|
||
},
|
||
},
|
||
},
|
||
|
||
// ── DASHBOARD DETAIL ──
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_dashboard_detail",
|
||
description: "Get detailed dashboard data: peak allocation times, top-value resources, demand pipeline, chargeability overview.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
section: {
|
||
type: "string",
|
||
description: "Which section: peak_times, top_resources, demand_pipeline, chargeability_overview, or all",
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
|
||
// ── PROJECT MANAGEMENT ──
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "delete_project",
|
||
description: "Delete a project. Only DRAFT projects can be deleted. Requires manageProjects permission. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
projectId: { type: "string", description: "Project ID or short code" },
|
||
},
|
||
required: ["projectId"],
|
||
},
|
||
},
|
||
},
|
||
|
||
// ── ORG UNIT MANAGEMENT ──
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "create_org_unit",
|
||
description: "Create a new organizational unit. Admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
name: { type: "string", description: "Org unit name" },
|
||
shortName: { type: "string", description: "Short name/code" },
|
||
level: { type: "integer", description: "Level (5, 6, or 7)" },
|
||
parentId: { type: "string", description: "Parent org unit ID (optional)" },
|
||
sortOrder: { type: "integer", description: "Sort order. Default: 0" },
|
||
},
|
||
required: ["name", "level"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "update_org_unit",
|
||
description: "Update an organizational unit. Admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
id: { type: "string", description: "Org unit ID" },
|
||
name: { type: "string", description: "New name" },
|
||
shortName: { type: "string", description: "New short name" },
|
||
sortOrder: { type: "integer", description: "New sort order" },
|
||
isActive: { type: "boolean", description: "Set active state" },
|
||
parentId: { type: "string", description: "Parent org unit ID; use null to clear" },
|
||
},
|
||
required: ["id"],
|
||
},
|
||
},
|
||
},
|
||
|
||
// ── COVER ART ──
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "generate_project_cover",
|
||
description: "Generate an AI cover art image for a project. Uses the configured image provider (DALL-E or Google Gemini). The image will be stored as the project's cover. Requires manageProjects permission.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
projectId: { type: "string", description: "Project ID" },
|
||
prompt: { type: "string", description: "Optional custom prompt for the AI image generation (e.g. 'futuristic car in neon cityscape'). If not provided, a default automotive/CGI prompt is used based on the project name." },
|
||
},
|
||
required: ["projectId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "remove_project_cover",
|
||
description: "Remove the cover art image from a project. Requires manageProjects permission.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
projectId: { type: "string", description: "Project ID" },
|
||
},
|
||
required: ["projectId"],
|
||
},
|
||
},
|
||
},
|
||
|
||
// ── TASK MANAGEMENT ──
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_tasks",
|
||
description: "List tasks and approvals for the current user via the real notification router, optionally including tasks assigned to them.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
status: { type: "string", enum: ["OPEN", "IN_PROGRESS", "DONE", "DISMISSED"], description: "Optional status filter." },
|
||
includeAssigned: { type: "boolean", description: "Include tasks where the current user is assignee as well as owner. Default: true." },
|
||
limit: { type: "integer", description: "Max results. Default: 20." },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_task_counts",
|
||
description: "Get open, in-progress, done, dismissed, and overdue task counts for the current user.",
|
||
parameters: { type: "object", properties: {} },
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_task_detail",
|
||
description: "Get details of a specific task/notification including linked entity information.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
taskId: { type: "string", description: "Notification/task ID" },
|
||
},
|
||
required: ["taskId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "update_task_status",
|
||
description: "Update the status of a task. Mark as IN_PROGRESS, DONE, or DISMISSED.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
taskId: { type: "string", description: "Task/notification ID" },
|
||
status: { type: "string", enum: ["OPEN", "IN_PROGRESS", "DONE", "DISMISSED"], description: "New status" },
|
||
},
|
||
required: ["taskId", "status"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "execute_task_action",
|
||
description: "Execute the machine-readable action associated with a task. For example: approve a vacation, confirm an assignment, etc. The action is encoded in the task's taskAction field.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
taskId: { type: "string", description: "Task/notification ID containing the action to execute" },
|
||
},
|
||
required: ["taskId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "create_reminder",
|
||
description: "Create a personal reminder for the current user via the real notification router. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
title: { type: "string", description: "Reminder title" },
|
||
body: { type: "string", description: "Optional details" },
|
||
remindAt: { type: "string", format: "date-time", description: "When to remind (ISO 8601 datetime)" },
|
||
recurrence: { type: "string", enum: ["daily", "weekly", "monthly"], description: "Optional recurrence pattern" },
|
||
entityId: { type: "string", description: "Optional: linked entity ID (project, resource, etc.)" },
|
||
entityType: { type: "string", description: "Optional: entity type (project, resource, vacation, etc.)" },
|
||
link: { type: "string", description: "Optional deep link." },
|
||
},
|
||
required: ["title", "remindAt"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_reminders",
|
||
description: "List personal reminders for the current user via the real notification router.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
limit: { type: "integer", description: "Max results. Default: 20." },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "update_reminder",
|
||
description: "Update a personal reminder via the real notification router. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
id: { type: "string", description: "Reminder notification ID." },
|
||
title: { type: "string", description: "Optional reminder title." },
|
||
body: { type: "string", description: "Optional reminder body." },
|
||
remindAt: { type: "string", format: "date-time", description: "Optional reminder timestamp." },
|
||
recurrence: { type: ["string", "null"], enum: ["daily", "weekly", "monthly", null], description: "Optional recurrence update. Use null to clear recurrence." },
|
||
},
|
||
required: ["id"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "delete_reminder",
|
||
description: "Delete a personal reminder via the real notification router. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
id: { type: "string", description: "Reminder notification ID." },
|
||
},
|
||
required: ["id"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "create_task_for_user",
|
||
description: "Create a task for a specific user via the real notification router. Manager or admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
userId: { type: "string", description: "Target user ID" },
|
||
title: { type: "string", description: "Task title" },
|
||
body: { type: "string", description: "Task description" },
|
||
priority: { type: "string", enum: ["LOW", "NORMAL", "HIGH", "URGENT"], description: "Priority (default NORMAL)" },
|
||
dueDate: { type: "string", format: "date-time", description: "Optional due date (ISO 8601)" },
|
||
taskAction: { type: "string", description: "Optional machine-readable action (format: action_name:entity_id)" },
|
||
entityId: { type: "string", description: "Optional linked entity ID" },
|
||
entityType: { type: "string", description: "Optional entity type" },
|
||
link: { type: "string", description: "Optional deep link." },
|
||
channel: { type: "string", enum: ["in_app", "email", "both"], description: "Delivery channel. Default: in_app." },
|
||
},
|
||
required: ["userId", "title"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "assign_task",
|
||
description: "Assign or reassign a task to another user via the real notification router. Manager or admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
id: { type: "string", description: "Task notification ID." },
|
||
assigneeId: { type: "string", description: "User ID to assign." },
|
||
},
|
||
required: ["id", "assigneeId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "send_broadcast",
|
||
description: "Create and send a broadcast notification via the real notification router. Manager or admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
title: { type: "string", description: "Notification title" },
|
||
body: { type: "string", description: "Notification body" },
|
||
targetType: { type: "string", enum: ["user", "role", "project", "orgUnit", "all"], description: "Target audience type" },
|
||
targetValue: { type: "string", description: "Target value: user ID, role name (ADMIN/MANAGER/CONTROLLER/USER/VIEWER), project ID, or org unit ID" },
|
||
category: { type: "string", enum: ["NOTIFICATION", "REMINDER", "TASK", "APPROVAL"], description: "Broadcast category. Default: NOTIFICATION." },
|
||
priority: { type: "string", enum: ["LOW", "NORMAL", "HIGH", "URGENT"], description: "Priority (default NORMAL)" },
|
||
channel: { type: "string", enum: ["in_app", "email", "both"], description: "Delivery channel (default in_app)" },
|
||
link: { type: "string", description: "Optional deep-link URL" },
|
||
scheduledAt: { type: "string", format: "date-time", description: "Optional scheduled send timestamp." },
|
||
taskAction: { type: "string", description: "Optional machine-readable task action for task-like broadcasts." },
|
||
dueDate: { type: "string", format: "date-time", description: "Optional due date for task-like broadcasts." },
|
||
},
|
||
required: ["title", "targetType"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_broadcasts",
|
||
description: "List notification broadcasts via the real notification router. Manager or admin role required.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
limit: { type: "integer", description: "Max results. Default: 20." },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_broadcast_detail",
|
||
description: "Get one notification broadcast via the real notification router. Manager or admin role required.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
id: { type: "string", description: "Broadcast ID." },
|
||
},
|
||
required: ["id"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "delete_notification",
|
||
description: "Delete one of the current user's own notifications via the real notification router. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
id: { type: "string", description: "Notification ID." },
|
||
},
|
||
required: ["id"],
|
||
},
|
||
},
|
||
},
|
||
|
||
// ── INSIGHTS & ANOMALIES ──
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "detect_anomalies",
|
||
description: "Detect anomalies across all active projects: budget burn rate issues, staffing gaps, utilization outliers, and timeline overruns.",
|
||
parameters: { type: "object", properties: {} },
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_skill_gaps",
|
||
description: "Analyze skill supply vs demand across all active projects. Returns which skills are in short supply relative to demand requirements.",
|
||
parameters: { type: "object", properties: {} },
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_project_health",
|
||
description: "Get health scores for all active projects based on budget utilization, staffing completeness, and timeline status.",
|
||
parameters: { type: "object", properties: {} },
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_budget_forecast",
|
||
description: "Get budget utilization and burn rate per active project. Shows total budget, spent, remaining, and whether burn is ahead or behind schedule.",
|
||
parameters: { type: "object", properties: {} },
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_insights_summary",
|
||
description: "Get a summary of anomaly counts by category (budget, staffing, timeline, utilization) plus critical count.",
|
||
parameters: { type: "object", properties: {} },
|
||
},
|
||
},
|
||
|
||
// ── REPORTS & COMMENTS ──
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "run_report",
|
||
description: "Run a dynamic report query on resources, projects, assignments, or resource-month rows with flexible column selection and filtering.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
entity: {
|
||
type: "string",
|
||
enum: ["resource", "project", "assignment", "resource_month"],
|
||
description: "Entity type to query",
|
||
},
|
||
columns: {
|
||
type: "array",
|
||
items: { type: "string" },
|
||
description: "Column keys to include (e.g. 'displayName', 'chapter', 'country.name')",
|
||
},
|
||
filters: {
|
||
type: "array",
|
||
items: {
|
||
type: "object",
|
||
properties: {
|
||
field: { type: "string", description: "Field to filter on" },
|
||
op: { type: "string", enum: ["eq", "neq", "gt", "lt", "gte", "lte", "contains", "in"], description: "Filter operator" },
|
||
value: { type: "string", description: "Filter value (string)" },
|
||
},
|
||
required: ["field", "op", "value"],
|
||
},
|
||
description: "Filters to apply",
|
||
},
|
||
periodMonth: {
|
||
type: "string",
|
||
description: "Required for resource_month reports. Format: YYYY-MM",
|
||
},
|
||
limit: { type: "integer", description: "Max results. Default: 50" },
|
||
},
|
||
required: ["entity", "columns"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_comments",
|
||
description: "List comments (with replies) for a specific entity such as an estimate, scope item, or demand line.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
entityType: { type: "string", description: "Entity type (e.g. 'estimate', 'estimate_version', 'scope_item', 'demand_line')" },
|
||
entityId: { type: "string", description: "Entity ID" },
|
||
},
|
||
required: ["entityType", "entityId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "lookup_rate",
|
||
description: "Find the best matching rate card line for given criteria (client, chapter, management level, role, seniority).",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
clientId: { type: "string", description: "Client ID to find rate card for" },
|
||
chapter: { type: "string", description: "Chapter to match" },
|
||
managementLevelId: { type: "string", description: "Management level ID to match" },
|
||
roleName: { type: "string", description: "Role name to match" },
|
||
seniority: { type: "string", description: "Seniority level to match" },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
|
||
// ── SCENARIO & AI ──
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "simulate_scenario",
|
||
description: "Run a read-only what-if staffing simulation for a project. Shows cost/hours/utilization impact of adding, removing, or changing resource assignments without persisting changes.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
projectId: { type: "string", description: "Project ID" },
|
||
changes: {
|
||
type: "array",
|
||
items: {
|
||
type: "object",
|
||
properties: {
|
||
assignmentId: { type: "string", description: "Existing assignment ID to modify (omit for new)" },
|
||
resourceId: { type: "string", description: "Resource ID" },
|
||
roleId: { type: "string", description: "Role ID" },
|
||
startDate: { type: "string", description: "Start date YYYY-MM-DD" },
|
||
endDate: { type: "string", description: "End date YYYY-MM-DD" },
|
||
hoursPerDay: { type: "number", description: "Hours per day" },
|
||
remove: { type: "boolean", description: "Set true to remove an existing assignment" },
|
||
},
|
||
required: ["startDate", "endDate", "hoursPerDay"],
|
||
},
|
||
description: "Array of staffing changes to simulate",
|
||
},
|
||
},
|
||
required: ["projectId", "changes"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "generate_project_narrative",
|
||
description: "Generate an AI-powered executive narrative for a project covering budget, staffing, timeline risk, and action items. Requires AI to be configured.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
projectId: { type: "string", description: "Project ID" },
|
||
},
|
||
required: ["projectId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "create_comment",
|
||
description: "Add a comment to an entity (estimate, scope item, demand line, etc.). Supports @mentions. Always confirm with the user first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
entityType: { type: "string", description: "Entity type (e.g. 'estimate', 'estimate_version', 'scope_item')" },
|
||
entityId: { type: "string", description: "Entity ID" },
|
||
body: { type: "string", description: "Comment body text. Use @[Name](userId) for mentions." },
|
||
},
|
||
required: ["entityType", "entityId", "body"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "resolve_comment",
|
||
description: "Mark a comment as resolved (or unresolve it). Only the comment author or an admin can do this.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
commentId: { type: "string", description: "Comment ID to resolve" },
|
||
resolved: { type: "boolean", description: "Set to true to resolve, false to unresolve. Default: true" },
|
||
},
|
||
required: ["commentId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "query_change_history",
|
||
description: "Search the audit history for changes to projects, resources, allocations, vacations, or any entity. Reuses the real audit log list API. Controller/manager/admin roles only.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
entityType: { type: "string", description: "Filter by entity type (e.g. 'Project', 'Resource', 'Allocation', 'Vacation', 'Role', 'Estimate')" },
|
||
search: { type: "string", description: "Search in entity name or summary text" },
|
||
userId: { type: "string", description: "Filter by user ID who made the change" },
|
||
daysBack: { type: "integer", description: "How many days back to search. Default: 7" },
|
||
action: { type: "string", description: "Filter by action type: CREATE, UPDATE, DELETE, SHIFT, IMPORT" },
|
||
limit: { type: "integer", description: "Max results. Default: 20" },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_entity_timeline",
|
||
description: "Get the audit history for a specific entity (project, resource, etc.) via the real audit API. Controller/manager/admin roles only.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
entityType: { type: "string", description: "Entity type (e.g. 'Project', 'Resource', 'Allocation')" },
|
||
entityId: { type: "string", description: "Entity ID" },
|
||
limit: { type: "integer", description: "Max results. Default: 50" },
|
||
},
|
||
required: ["entityType", "entityId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "export_resources_csv",
|
||
description: "Export the current active resource list as CSV via the real import/export router. Controller/manager/admin roles only.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "export_projects_csv",
|
||
description: "Export the current project list as CSV via the real import/export router. Controller/manager/admin roles only.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "import_csv_data",
|
||
description: "Import CSV-style row data for resources, projects, or allocations via the real import/export router. Requires manager/admin, importData permission, and confirmation.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
entityType: { type: "string", enum: ["resources", "projects", "allocations"], description: "Import target entity type." },
|
||
rows: {
|
||
type: "array",
|
||
description: "CSV rows already parsed to key/value objects.",
|
||
items: {
|
||
type: "object",
|
||
additionalProperties: { type: "string" },
|
||
},
|
||
},
|
||
dryRun: { type: "boolean", description: "Validate only without persisting changes. Default: true." },
|
||
},
|
||
required: ["entityType", "rows"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_dispo_import_batches",
|
||
description: "List Dispo import batches with pagination and optional status filter via the real dispo router. Admin role required.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
status: { type: "string", description: "Optional batch status filter." },
|
||
limit: { type: "integer", description: "Max results. Default: 50, max: 200." },
|
||
cursor: { type: "string", description: "Optional pagination cursor." },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_dispo_import_batch",
|
||
description: "Get one Dispo import batch including staged record counters via the real dispo router. Admin role required.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
id: { type: "string", description: "Import batch ID." },
|
||
},
|
||
required: ["id"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "stage_dispo_import_batch",
|
||
description: "Stage a Dispo import batch via the real dispo router. Admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
planningWorkbookPath: { type: "string", description: "Filesystem path to the planning workbook." },
|
||
referenceWorkbookPath: { type: "string", description: "Filesystem path to the reference workbook." },
|
||
chargeabilityWorkbookPath: { type: "string", description: "Filesystem path to the chargeability workbook." },
|
||
costWorkbookPath: { type: "string", description: "Optional filesystem path to the cost workbook." },
|
||
rosterWorkbookPath: { type: "string", description: "Optional filesystem path to the roster workbook." },
|
||
notes: { type: "string", description: "Optional import notes." },
|
||
},
|
||
required: ["planningWorkbookPath", "referenceWorkbookPath", "chargeabilityWorkbookPath"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "validate_dispo_import_batch",
|
||
description: "Validate a Dispo import batch readiness check via the real dispo router without committing anything. Admin role required.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
planningWorkbookPath: { type: "string", description: "Filesystem path to the planning workbook." },
|
||
referenceWorkbookPath: { type: "string", description: "Filesystem path to the reference workbook." },
|
||
chargeabilityWorkbookPath: { type: "string", description: "Filesystem path to the chargeability workbook." },
|
||
costWorkbookPath: { type: "string", description: "Optional filesystem path to the cost workbook." },
|
||
rosterWorkbookPath: { type: "string", description: "Optional filesystem path to the roster workbook." },
|
||
importBatchId: { type: "string", description: "Optional existing staged import batch ID." },
|
||
notes: { type: "string", description: "Optional import notes." },
|
||
},
|
||
required: ["planningWorkbookPath", "referenceWorkbookPath", "chargeabilityWorkbookPath"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "cancel_dispo_import_batch",
|
||
description: "Cancel a staged Dispo import batch via the real dispo router. Admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
id: { type: "string", description: "Import batch ID." },
|
||
},
|
||
required: ["id"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_dispo_staged_resources",
|
||
description: "List staged Dispo resources for one import batch via the real dispo router. Admin role required.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
importBatchId: { type: "string", description: "Import batch ID." },
|
||
status: { type: "string", description: "Optional staged record status filter." },
|
||
limit: { type: "integer", description: "Max results. Default: 50, max: 200." },
|
||
cursor: { type: "string", description: "Optional pagination cursor." },
|
||
},
|
||
required: ["importBatchId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_dispo_staged_projects",
|
||
description: "List staged Dispo projects for one import batch via the real dispo router. Admin role required.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
importBatchId: { type: "string", description: "Import batch ID." },
|
||
status: { type: "string", description: "Optional staged record status filter." },
|
||
isTbd: { type: "boolean", description: "Optional TBD-project filter." },
|
||
limit: { type: "integer", description: "Max results. Default: 50, max: 200." },
|
||
cursor: { type: "string", description: "Optional pagination cursor." },
|
||
},
|
||
required: ["importBatchId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_dispo_staged_assignments",
|
||
description: "List staged Dispo assignments for one import batch via the real dispo router. Admin role required.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
importBatchId: { type: "string", description: "Import batch ID." },
|
||
status: { type: "string", description: "Optional staged record status filter." },
|
||
resourceExternalId: { type: "string", description: "Optional resource external ID filter." },
|
||
limit: { type: "integer", description: "Max results. Default: 50, max: 200." },
|
||
cursor: { type: "string", description: "Optional pagination cursor." },
|
||
},
|
||
required: ["importBatchId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_dispo_staged_vacations",
|
||
description: "List staged Dispo vacations for one import batch via the real dispo router. Admin role required.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
importBatchId: { type: "string", description: "Import batch ID." },
|
||
resourceExternalId: { type: "string", description: "Optional resource external ID filter." },
|
||
limit: { type: "integer", description: "Max results. Default: 50, max: 200." },
|
||
cursor: { type: "string", description: "Optional pagination cursor." },
|
||
},
|
||
required: ["importBatchId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_dispo_staged_unresolved_records",
|
||
description: "List staged unresolved Dispo records for one import batch via the real dispo router. Admin role required.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
importBatchId: { type: "string", description: "Import batch ID." },
|
||
recordType: { type: "string", description: "Optional unresolved record type filter." },
|
||
limit: { type: "integer", description: "Max results. Default: 50, max: 200." },
|
||
cursor: { type: "string", description: "Optional pagination cursor." },
|
||
},
|
||
required: ["importBatchId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "resolve_dispo_staged_record",
|
||
description: "Resolve one staged Dispo record via the real dispo router. Admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
id: { type: "string", description: "Staged record ID." },
|
||
recordType: { type: "string", description: "Staged record type." },
|
||
action: { type: "string", enum: ["APPROVE", "REJECT", "SKIP"], description: "Resolution action." },
|
||
},
|
||
required: ["id", "recordType", "action"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "commit_dispo_import_batch",
|
||
description: "Commit a staged Dispo import batch via the real dispo router. Admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
importBatchId: { type: "string", description: "Import batch ID." },
|
||
allowTbdUnresolved: { type: "boolean", description: "Allow unresolved TBD projects during commit." },
|
||
importTbdProjects: { type: "boolean", description: "Whether TBD projects should be imported." },
|
||
},
|
||
required: ["importBatchId"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_system_settings",
|
||
description: "Get sanitized system settings through the real settings router. Admin role required.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "update_system_settings",
|
||
description: "Update system settings through the real settings router. Admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
aiProvider: { type: "string", enum: ["openai", "azure"] },
|
||
azureOpenAiEndpoint: { type: "string" },
|
||
azureOpenAiDeployment: { type: "string" },
|
||
azureOpenAiApiKey: { type: "string" },
|
||
azureApiVersion: { type: "string" },
|
||
aiMaxCompletionTokens: { type: "integer" },
|
||
aiTemperature: { type: "number" },
|
||
aiSummaryPrompt: { type: "string" },
|
||
scoreWeights: { type: "object" },
|
||
scoreVisibleRoles: { type: "array", items: { type: "string" } },
|
||
smtpHost: { type: "string" },
|
||
smtpPort: { type: "integer" },
|
||
smtpUser: { type: "string" },
|
||
smtpPassword: { type: "string" },
|
||
smtpFrom: { type: "string" },
|
||
smtpTls: { type: "boolean" },
|
||
anonymizationEnabled: { type: "boolean" },
|
||
anonymizationDomain: { type: "string" },
|
||
anonymizationSeed: { type: "string" },
|
||
anonymizationMode: { type: "string", enum: ["global"] },
|
||
azureDalleDeployment: { type: "string" },
|
||
azureDalleEndpoint: { type: "string" },
|
||
azureDalleApiKey: { type: "string" },
|
||
geminiApiKey: { type: "string" },
|
||
geminiModel: { type: "string" },
|
||
imageProvider: { type: "string", enum: ["dalle", "gemini"] },
|
||
vacationDefaultDays: { type: "integer" },
|
||
timelineUndoMaxSteps: { type: "integer" },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "test_ai_connection",
|
||
description: "Run the real AI connection test from system settings. Admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "test_smtp_connection",
|
||
description: "Run the real SMTP connection test from system settings. Admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "test_gemini_connection",
|
||
description: "Run the real Gemini connection test from system settings. Admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_ai_configured",
|
||
description: "Get whether AI is configured for the current system via the real settings router. Admin role required.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_system_role_configs",
|
||
description: "List system role configuration defaults via the real system-role-config router. Admin role required.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "update_system_role_config",
|
||
description: "Update one system role configuration via the real system-role-config router. Admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
role: { type: "string", description: "System role key." },
|
||
label: { type: "string", description: "Optional role label." },
|
||
description: { type: "string", description: "Optional role description." },
|
||
color: { type: "string", description: "Optional role color." },
|
||
defaultPermissions: { type: "array", items: { type: "string" }, description: "Optional default permission set." },
|
||
},
|
||
required: ["role"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_webhooks",
|
||
description: "List webhooks via the real webhook router. Secrets are masked in assistant responses. Admin role required.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_webhook",
|
||
description: "Get one webhook via the real webhook router. Secrets are masked in assistant responses. Admin role required.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
id: { type: "string", description: "Webhook ID." },
|
||
},
|
||
required: ["id"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "create_webhook",
|
||
description: "Create a webhook via the real webhook router. Secrets are masked in assistant responses. Admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
name: { type: "string", description: "Webhook name." },
|
||
url: { type: "string", description: "Webhook target URL." },
|
||
secret: { type: "string", description: "Optional webhook signing secret." },
|
||
events: { type: "array", items: { type: "string" }, description: "Subscribed webhook events." },
|
||
isActive: { type: "boolean", description: "Whether the webhook is active. Default: true." },
|
||
},
|
||
required: ["name", "url", "events"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "update_webhook",
|
||
description: "Update a webhook via the real webhook router. Secrets are masked in assistant responses. Admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
id: { type: "string", description: "Webhook ID." },
|
||
data: {
|
||
type: "object",
|
||
properties: {
|
||
name: { type: "string" },
|
||
url: { type: "string" },
|
||
secret: { type: "string" },
|
||
events: { type: "array", items: { type: "string" } },
|
||
isActive: { type: "boolean" },
|
||
},
|
||
},
|
||
},
|
||
required: ["id", "data"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "delete_webhook",
|
||
description: "Delete a webhook via the real webhook router. Admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
id: { type: "string", description: "Webhook ID." },
|
||
},
|
||
required: ["id"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "test_webhook",
|
||
description: "Send a real test payload to a webhook via the real webhook router. Admin role required. Always confirm first.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
id: { type: "string", description: "Webhook ID." },
|
||
},
|
||
required: ["id"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_audit_log_entries",
|
||
description: "List audit log entries with full audit-router filters and cursor pagination. Controller/manager/admin roles only.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
entityType: { type: "string", description: "Optional entity type filter." },
|
||
entityId: { type: "string", description: "Optional entity ID filter." },
|
||
userId: { type: "string", description: "Optional user ID filter." },
|
||
action: { type: "string", description: "Optional action filter such as CREATE, UPDATE, DELETE, SHIFT, IMPORT." },
|
||
source: { type: "string", description: "Optional source filter such as ui or assistant." },
|
||
startDate: { type: "string", description: "Optional start date in YYYY-MM-DD." },
|
||
endDate: { type: "string", description: "Optional end date in YYYY-MM-DD." },
|
||
search: { type: "string", description: "Optional case-insensitive search across entity name, summary, and entity type." },
|
||
limit: { type: "integer", description: "Max results. Default: 50, max: 100." },
|
||
cursor: { type: "string", description: "Optional pagination cursor (last seen audit entry ID)." },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_audit_log_entry",
|
||
description: "Get one audit log entry including the full changes payload. Controller/manager/admin roles only.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
id: { type: "string", description: "Audit log entry ID." },
|
||
},
|
||
required: ["id"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_audit_log_timeline",
|
||
description: "Get audit log entries grouped by day for a time window. Controller/manager/admin roles only.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
startDate: { type: "string", description: "Optional start date in YYYY-MM-DD." },
|
||
endDate: { type: "string", description: "Optional end date in YYYY-MM-DD." },
|
||
limit: { type: "integer", description: "Max entries. Default: 200, max: 500." },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_audit_activity_summary",
|
||
description: "Get audit activity totals by entity type, action, and user for a date range. Controller/manager/admin roles only.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
startDate: { type: "string", description: "Optional start date in YYYY-MM-DD." },
|
||
endDate: { type: "string", description: "Optional end date in YYYY-MM-DD." },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "get_shoring_ratio",
|
||
description: "Get the onshore/offshore staffing ratio for a project. Higher offshore is better (cost-efficient). The threshold is the MINIMUM offshore target. Shows country breakdown and whether the target is met.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
projectId: { type: "string", description: "Project ID or short code" },
|
||
},
|
||
required: ["projectId"],
|
||
},
|
||
},
|
||
},
|
||
];
|
||
|
||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||
|
||
/** Resolve a responsible person name against existing resources. Returns the exact displayName or an error object. */
|
||
async function resolveResponsiblePerson(
|
||
name: string,
|
||
ctx: ToolContext,
|
||
): Promise<{ displayName: string } | { error: string }> {
|
||
const caller = createResourceCaller(createScopedCallerContext(ctx));
|
||
const result = await caller.resolveResponsiblePersonName({ name });
|
||
|
||
if (result.status === "resolved") {
|
||
return { displayName: result.displayName };
|
||
}
|
||
|
||
if (result.status === "ambiguous" || result.status === "missing") {
|
||
return { error: result.message };
|
||
}
|
||
|
||
return { error: `Unable to resolve responsible person: ${name}` };
|
||
}
|
||
|
||
// ─── 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 });
|
||
},
|
||
|
||
async search_projects(params: { query?: string; status?: string; limit?: number }, ctx: ToolContext) {
|
||
const caller = createProjectCaller(createScopedCallerContext(ctx));
|
||
return caller.searchSummariesDetail({
|
||
search: params.query,
|
||
status: params.status as import("@capakraken/shared").ProjectStatus | undefined,
|
||
limit: Math.min(params.limit ?? 20, 50),
|
||
});
|
||
},
|
||
|
||
async get_project(params: { identifier: string }, ctx: ToolContext) {
|
||
const caller = createProjectCaller(createScopedCallerContext(ctx));
|
||
let project;
|
||
try {
|
||
project = await caller.getByIdentifierDetail({ identifier: params.identifier });
|
||
} catch (error) {
|
||
const mapped = toAssistantNotFoundError(
|
||
error,
|
||
`Project not found: ${params.identifier}`,
|
||
);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return project;
|
||
},
|
||
|
||
async find_best_project_resource(params: {
|
||
projectIdentifier: string;
|
||
startDate?: string;
|
||
endDate?: string;
|
||
durationDays?: number;
|
||
minHoursPerDay?: number;
|
||
rankingMode?: "lowest_lcr" | "highest_remaining_hours_per_day" | "highest_remaining_hours";
|
||
chapter?: string;
|
||
roleName?: string;
|
||
}, ctx: ToolContext) {
|
||
assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS);
|
||
assertPermission(ctx, PermissionKey.VIEW_COSTS);
|
||
|
||
const project = await resolveProjectIdentifier(ctx, params.projectIdentifier);
|
||
if ("error" in project) {
|
||
return project;
|
||
}
|
||
|
||
const caller = createStaffingCaller(createScopedCallerContext(ctx));
|
||
return caller.getBestProjectResourceDetail({
|
||
projectId: project.id,
|
||
...(params.startDate ? { startDate: parseIsoDate(params.startDate, "startDate") } : {}),
|
||
...(params.endDate ? { endDate: parseIsoDate(params.endDate, "endDate") } : {}),
|
||
...(params.durationDays !== undefined ? { durationDays: params.durationDays } : {}),
|
||
...(params.minHoursPerDay !== undefined ? { minHoursPerDay: params.minHoursPerDay } : {}),
|
||
...(params.rankingMode ? { rankingMode: params.rankingMode } : {}),
|
||
...(params.chapter ? { chapter: params.chapter } : {}),
|
||
...(params.roleName ? { roleName: params.roleName } : {}),
|
||
});
|
||
},
|
||
|
||
async get_timeline_entries_view(params: {
|
||
startDate?: string;
|
||
endDate?: string;
|
||
durationDays?: number;
|
||
resourceIds?: string[];
|
||
projectIds?: string[];
|
||
clientIds?: string[];
|
||
chapters?: string[];
|
||
eids?: string[];
|
||
countryCodes?: string[];
|
||
}, ctx: ToolContext) {
|
||
assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS);
|
||
|
||
const caller = createTimelineCaller(createScopedCallerContext(ctx));
|
||
return caller.getEntriesDetail({ ...params });
|
||
},
|
||
|
||
async get_timeline_holiday_overlays(params: {
|
||
startDate?: string;
|
||
endDate?: string;
|
||
durationDays?: number;
|
||
resourceIds?: string[];
|
||
projectIds?: string[];
|
||
clientIds?: string[];
|
||
chapters?: string[];
|
||
eids?: string[];
|
||
countryCodes?: string[];
|
||
}, ctx: ToolContext) {
|
||
assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS);
|
||
|
||
const caller = createTimelineCaller(createScopedCallerContext(ctx));
|
||
return caller.getHolidayOverlayDetail({ ...params });
|
||
},
|
||
|
||
async get_project_timeline_context(params: {
|
||
projectIdentifier: string;
|
||
startDate?: string;
|
||
endDate?: string;
|
||
durationDays?: number;
|
||
}, ctx: ToolContext) {
|
||
assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS);
|
||
|
||
const project = await resolveProjectIdentifier(ctx, params.projectIdentifier);
|
||
if ("error" in project) {
|
||
return project;
|
||
}
|
||
|
||
const caller = createTimelineCaller(createScopedCallerContext(ctx));
|
||
return caller.getProjectContextDetail({
|
||
projectId: project.id,
|
||
...(params.startDate ? { startDate: params.startDate } : {}),
|
||
...(params.endDate ? { endDate: params.endDate } : {}),
|
||
...(params.durationDays !== undefined ? { durationDays: params.durationDays } : {}),
|
||
});
|
||
},
|
||
|
||
async preview_project_shift(params: {
|
||
projectIdentifier: string;
|
||
newStartDate: string;
|
||
newEndDate: string;
|
||
}, ctx: ToolContext) {
|
||
assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS);
|
||
|
||
const project = await resolveProjectIdentifier(ctx, params.projectIdentifier);
|
||
if ("error" in project) {
|
||
return project;
|
||
}
|
||
|
||
const caller = createTimelineCaller(createScopedCallerContext(ctx));
|
||
return caller.getShiftPreviewDetail({
|
||
projectId: project.id,
|
||
newStartDate: parseIsoDate(params.newStartDate, "newStartDate"),
|
||
newEndDate: parseIsoDate(params.newEndDate, "newEndDate"),
|
||
});
|
||
},
|
||
|
||
async update_timeline_allocation_inline(params: {
|
||
allocationId: string;
|
||
hoursPerDay?: number;
|
||
startDate?: string;
|
||
endDate?: string;
|
||
includeSaturday?: boolean;
|
||
role?: string;
|
||
}, ctx: ToolContext) {
|
||
assertPermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
||
assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS);
|
||
|
||
const caller = createTimelineCaller(createScopedCallerContext(ctx));
|
||
let updated;
|
||
try {
|
||
updated = await caller.updateAllocationInline({
|
||
allocationId: params.allocationId,
|
||
...(params.hoursPerDay !== undefined ? { hoursPerDay: params.hoursPerDay } : {}),
|
||
...(params.startDate ? { startDate: parseIsoDate(params.startDate, "startDate") } : {}),
|
||
...(params.endDate ? { endDate: parseIsoDate(params.endDate, "endDate") } : {}),
|
||
...(params.includeSaturday !== undefined ? { includeSaturday: params.includeSaturday } : {}),
|
||
...(params.role !== undefined ? { role: params.role } : {}),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantTimelineMutationError(error, "updateInline");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["allocation", "timeline", "project"],
|
||
success: true,
|
||
message: `Updated timeline allocation ${updated.id}.`,
|
||
allocation: {
|
||
id: updated.id,
|
||
projectId: updated.projectId,
|
||
resourceId: updated.resourceId ?? null,
|
||
startDate: fmtDate(updated.startDate),
|
||
endDate: fmtDate(updated.endDate),
|
||
hoursPerDay: updated.hoursPerDay,
|
||
role: updated.role ?? null,
|
||
status: updated.status,
|
||
},
|
||
};
|
||
},
|
||
|
||
async apply_timeline_project_shift(params: {
|
||
projectIdentifier: string;
|
||
newStartDate: string;
|
||
newEndDate: string;
|
||
}, ctx: ToolContext) {
|
||
assertPermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
||
assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS);
|
||
|
||
const project = await resolveProjectIdentifier(ctx, params.projectIdentifier);
|
||
if ("error" in project) {
|
||
return project;
|
||
}
|
||
|
||
const newStartDate = parseIsoDate(params.newStartDate, "newStartDate");
|
||
const newEndDate = parseIsoDate(params.newEndDate, "newEndDate");
|
||
const caller = createTimelineCaller(createScopedCallerContext(ctx));
|
||
let result;
|
||
try {
|
||
result = await caller.applyShift({
|
||
projectId: project.id,
|
||
newStartDate,
|
||
newEndDate,
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantTimelineMutationError(error, "applyShift");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["allocation", "timeline", "project"],
|
||
success: true,
|
||
message: `Shifted project ${project.shortCode ?? project.name ?? project.id} to ${fmtDate(newStartDate)} - ${fmtDate(newEndDate)}.`,
|
||
project: {
|
||
id: result.project.id,
|
||
startDate: fmtDate(result.project.startDate),
|
||
endDate: fmtDate(result.project.endDate),
|
||
},
|
||
validation: result.validation,
|
||
};
|
||
},
|
||
|
||
async quick_assign_timeline_resource(params: {
|
||
resourceIdentifier: string;
|
||
projectIdentifier: string;
|
||
startDate: string;
|
||
endDate: string;
|
||
hoursPerDay?: number;
|
||
role?: string;
|
||
roleId?: string;
|
||
status?: AllocationStatus;
|
||
}, ctx: ToolContext) {
|
||
assertPermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
||
assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS);
|
||
|
||
const [resource, project] = await Promise.all([
|
||
resolveResourceIdentifier(ctx, params.resourceIdentifier),
|
||
resolveProjectIdentifier(ctx, params.projectIdentifier),
|
||
]);
|
||
if ("error" in resource) {
|
||
return resource;
|
||
}
|
||
if ("error" in project) {
|
||
return project;
|
||
}
|
||
|
||
const caller = createTimelineCaller(createScopedCallerContext(ctx));
|
||
let allocation;
|
||
try {
|
||
allocation = await caller.quickAssign({
|
||
resourceId: resource.id,
|
||
projectId: project.id,
|
||
startDate: parseIsoDate(params.startDate, "startDate"),
|
||
endDate: parseIsoDate(params.endDate, "endDate"),
|
||
...(params.hoursPerDay !== undefined ? { hoursPerDay: params.hoursPerDay } : {}),
|
||
...(params.role !== undefined ? { role: params.role } : {}),
|
||
...(params.roleId !== undefined ? { roleId: params.roleId } : {}),
|
||
...(params.status !== undefined ? { status: params.status } : {}),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantTimelineMutationError(error, "quickAssign");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["allocation", "timeline", "project"],
|
||
success: true,
|
||
message: `Quick-assigned ${resource.displayName} to ${project.name} (${project.shortCode ?? project.id}).`,
|
||
allocation: {
|
||
id: allocation.id,
|
||
projectId: allocation.projectId,
|
||
resourceId: allocation.resourceId ?? null,
|
||
startDate: fmtDate(toDate(allocation.startDate)),
|
||
endDate: fmtDate(toDate(allocation.endDate)),
|
||
hoursPerDay: allocation.hoursPerDay,
|
||
role: allocation.role ?? null,
|
||
status: allocation.status,
|
||
},
|
||
};
|
||
},
|
||
|
||
async batch_quick_assign_timeline_resources(params: {
|
||
assignments: Array<{
|
||
resourceIdentifier: string;
|
||
projectIdentifier: string;
|
||
startDate: string;
|
||
endDate: string;
|
||
hoursPerDay?: number;
|
||
role?: string;
|
||
status?: AllocationStatus;
|
||
}>;
|
||
}, ctx: ToolContext) {
|
||
assertPermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
||
assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS);
|
||
|
||
const resolvedAssignments = await Promise.all(params.assignments.map(async (assignment, index) => {
|
||
const [resource, project] = await Promise.all([
|
||
resolveResourceIdentifier(ctx, assignment.resourceIdentifier),
|
||
resolveProjectIdentifier(ctx, assignment.projectIdentifier),
|
||
]);
|
||
if ("error" in resource) {
|
||
return toAssistantIndexedFieldError(index, "resourceIdentifier", resource.error);
|
||
}
|
||
if ("error" in project) {
|
||
return toAssistantIndexedFieldError(index, "projectIdentifier", project.error);
|
||
}
|
||
return {
|
||
resourceId: resource.id,
|
||
projectId: project.id,
|
||
startDate: parseIsoDate(assignment.startDate, `assignments[${index}].startDate`),
|
||
endDate: parseIsoDate(assignment.endDate, `assignments[${index}].endDate`),
|
||
...(assignment.hoursPerDay !== undefined ? { hoursPerDay: assignment.hoursPerDay } : {}),
|
||
...(assignment.role !== undefined ? { role: assignment.role } : {}),
|
||
...(assignment.status !== undefined ? { status: assignment.status } : {}),
|
||
};
|
||
}));
|
||
|
||
const resolutionError = resolvedAssignments.find(
|
||
(assignment): assignment is AssistantIndexedFieldErrorResult => isAssistantToolErrorResult(assignment),
|
||
);
|
||
if (resolutionError) {
|
||
return resolutionError;
|
||
}
|
||
const validAssignments = resolvedAssignments.filter(
|
||
(assignment): assignment is BatchQuickAssignmentInput => !isAssistantToolErrorResult(assignment),
|
||
);
|
||
|
||
const caller = createTimelineCaller(createScopedCallerContext(ctx));
|
||
let result;
|
||
try {
|
||
result = await caller.batchQuickAssign({
|
||
assignments: validAssignments,
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantTimelineMutationError(error, "quickAssign");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["allocation", "timeline", "project"],
|
||
success: true,
|
||
message: `Created ${result.count} timeline quick-assignment(s).`,
|
||
count: result.count,
|
||
};
|
||
},
|
||
|
||
async batch_shift_timeline_allocations(params: {
|
||
allocationIds: string[];
|
||
daysDelta: number;
|
||
mode?: "move" | "resize-start" | "resize-end";
|
||
}, ctx: ToolContext) {
|
||
assertPermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
||
assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS);
|
||
|
||
const caller = createTimelineCaller(createScopedCallerContext(ctx));
|
||
let result;
|
||
try {
|
||
result = await caller.batchShiftAllocations({
|
||
allocationIds: params.allocationIds,
|
||
daysDelta: params.daysDelta,
|
||
...(params.mode !== undefined ? { mode: params.mode } : {}),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantTimelineMutationError(error, "batchShift");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["allocation", "timeline", "project"],
|
||
success: true,
|
||
message: `Shifted ${result.count} allocation(s) by ${params.daysDelta} day(s).`,
|
||
count: result.count,
|
||
};
|
||
},
|
||
|
||
async list_allocations(params: {
|
||
resourceId?: string; projectId?: string;
|
||
resourceName?: string; projectCode?: string;
|
||
status?: string; limit?: number;
|
||
}, ctx: ToolContext) {
|
||
const caller = createAllocationCaller(createScopedCallerContext(ctx));
|
||
const status = params.status && Object.values(AllocationStatus).includes(params.status as AllocationStatus)
|
||
? params.status as AllocationStatus
|
||
: undefined;
|
||
const readModel = await caller.listView({
|
||
resourceId: params.resourceId,
|
||
projectId: params.projectId,
|
||
status,
|
||
});
|
||
|
||
const resourceNameQuery = params.resourceName?.trim().toLowerCase();
|
||
const projectCodeQuery = params.projectCode?.trim().toLowerCase();
|
||
const limit = Math.min(params.limit ?? 30, 50);
|
||
|
||
return readModel.assignments
|
||
.filter((assignment) => {
|
||
if (
|
||
resourceNameQuery
|
||
&& !assignment.resource?.displayName?.toLowerCase().includes(resourceNameQuery)
|
||
) {
|
||
return false;
|
||
}
|
||
if (
|
||
projectCodeQuery
|
||
&& !assignment.project?.shortCode?.toLowerCase().includes(projectCodeQuery)
|
||
) {
|
||
return false;
|
||
}
|
||
return true;
|
||
})
|
||
.slice(0, limit)
|
||
.map((assignment) => ({
|
||
id: assignment.id,
|
||
resource: assignment.resource?.displayName ?? "Unknown",
|
||
resourceEid: assignment.resource?.eid ?? null,
|
||
project: assignment.project?.name ?? "Unknown",
|
||
projectCode: assignment.project?.shortCode ?? null,
|
||
role: assignment.role ?? assignment.roleEntity?.name ?? null,
|
||
status: assignment.status,
|
||
hoursPerDay: assignment.hoursPerDay,
|
||
dailyCost: fmtEur(assignment.dailyCostCents),
|
||
start: fmtDate(new Date(assignment.startDate)),
|
||
end: fmtDate(new Date(assignment.endDate)),
|
||
}));
|
||
},
|
||
|
||
async get_budget_status(params: { projectId: string }, ctx: ToolContext) {
|
||
const project = await resolveProjectIdentifier(ctx, params.projectId);
|
||
if ("error" in project) {
|
||
return project;
|
||
}
|
||
|
||
const caller = createTimelineCaller(createScopedCallerContext(ctx));
|
||
const budgetStatus = await caller.getBudgetStatus({ projectId: project.id });
|
||
|
||
if (budgetStatus.budgetCents <= 0) {
|
||
return {
|
||
project: budgetStatus.projectName,
|
||
code: budgetStatus.projectCode,
|
||
budget: "Not set",
|
||
note: "No budget defined for this project",
|
||
totalAllocations: budgetStatus.totalAllocations,
|
||
};
|
||
}
|
||
|
||
return {
|
||
project: budgetStatus.projectName,
|
||
code: budgetStatus.projectCode,
|
||
budget: fmtEur(budgetStatus.budgetCents),
|
||
confirmed: fmtEur(budgetStatus.confirmedCents),
|
||
proposed: fmtEur(budgetStatus.proposedCents),
|
||
allocated: fmtEur(budgetStatus.allocatedCents),
|
||
remaining: fmtEur(budgetStatus.remainingCents),
|
||
utilization: `${budgetStatus.utilizationPercent.toFixed(1)}%`,
|
||
winWeighted: fmtEur(budgetStatus.winProbabilityWeightedCents),
|
||
};
|
||
},
|
||
|
||
async get_vacation_balance(params: { resourceId: string; year?: number }, ctx: ToolContext) {
|
||
const year = params.year ?? new Date().getFullYear();
|
||
const resource = await resolveResourceIdentifier(ctx, params.resourceId);
|
||
if ("error" in resource) return resource;
|
||
|
||
const caller = createEntitlementCaller(createScopedCallerContext(ctx));
|
||
return caller.getBalanceDetail({ resourceId: resource.id, year });
|
||
},
|
||
|
||
async list_vacations_upcoming(params: {
|
||
resourceName?: string; chapter?: string; daysAhead?: number; limit?: number;
|
||
}, ctx: ToolContext) {
|
||
const daysAhead = params.daysAhead ?? 30;
|
||
const limit = Math.min(params.limit ?? 30, 50);
|
||
const caller = createVacationCaller(createScopedCallerContext(ctx));
|
||
const now = new Date();
|
||
const until = new Date(now.getTime() + daysAhead * 24 * 60 * 60 * 1000);
|
||
|
||
const vacations = await caller.list({
|
||
status: "APPROVED",
|
||
startDate: now,
|
||
endDate: until,
|
||
limit,
|
||
});
|
||
|
||
const filtered = vacations
|
||
.filter((vacation) => {
|
||
if (params.resourceName) {
|
||
const resourceName = vacation.resource?.displayName?.toLowerCase() ?? "";
|
||
if (!resourceName.includes(params.resourceName.toLowerCase())) {
|
||
return false;
|
||
}
|
||
}
|
||
if (params.chapter) {
|
||
const chapter = vacation.resource?.chapter?.toLowerCase() ?? "";
|
||
if (!chapter.includes(params.chapter.toLowerCase())) {
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
})
|
||
.slice(0, limit);
|
||
|
||
return filtered.map((v) => ({
|
||
resource: v.resource.displayName,
|
||
eid: v.resource.eid,
|
||
chapter: v.resource.chapter ?? null,
|
||
type: v.type,
|
||
start: fmtDate(v.startDate),
|
||
end: fmtDate(v.endDate),
|
||
isHalfDay: v.isHalfDay,
|
||
halfDayPart: v.halfDayPart,
|
||
}));
|
||
},
|
||
|
||
async list_holidays_by_region(params: {
|
||
countryCode: string;
|
||
federalState?: string;
|
||
metroCity?: string;
|
||
year?: number;
|
||
periodStart?: string;
|
||
periodEnd?: string;
|
||
}, ctx: ToolContext) {
|
||
const { year, periodStart, periodEnd } = resolveHolidayPeriod(params);
|
||
const caller = createHolidayCalendarCaller(createScopedCallerContext(ctx));
|
||
const resolved = await caller.resolveHolidaysDetail({
|
||
periodStart,
|
||
periodEnd,
|
||
countryCode: params.countryCode.trim().toUpperCase(),
|
||
...(params.federalState ? { stateCode: params.federalState } : {}),
|
||
...(params.metroCity ? { metroCityName: params.metroCity } : {}),
|
||
});
|
||
|
||
return {
|
||
locationContext: resolved.locationContext,
|
||
year,
|
||
periodStart: resolved.periodStart,
|
||
periodEnd: resolved.periodEnd,
|
||
count: resolved.count,
|
||
summary: resolved.summary,
|
||
holidays: resolved.holidays,
|
||
};
|
||
},
|
||
|
||
async get_resource_holidays(params: {
|
||
identifier: string;
|
||
year?: number;
|
||
periodStart?: string;
|
||
periodEnd?: string;
|
||
}, ctx: ToolContext) {
|
||
const resource = await resolveResourceIdentifier(ctx, params.identifier);
|
||
if ("error" in resource) return resource;
|
||
|
||
const { year, periodStart, periodEnd } = resolveHolidayPeriod(params);
|
||
const caller = createHolidayCalendarCaller(createScopedCallerContext(ctx));
|
||
const resolved = await caller.resolveResourceHolidaysDetail({
|
||
resourceId: resource.id,
|
||
periodStart,
|
||
periodEnd,
|
||
});
|
||
|
||
return {
|
||
resource: resolved.resource,
|
||
year,
|
||
periodStart: resolved.periodStart,
|
||
periodEnd: resolved.periodEnd,
|
||
count: resolved.count,
|
||
summary: resolved.summary,
|
||
holidays: resolved.holidays,
|
||
};
|
||
},
|
||
|
||
async list_holiday_calendars(params: {
|
||
includeInactive?: boolean;
|
||
countryCode?: string;
|
||
scopeType?: "COUNTRY" | "STATE" | "CITY";
|
||
stateCode?: string;
|
||
metroCity?: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createHolidayCalendarCaller(createScopedCallerContext(ctx));
|
||
return caller.listCalendarsDetail(params);
|
||
},
|
||
|
||
async get_holiday_calendar(params: {
|
||
identifier: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createHolidayCalendarCaller(createScopedCallerContext(ctx));
|
||
const identifier = params.identifier.trim();
|
||
return resolveEntityOrAssistantError(
|
||
() => caller.getCalendarByIdentifierDetail({ identifier }),
|
||
`Holiday calendar not found: ${identifier}`,
|
||
);
|
||
},
|
||
|
||
async preview_resolved_holiday_calendar(params: {
|
||
countryId: string;
|
||
stateCode?: string;
|
||
metroCityId?: string;
|
||
year: number;
|
||
}, ctx: ToolContext) {
|
||
const input = PreviewResolvedHolidaysSchema.parse(params);
|
||
const caller = createHolidayCalendarCaller(createScopedCallerContext(ctx));
|
||
return caller.previewResolvedHolidaysDetail(input);
|
||
},
|
||
|
||
async create_holiday_calendar(params: {
|
||
name: string;
|
||
scopeType: "COUNTRY" | "STATE" | "CITY";
|
||
countryId: string;
|
||
stateCode?: string;
|
||
metroCityId?: string;
|
||
isActive?: boolean;
|
||
priority?: number;
|
||
}, ctx: ToolContext) {
|
||
assertAdminRole(ctx);
|
||
const caller = createHolidayCalendarCaller(createScopedCallerContext(ctx));
|
||
let created;
|
||
try {
|
||
created = await caller.createCalendar(CreateHolidayCalendarSchema.parse(params));
|
||
} catch (error) {
|
||
const mapped = toAssistantHolidayCalendarMutationError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["holidayCalendar", "vacation"],
|
||
success: true,
|
||
calendar: formatHolidayCalendar(created),
|
||
message: `Created holiday calendar: ${created.name}`,
|
||
};
|
||
},
|
||
|
||
async update_holiday_calendar(params: {
|
||
id: string;
|
||
data: {
|
||
name?: string;
|
||
stateCode?: string | null;
|
||
metroCityId?: string | null;
|
||
isActive?: boolean;
|
||
priority?: number;
|
||
};
|
||
}, ctx: ToolContext) {
|
||
assertAdminRole(ctx);
|
||
const caller = createHolidayCalendarCaller(createScopedCallerContext(ctx));
|
||
const input = {
|
||
id: params.id,
|
||
data: UpdateHolidayCalendarSchema.parse(params.data),
|
||
};
|
||
let updated;
|
||
try {
|
||
updated = await caller.updateCalendar(input);
|
||
} catch (error) {
|
||
const mapped = toAssistantHolidayCalendarMutationError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["holidayCalendar", "vacation"],
|
||
success: true,
|
||
calendar: formatHolidayCalendar(updated),
|
||
message: `Updated holiday calendar: ${updated.name}`,
|
||
};
|
||
},
|
||
|
||
async delete_holiday_calendar(params: {
|
||
id: string;
|
||
}, ctx: ToolContext) {
|
||
assertAdminRole(ctx);
|
||
const caller = createHolidayCalendarCaller(createScopedCallerContext(ctx));
|
||
let deleted;
|
||
try {
|
||
deleted = await caller.deleteCalendar({ id: params.id });
|
||
} catch (error) {
|
||
const mapped = toAssistantHolidayCalendarNotFoundError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["holidayCalendar", "vacation"],
|
||
success: true,
|
||
message: `Deleted holiday calendar: ${deleted.name}`,
|
||
};
|
||
},
|
||
|
||
async create_holiday_calendar_entry(params: {
|
||
holidayCalendarId: string;
|
||
date: string;
|
||
name: string;
|
||
isRecurringAnnual?: boolean;
|
||
source?: string;
|
||
}, ctx: ToolContext) {
|
||
assertAdminRole(ctx);
|
||
const caller = createHolidayCalendarCaller(createScopedCallerContext(ctx));
|
||
let created;
|
||
try {
|
||
created = await caller.createEntry(CreateHolidayCalendarEntrySchema.parse(params));
|
||
} catch (error) {
|
||
const mapped = toAssistantHolidayEntryMutationError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["holidayCalendar", "vacation"],
|
||
success: true,
|
||
entry: formatHolidayCalendarEntry(created),
|
||
message: `Created holiday entry: ${created.name}`,
|
||
};
|
||
},
|
||
|
||
async update_holiday_calendar_entry(params: {
|
||
id: string;
|
||
data: {
|
||
date?: string;
|
||
name?: string;
|
||
isRecurringAnnual?: boolean;
|
||
source?: string | null;
|
||
};
|
||
}, ctx: ToolContext) {
|
||
assertAdminRole(ctx);
|
||
const caller = createHolidayCalendarCaller(createScopedCallerContext(ctx));
|
||
const input = {
|
||
id: params.id,
|
||
data: UpdateHolidayCalendarEntrySchema.parse(params.data),
|
||
};
|
||
let updated;
|
||
try {
|
||
updated = await caller.updateEntry(input);
|
||
} catch (error) {
|
||
const mapped = toAssistantHolidayEntryMutationError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["holidayCalendar", "vacation"],
|
||
success: true,
|
||
entry: formatHolidayCalendarEntry(updated),
|
||
message: `Updated holiday entry: ${updated.name}`,
|
||
};
|
||
},
|
||
|
||
async delete_holiday_calendar_entry(params: {
|
||
id: string;
|
||
}, ctx: ToolContext) {
|
||
assertAdminRole(ctx);
|
||
const caller = createHolidayCalendarCaller(createScopedCallerContext(ctx));
|
||
let deleted;
|
||
try {
|
||
deleted = await caller.deleteEntry({ id: params.id });
|
||
} catch (error) {
|
||
const mapped = toAssistantHolidayEntryNotFoundError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["holidayCalendar", "vacation"],
|
||
success: true,
|
||
message: `Deleted holiday entry: ${deleted.name}`,
|
||
};
|
||
},
|
||
|
||
async list_roles(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createRoleCaller(createScopedCallerContext(ctx));
|
||
const roles = await caller.list({});
|
||
return roles.map((role) => ({
|
||
id: role.id,
|
||
name: role.name,
|
||
color: role.color,
|
||
}));
|
||
},
|
||
|
||
async search_by_skill(params: { skill: string }, ctx: ToolContext) {
|
||
const caller = createResourceCaller(createScopedCallerContext(ctx));
|
||
const matched = await caller.searchBySkills({
|
||
rules: [{ skill: params.skill, minProficiency: 1 }],
|
||
operator: "OR",
|
||
});
|
||
|
||
return matched.slice(0, 20).map((resource) => ({
|
||
id: resource.id,
|
||
eid: resource.eid,
|
||
name: resource.displayName,
|
||
matchedSkill: resource.matchedSkills[0]?.skill ?? null,
|
||
level: resource.matchedSkills[0]?.proficiency ?? null,
|
||
chapter: resource.chapter ?? null,
|
||
}));
|
||
},
|
||
|
||
async get_statistics(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createDashboardCaller(createScopedCallerContext(ctx));
|
||
return caller.getStatisticsDetail();
|
||
},
|
||
|
||
async get_chargeability(params: { resourceId: string; month?: string }, ctx: ToolContext) {
|
||
const now = new Date();
|
||
const month = params.month ?? `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||
const resource = await resolveResourceIdentifier(ctx, params.resourceId);
|
||
if ("error" in resource) {
|
||
return resource;
|
||
}
|
||
|
||
const caller = createResourceCaller(createScopedCallerContext(ctx));
|
||
return caller.getChargeabilitySummary({
|
||
resourceId: resource.id,
|
||
month,
|
||
});
|
||
},
|
||
|
||
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 } : {}),
|
||
});
|
||
},
|
||
|
||
async search_estimates(params: {
|
||
projectCode?: string; query?: string; status?: string; limit?: number;
|
||
}, ctx: ToolContext) {
|
||
const caller = createEstimateCaller(createScopedCallerContext(ctx));
|
||
let projectId: string | undefined;
|
||
if (params.projectCode) {
|
||
const project = await resolveProjectIdentifier(ctx, params.projectCode);
|
||
if ("error" in project) {
|
||
return project;
|
||
}
|
||
projectId = project.id;
|
||
}
|
||
|
||
return caller.list({
|
||
...(params.query ? { query: params.query } : {}),
|
||
...(params.status ? { status: params.status as EstimateStatus } : {}),
|
||
...(projectId ? { projectId } : {}),
|
||
});
|
||
},
|
||
|
||
async list_clients(params: { query?: string; limit?: number }, ctx: ToolContext) {
|
||
const limit = Math.min(params.limit ?? 20, 50);
|
||
const caller = createClientCaller(createScopedCallerContext(ctx));
|
||
const clients = await caller.list({
|
||
isActive: true,
|
||
...(params.query ? { search: params.query } : {}),
|
||
});
|
||
return clients.slice(0, limit).map((c) => ({
|
||
id: c.id,
|
||
name: c.name,
|
||
code: c.code,
|
||
projectCount: c._count.projects,
|
||
}));
|
||
},
|
||
|
||
async list_org_units(params: { level?: number }, ctx: ToolContext) {
|
||
const caller = createOrgUnitCaller(createScopedCallerContext(ctx));
|
||
const units = await caller.list({
|
||
isActive: true,
|
||
...(params.level !== undefined ? { level: params.level } : {}),
|
||
});
|
||
const details = await Promise.all(units.map((unit) => caller.getById({ id: unit.id })));
|
||
|
||
return details.map((u) => ({
|
||
id: u.id,
|
||
name: u.name,
|
||
shortName: u.shortName,
|
||
level: u.level,
|
||
parent: u.parent?.name ?? null,
|
||
resourceCount: u._count.resources,
|
||
}));
|
||
},
|
||
|
||
// ── NAVIGATION TOOLS ──
|
||
|
||
async navigate_to_page(params: {
|
||
page: string;
|
||
eids?: string; chapters?: string; projectIds?: string; clientIds?: string;
|
||
countryCodes?: string; startDate?: string; days?: number;
|
||
}, _ctx: ToolContext) {
|
||
const pageMap: Record<string, string> = {
|
||
timeline: "/timeline",
|
||
dashboard: "/dashboard",
|
||
resources: "/resources",
|
||
projects: "/projects",
|
||
allocations: "/allocations",
|
||
staffing: "/staffing",
|
||
estimates: "/estimates",
|
||
vacations: "/vacations",
|
||
"my-vacations": "/vacations/my",
|
||
roles: "/roles",
|
||
"skills-analytics": "/analytics/skills",
|
||
chargeability: "/reports/chargeability",
|
||
"computation-graph": "/analytics/computation-graph",
|
||
};
|
||
const path = pageMap[params.page];
|
||
if (!path) return { error: `Unknown page: ${params.page}. Available: ${Object.keys(pageMap).join(", ")}` };
|
||
|
||
// Build query params for pages that support them
|
||
const queryParts: string[] = [];
|
||
if (params.eids) queryParts.push(`eids=${encodeURIComponent(params.eids)}`);
|
||
if (params.chapters) queryParts.push(`chapters=${encodeURIComponent(params.chapters)}`);
|
||
if (params.projectIds) queryParts.push(`projectIds=${encodeURIComponent(params.projectIds)}`);
|
||
if (params.clientIds) queryParts.push(`clientIds=${encodeURIComponent(params.clientIds)}`);
|
||
if (params.countryCodes) queryParts.push(`countryCodes=${encodeURIComponent(params.countryCodes)}`);
|
||
if (params.startDate) queryParts.push(`startDate=${encodeURIComponent(params.startDate)}`);
|
||
if (params.days) queryParts.push(`days=${params.days}`);
|
||
|
||
const url = queryParts.length > 0 ? `${path}?${queryParts.join("&")}` : path;
|
||
|
||
return {
|
||
__action: "navigate",
|
||
url,
|
||
description: `Navigiere zu ${path}`,
|
||
};
|
||
},
|
||
|
||
// ── WRITE TOOLS ──
|
||
|
||
async create_allocation(params: {
|
||
resourceId: string; projectId: string;
|
||
startDate: string; endDate: string;
|
||
hoursPerDay: number; role?: string;
|
||
}, ctx: ToolContext) {
|
||
assertPermission(ctx, "manageAllocations" as PermissionKey);
|
||
const [resource, project] = await Promise.all([
|
||
resolveResourceIdentifier(ctx, params.resourceId),
|
||
resolveProjectIdentifier(ctx, params.projectId),
|
||
]);
|
||
if ("error" in resource) {
|
||
return resource;
|
||
}
|
||
if ("error" in project) {
|
||
return project;
|
||
}
|
||
|
||
const caller = createAllocationCaller(createScopedCallerContext(ctx));
|
||
try {
|
||
const result = await caller.ensureAssignment({
|
||
resourceId: resource.id,
|
||
projectId: project.id,
|
||
startDate: parseIsoDate(params.startDate, "startDate"),
|
||
endDate: parseIsoDate(params.endDate, "endDate"),
|
||
hoursPerDay: params.hoursPerDay,
|
||
...(params.role ? { role: params.role } : {}),
|
||
});
|
||
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["allocation", "timeline"],
|
||
success: true,
|
||
message: `${result.action === "reactivated" ? "Reactivated" : "Created"} allocation: ${resource.displayName} → ${project.name} (${project.shortCode}), ${params.hoursPerDay}h/day, ${params.startDate} to ${params.endDate}`,
|
||
allocationId: result.assignment.id,
|
||
status: result.assignment.status,
|
||
};
|
||
} catch (error) {
|
||
if (error instanceof TRPCError && error.code === "CONFLICT") {
|
||
return { error: "Allocation already exists for this resource/project/dates. No new allocation created." };
|
||
}
|
||
throw error;
|
||
}
|
||
},
|
||
|
||
async cancel_allocation(params: {
|
||
allocationId?: string;
|
||
resourceName?: string; projectCode?: string;
|
||
startDate?: string; endDate?: string;
|
||
}, ctx: ToolContext) {
|
||
assertPermission(ctx, "manageAllocations" as PermissionKey);
|
||
|
||
const caller = createAllocationCaller(createScopedCallerContext(ctx));
|
||
let resourceId: string | undefined;
|
||
let projectId: string | undefined;
|
||
if (!params.allocationId && params.resourceName && params.projectCode) {
|
||
const [resource, project] = await Promise.all([
|
||
resolveResourceIdentifier(ctx, params.resourceName),
|
||
resolveProjectIdentifier(ctx, params.projectCode),
|
||
]);
|
||
if ("error" in resource) {
|
||
return resource;
|
||
}
|
||
if ("error" in project) {
|
||
return project;
|
||
}
|
||
resourceId = resource.id;
|
||
projectId = project.id;
|
||
}
|
||
|
||
const startDate = parseOptionalIsoDate(params.startDate, "startDate");
|
||
const endDate = parseOptionalIsoDate(params.endDate, "endDate");
|
||
let assignment;
|
||
try {
|
||
assignment = await caller.resolveAssignment({
|
||
...(params.allocationId ? { assignmentId: params.allocationId } : {}),
|
||
...(resourceId ? { resourceId } : {}),
|
||
...(projectId ? { projectId } : {}),
|
||
...(startDate ? { startDate } : {}),
|
||
...(endDate ? { endDate } : {}),
|
||
selectionMode: "WINDOW",
|
||
excludeCancelled: true,
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantAllocationNotFoundError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
try {
|
||
await caller.updateAssignment({
|
||
id: assignment.id,
|
||
data: UpdateAssignmentSchema.parse({ status: AllocationStatus.CANCELLED }),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantAllocationNotFoundError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["allocation", "timeline"],
|
||
success: true,
|
||
message: `Cancelled allocation: ${assignment.resource.displayName} → ${assignment.project.name} (${assignment.project.shortCode}), ${fmtDate(assignment.startDate)} to ${fmtDate(assignment.endDate)}`,
|
||
};
|
||
},
|
||
|
||
async update_allocation_status(params: {
|
||
allocationId?: string;
|
||
resourceName?: string; projectCode?: string;
|
||
startDate?: string;
|
||
newStatus: string;
|
||
}, ctx: ToolContext) {
|
||
assertPermission(ctx, "manageAllocations" as PermissionKey);
|
||
|
||
const validStatuses = ["PROPOSED", "CONFIRMED", "ACTIVE", "COMPLETED", "CANCELLED"];
|
||
if (!validStatuses.includes(params.newStatus)) {
|
||
return { error: `Invalid status: ${params.newStatus}. Valid: ${validStatuses.join(", ")}` };
|
||
}
|
||
|
||
const caller = createAllocationCaller(createScopedCallerContext(ctx));
|
||
let resourceId: string | undefined;
|
||
let projectId: string | undefined;
|
||
if (!params.allocationId && params.resourceName && params.projectCode) {
|
||
const [resource, project] = await Promise.all([
|
||
resolveResourceIdentifier(ctx, params.resourceName),
|
||
resolveProjectIdentifier(ctx, params.projectCode),
|
||
]);
|
||
if ("error" in resource) {
|
||
return resource;
|
||
}
|
||
if ("error" in project) {
|
||
return project;
|
||
}
|
||
resourceId = resource.id;
|
||
projectId = project.id;
|
||
}
|
||
|
||
const startDate = parseOptionalIsoDate(params.startDate, "startDate");
|
||
let assignment;
|
||
try {
|
||
assignment = await caller.resolveAssignment({
|
||
...(params.allocationId ? { assignmentId: params.allocationId } : {}),
|
||
...(resourceId ? { resourceId } : {}),
|
||
...(projectId ? { projectId } : {}),
|
||
...(startDate ? { startDate } : {}),
|
||
selectionMode: "EXACT_START",
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantAllocationNotFoundError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
const oldStatus = assignment.status;
|
||
try {
|
||
await caller.updateAssignment({
|
||
id: assignment.id,
|
||
data: UpdateAssignmentSchema.parse({
|
||
status: params.newStatus as AllocationStatus,
|
||
}),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantAllocationNotFoundError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["allocation", "timeline"],
|
||
success: true,
|
||
message: `Updated allocation status: ${assignment.resource.displayName} → ${assignment.project.name} (${assignment.project.shortCode}), ${fmtDate(assignment.startDate)} to ${fmtDate(assignment.endDate)}: ${oldStatus} → ${params.newStatus}`,
|
||
};
|
||
},
|
||
|
||
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,
|
||
};
|
||
},
|
||
|
||
async update_project(params: {
|
||
id: string; name?: string; budgetCents?: number;
|
||
winProbability?: number; status?: string;
|
||
responsiblePerson?: string;
|
||
}, ctx: ToolContext) {
|
||
assertPermission(ctx, "manageProjects" as PermissionKey);
|
||
const project = await resolveProjectIdentifier(ctx, params.id);
|
||
if ("error" in project) return project;
|
||
|
||
const data: Record<string, unknown> = {};
|
||
if (params.name !== undefined) data.name = params.name;
|
||
if (params.budgetCents !== undefined) data.budgetCents = params.budgetCents;
|
||
if (params.winProbability !== undefined) data.winProbability = params.winProbability;
|
||
if (params.status !== undefined) data.status = params.status;
|
||
|
||
// Validate responsible person against existing resources
|
||
if (params.responsiblePerson !== undefined) {
|
||
const result = await resolveResponsiblePerson(params.responsiblePerson, ctx);
|
||
if ("error" in result) return { error: result.error };
|
||
data.responsiblePerson = result.displayName;
|
||
}
|
||
|
||
const parsedData = UpdateProjectSchema.parse(data);
|
||
const updatedFields = Object.keys(parsedData);
|
||
if (updatedFields.length === 0) return { error: "No fields to update" };
|
||
|
||
const caller = createProjectCaller(createScopedCallerContext(ctx));
|
||
let updated;
|
||
try {
|
||
updated = await caller.update({ id: project.id, data: parsedData });
|
||
} catch (error) {
|
||
const mapped = toAssistantProjectMutationError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["project"],
|
||
success: true,
|
||
message: `Updated project ${updated.name} (${updated.shortCode})`,
|
||
updatedFields,
|
||
};
|
||
},
|
||
|
||
async create_project(params: {
|
||
shortCode: string; name: string; orderType: string;
|
||
allocationType?: string; budgetCents: number;
|
||
startDate: string; endDate: string;
|
||
winProbability?: number; status?: string;
|
||
responsiblePerson?: string; color?: string;
|
||
blueprintName?: string; clientName?: string;
|
||
}, ctx: ToolContext) {
|
||
assertPermission(ctx, "manageProjects" as PermissionKey);
|
||
if (!params.responsiblePerson?.trim()) {
|
||
return { error: "responsiblePerson is required to create a project." };
|
||
}
|
||
|
||
// Validate responsible person against existing resources
|
||
const responsible = await resolveResponsiblePerson(params.responsiblePerson, ctx);
|
||
if ("error" in responsible) return { error: responsible.error };
|
||
|
||
const blueprintCaller = createBlueprintCaller(createScopedCallerContext(ctx));
|
||
const clientCaller = createClientCaller(createScopedCallerContext(ctx));
|
||
|
||
let blueprintId: string | undefined;
|
||
if (params.blueprintName) {
|
||
const blueprint = await resolveEntityOrAssistantError(
|
||
() => blueprintCaller.resolveByIdentifier({ identifier: params.blueprintName! }),
|
||
`Blueprint not found: "${params.blueprintName}"`,
|
||
);
|
||
if ("error" in blueprint) {
|
||
return blueprint;
|
||
}
|
||
blueprintId = blueprint.id;
|
||
}
|
||
|
||
let clientId: string | undefined;
|
||
if (params.clientName) {
|
||
const client = await resolveEntityOrAssistantError(
|
||
() => clientCaller.resolveByIdentifier({ identifier: params.clientName! }),
|
||
`Client not found: "${params.clientName}"`,
|
||
);
|
||
if ("error" in client) {
|
||
return client;
|
||
}
|
||
clientId = client.id;
|
||
}
|
||
|
||
const input = CreateProjectSchema.parse({
|
||
shortCode: params.shortCode,
|
||
name: params.name,
|
||
orderType: params.orderType,
|
||
allocationType: params.allocationType ?? "INT",
|
||
budgetCents: params.budgetCents,
|
||
startDate: params.startDate,
|
||
endDate: params.endDate,
|
||
winProbability: params.winProbability ?? 100,
|
||
status: params.status ?? "DRAFT",
|
||
responsiblePerson: responsible.displayName,
|
||
...(params.color ? { color: params.color } : {}),
|
||
...(blueprintId ? { blueprintId } : {}),
|
||
...(clientId ? { clientId } : {}),
|
||
staffingReqs: [],
|
||
dynamicFields: {},
|
||
});
|
||
|
||
const caller = createProjectCaller(createScopedCallerContext(ctx));
|
||
let project;
|
||
try {
|
||
project = await caller.create(input);
|
||
} catch (error) {
|
||
const mapped = toAssistantProjectCreationError(error, input.shortCode);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["project"],
|
||
success: true,
|
||
message: `Created project: ${project.name} (${project.shortCode}), budget ${fmtEur(params.budgetCents)}, ${params.startDate} to ${params.endDate}, status: ${project.status}`,
|
||
projectId: project.id,
|
||
shortCode: project.shortCode,
|
||
};
|
||
},
|
||
|
||
// ── 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: {
|
||
resourceId: string; type: string;
|
||
startDate: string; endDate: string;
|
||
isHalfDay?: boolean; halfDayPart?: string; note?: string;
|
||
}, ctx: ToolContext) {
|
||
const resource = await resolveResourceIdentifier(ctx, params.resourceId);
|
||
if ("error" in resource) return resource;
|
||
|
||
const caller = createVacationCaller(createScopedCallerContext(ctx));
|
||
const type = parseAssistantVacationRequestType(params.type);
|
||
let vacation;
|
||
try {
|
||
vacation = await caller.create({
|
||
resourceId: resource.id,
|
||
type,
|
||
startDate: parseIsoDate(params.startDate, "startDate"),
|
||
endDate: parseIsoDate(params.endDate, "endDate"),
|
||
...(params.isHalfDay !== undefined ? { isHalfDay: params.isHalfDay } : {}),
|
||
...(params.halfDayPart !== undefined ? { halfDayPart: params.halfDayPart as "MORNING" | "AFTERNOON" } : {}),
|
||
...(params.note !== undefined ? { note: params.note } : {}),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantVacationCreationError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
const effectiveDays = "effectiveDays" in vacation && typeof vacation.effectiveDays === "number"
|
||
? vacation.effectiveDays
|
||
: null;
|
||
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["vacation"],
|
||
success: true,
|
||
message: `Created ${type} for ${resource.displayName}: ${params.startDate} to ${params.endDate} (status: ${vacation.status}${effectiveDays !== null ? `, deducted ${effectiveDays} day(s)` : ""})`,
|
||
vacationId: vacation.id,
|
||
vacation,
|
||
};
|
||
},
|
||
|
||
async approve_vacation(params: { vacationId: string }, ctx: ToolContext) {
|
||
const caller = createVacationCaller(createScopedCallerContext(ctx));
|
||
let existing;
|
||
try {
|
||
existing = await caller.getById({ id: params.vacationId });
|
||
} catch (error) {
|
||
const mapped = toAssistantVacationMutationError(error, "approve");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
let approved;
|
||
try {
|
||
approved = await caller.approve({ id: params.vacationId });
|
||
} catch (error) {
|
||
const mapped = toAssistantVacationMutationError(error, "approve");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["vacation"],
|
||
success: true,
|
||
warnings: approved.warnings,
|
||
vacation: approved,
|
||
message: `Approved vacation for ${existing.resource?.displayName ?? params.vacationId}`,
|
||
};
|
||
},
|
||
|
||
async reject_vacation(params: { vacationId: string; reason?: string }, ctx: ToolContext) {
|
||
const caller = createVacationCaller(createScopedCallerContext(ctx));
|
||
let existing;
|
||
try {
|
||
existing = await caller.getById({ id: params.vacationId });
|
||
} catch (error) {
|
||
const mapped = toAssistantVacationMutationError(error, "reject");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
let rejected;
|
||
try {
|
||
rejected = await caller.reject({
|
||
id: params.vacationId,
|
||
...(params.reason !== undefined ? { rejectionReason: params.reason } : {}),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantVacationMutationError(error, "reject");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["vacation"],
|
||
success: true,
|
||
vacation: rejected,
|
||
message: `Rejected vacation for ${existing.resource?.displayName ?? params.vacationId}${params.reason ? `: ${params.reason}` : ""}`,
|
||
};
|
||
},
|
||
|
||
async cancel_vacation(params: { vacationId: string }, ctx: ToolContext) {
|
||
const caller = createVacationCaller(createScopedCallerContext(ctx));
|
||
let existing;
|
||
try {
|
||
existing = await caller.getById({ id: params.vacationId });
|
||
} catch (error) {
|
||
const mapped = toAssistantVacationMutationError(error, "cancel");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
let cancelled;
|
||
try {
|
||
cancelled = await caller.cancel({ id: params.vacationId });
|
||
} catch (error) {
|
||
const mapped = toAssistantVacationMutationError(error, "cancel");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["vacation"],
|
||
success: true,
|
||
vacation: cancelled,
|
||
message: `Cancelled vacation for ${existing.resource?.displayName ?? params.vacationId}`,
|
||
};
|
||
},
|
||
|
||
async get_pending_vacation_approvals(params: { limit?: number }, ctx: ToolContext) {
|
||
const limit = Math.min(params.limit ?? 20, 50);
|
||
const caller = createVacationCaller(createScopedCallerContext(ctx));
|
||
const vacations = await caller.getPendingApprovals();
|
||
return vacations.map((v) => ({
|
||
id: v.id,
|
||
resource: v.resource.displayName,
|
||
eid: v.resource.eid,
|
||
chapter: v.resource.chapter,
|
||
type: v.type,
|
||
start: fmtDate(v.startDate),
|
||
end: fmtDate(v.endDate),
|
||
isHalfDay: v.isHalfDay,
|
||
})).slice(0, limit);
|
||
},
|
||
|
||
async get_team_vacation_overlap(params: {
|
||
resourceId: string; startDate: string; endDate: string;
|
||
}, ctx: ToolContext) {
|
||
const resource = await resolveResourceIdentifier(ctx, params.resourceId);
|
||
if ("error" in resource) {
|
||
return resource;
|
||
}
|
||
|
||
const caller = createVacationCaller(createScopedCallerContext(ctx));
|
||
return caller.getTeamOverlapDetail({
|
||
resourceId: resource.id,
|
||
startDate: parseIsoDate(params.startDate, "startDate"),
|
||
endDate: parseIsoDate(params.endDate, "endDate"),
|
||
});
|
||
},
|
||
|
||
// ── ENTITLEMENT ──
|
||
|
||
async get_entitlement_summary(params: { year?: number; resourceName?: string }, ctx: ToolContext) {
|
||
const year = params.year ?? new Date().getFullYear();
|
||
const caller = createEntitlementCaller(createScopedCallerContext(ctx));
|
||
return caller.getYearSummaryDetail({
|
||
year,
|
||
...(params.resourceName ? { resourceName: params.resourceName } : {}),
|
||
});
|
||
},
|
||
|
||
async set_entitlement(params: {
|
||
resourceId: string; year: number; entitledDays: number; carryoverDays?: number;
|
||
}, ctx: ToolContext) {
|
||
if (params.carryoverDays !== undefined) {
|
||
return {
|
||
error: "Manual carryoverDays is not supported here. Carryover is computed automatically from prior-year balances.",
|
||
};
|
||
}
|
||
|
||
const resource = await resolveResourceIdentifier(ctx, params.resourceId);
|
||
if ("error" in resource) return resource;
|
||
|
||
const caller = createEntitlementCaller(createScopedCallerContext(ctx));
|
||
let entitlement;
|
||
try {
|
||
entitlement = await caller.set({
|
||
resourceId: resource.id,
|
||
year: params.year,
|
||
entitledDays: params.entitledDays,
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantEntitlementMutationError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["vacation"],
|
||
success: true,
|
||
entitlement,
|
||
message: `Set entitlement for ${resource.displayName} (${params.year}): ${params.entitledDays} days`,
|
||
};
|
||
},
|
||
|
||
// ── DEMAND / STAFFING ──
|
||
|
||
async list_demands(params: { projectId?: string; status?: string; limit?: number }, ctx: ToolContext) {
|
||
const limit = Math.min(params.limit ?? 30, 50);
|
||
const caller = createAllocationCaller(createScopedCallerContext(ctx));
|
||
const resolvedProject = params.projectId
|
||
? await resolveProjectIdentifier(ctx, params.projectId)
|
||
: null;
|
||
if (resolvedProject && "error" in resolvedProject) {
|
||
return resolvedProject;
|
||
}
|
||
const demands = await caller.listDemands({
|
||
...(resolvedProject ? { projectId: resolvedProject.id } : {}),
|
||
...(params.status ? { status: params.status as AllocationStatus } : {}),
|
||
});
|
||
|
||
return demands.map((d) => ({
|
||
id: d.id,
|
||
project: d.project.name,
|
||
projectCode: d.project.shortCode,
|
||
role: d.roleEntity?.name ?? d.role ?? "Unspecified",
|
||
status: d.status,
|
||
headcount: d.headcount,
|
||
filled: d.assignments.length,
|
||
remaining: d.headcount - d.assignments.length,
|
||
hoursPerDay: d.hoursPerDay,
|
||
start: fmtDate(d.startDate),
|
||
end: fmtDate(d.endDate),
|
||
})).slice(0, limit);
|
||
},
|
||
|
||
async create_demand(params: {
|
||
projectId: string; roleName: string; headcount?: number;
|
||
hoursPerDay: number; startDate: string; endDate: string;
|
||
}, ctx: ToolContext) {
|
||
assertPermission(ctx, "manageAllocations" as PermissionKey);
|
||
const roleCaller = createRoleCaller(createScopedCallerContext(ctx));
|
||
const [project, role] = await Promise.all([
|
||
resolveProjectIdentifier(ctx, params.projectId),
|
||
resolveEntityOrAssistantError(
|
||
() => roleCaller.resolveByIdentifier({ identifier: params.roleName }),
|
||
`Role not found: ${params.roleName}`,
|
||
),
|
||
]);
|
||
if ("error" in project) {
|
||
return project;
|
||
}
|
||
if ("error" in role) {
|
||
return role;
|
||
}
|
||
|
||
const caller = createAllocationCaller(createScopedCallerContext(ctx));
|
||
let demand;
|
||
try {
|
||
demand = await caller.createDemand({
|
||
projectId: project.id,
|
||
roleId: role.id,
|
||
role: role.name,
|
||
headcount: params.headcount ?? 1,
|
||
hoursPerDay: params.hoursPerDay,
|
||
startDate: parseIsoDate(params.startDate, "startDate"),
|
||
endDate: parseIsoDate(params.endDate, "endDate"),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantDemandCreationError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["allocation"],
|
||
success: true,
|
||
message: `Created demand: ${role.name} × ${params.headcount ?? 1} for ${project.name} (${project.shortCode}), ${params.hoursPerDay}h/day, ${params.startDate} to ${params.endDate}`,
|
||
demandId: demand.id,
|
||
};
|
||
},
|
||
|
||
async fill_demand(params: { demandId: string; resourceId: string }, ctx: ToolContext) {
|
||
assertPermission(ctx, "manageAllocations" as PermissionKey);
|
||
const allocationCaller = createAllocationCaller(createScopedCallerContext(ctx));
|
||
const resource = await resolveResourceIdentifier(ctx, params.resourceId);
|
||
if ("error" in resource) return resource;
|
||
|
||
let result;
|
||
try {
|
||
result = await allocationCaller.assignResourceToDemand({
|
||
demandRequirementId: params.demandId,
|
||
resourceId: resource.id,
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantDemandFillError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
const roleName = result.demandRequirement.roleEntity?.name ?? result.demandRequirement.role ?? null;
|
||
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["allocation", "timeline"],
|
||
success: true,
|
||
message: `Assigned ${resource.displayName} to ${roleName ?? "demand"} on ${result.demandRequirement.project.name} (${result.demandRequirement.project.shortCode})`,
|
||
assignmentId: result.assignment.id,
|
||
};
|
||
},
|
||
|
||
async check_resource_availability(params: {
|
||
resourceId: string; startDate: string; endDate: string;
|
||
}, ctx: ToolContext) {
|
||
const resource = await resolveResourceIdentifier(ctx, params.resourceId);
|
||
if ("error" in resource) {
|
||
return resource;
|
||
}
|
||
|
||
const caller = createAllocationCaller(createScopedCallerContext(ctx));
|
||
return caller.getResourceAvailabilitySummary({
|
||
resourceId: resource.id,
|
||
startDate: parseIsoDate(params.startDate, "startDate"),
|
||
endDate: parseIsoDate(params.endDate, "endDate"),
|
||
});
|
||
},
|
||
|
||
async get_staffing_suggestions(params: {
|
||
projectId: string; roleName?: string;
|
||
startDate?: string; endDate?: string; limit?: number;
|
||
}, ctx: ToolContext) {
|
||
const project = await resolveProjectIdentifier(ctx, params.projectId);
|
||
if ("error" in project) {
|
||
return project;
|
||
}
|
||
|
||
const caller = createStaffingCaller(createScopedCallerContext(ctx));
|
||
const startDate = parseOptionalIsoDate(params.startDate, "startDate");
|
||
const endDate = parseOptionalIsoDate(params.endDate, "endDate");
|
||
return caller.getProjectStaffingSuggestions({
|
||
projectId: project.id,
|
||
...(params.roleName ? { roleName: params.roleName } : {}),
|
||
...(startDate ? { startDate } : {}),
|
||
...(endDate ? { endDate } : {}),
|
||
...(params.limit ? { limit: params.limit } : {}),
|
||
});
|
||
},
|
||
|
||
async find_capacity(params: {
|
||
startDate: string; endDate: string;
|
||
minHoursPerDay?: number; roleName?: string;
|
||
chapter?: string; limit?: number;
|
||
}, ctx: ToolContext) {
|
||
const caller = createStaffingCaller(createScopedCallerContext(ctx));
|
||
return caller.searchCapacity({
|
||
startDate: parseIsoDate(params.startDate, "startDate"),
|
||
endDate: parseIsoDate(params.endDate, "endDate"),
|
||
minHoursPerDay: params.minHoursPerDay ?? 4,
|
||
...(params.roleName ? { roleName: params.roleName } : {}),
|
||
...(params.chapter ? { chapter: params.chapter } : {}),
|
||
...(params.limit ? { limit: params.limit } : {}),
|
||
});
|
||
},
|
||
|
||
// ── BLUEPRINT ──
|
||
|
||
async list_blueprints(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createBlueprintCaller(createScopedCallerContext(ctx));
|
||
const blueprints = await caller.listSummaries();
|
||
return blueprints.map((b) => ({
|
||
id: b.id,
|
||
name: b.name,
|
||
projectCount: b._count.projects,
|
||
}));
|
||
},
|
||
|
||
async get_blueprint(params: { identifier: string }, ctx: ToolContext) {
|
||
const caller = createBlueprintCaller(createScopedCallerContext(ctx));
|
||
const bp = await resolveEntityOrAssistantError(
|
||
() => caller.getByIdentifier({ identifier: params.identifier }),
|
||
`Blueprint not found: ${params.identifier}`,
|
||
);
|
||
if ("error" in bp) {
|
||
return bp;
|
||
}
|
||
return {
|
||
id: bp.id,
|
||
name: bp.name,
|
||
fieldDefs: bp.fieldDefs,
|
||
rolePresets: bp.rolePresets,
|
||
};
|
||
},
|
||
|
||
// ── RATE CARDS ──
|
||
|
||
async list_rate_cards(params: { query?: string; limit?: number }, ctx: ToolContext) {
|
||
const caller = createRateCardCaller(createScopedCallerContext(ctx));
|
||
const cards = await caller.list({
|
||
isActive: true,
|
||
...(params.query ? { search: params.query } : {}),
|
||
});
|
||
return cards.map((c) => ({
|
||
id: c.id,
|
||
name: c.name,
|
||
effectiveFrom: fmtDate(c.effectiveFrom),
|
||
effectiveTo: fmtDate(c.effectiveTo),
|
||
lineCount: c._count.lines,
|
||
})).slice(0, Math.min(params.limit ?? 20, 50));
|
||
},
|
||
|
||
async resolve_rate(params: { resourceId?: string; roleName?: string; date?: string }, ctx: ToolContext) {
|
||
const caller = createRateCardCaller(createScopedCallerContext(ctx));
|
||
const date = parseOptionalIsoDate(params.date, "date");
|
||
if (params.resourceId) {
|
||
const resource = await resolveResourceIdentifier(ctx, params.resourceId);
|
||
if ("error" in resource) {
|
||
return resource;
|
||
}
|
||
return caller.resolveBestRate({
|
||
resourceId: resource.id,
|
||
...(date ? { date } : {}),
|
||
});
|
||
}
|
||
|
||
return caller.resolveBestRate({
|
||
...(params.roleName ? { roleName: params.roleName } : {}),
|
||
...(date ? { date } : {}),
|
||
});
|
||
},
|
||
|
||
// ── ESTIMATES ──
|
||
|
||
async get_estimate_detail(params: { estimateId: string }, ctx: ToolContext) {
|
||
assertPermission(ctx, PermissionKey.VIEW_COSTS);
|
||
const caller = createEstimateCaller(createScopedCallerContext(ctx));
|
||
try {
|
||
return await caller.getById({ id: params.estimateId });
|
||
} catch (error) {
|
||
const mapped = toAssistantEstimateNotFoundError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
},
|
||
|
||
async list_estimate_versions(params: { estimateId: string }, ctx: ToolContext) {
|
||
const caller = createEstimateCaller(createScopedCallerContext(ctx));
|
||
try {
|
||
return await caller.listVersions({ estimateId: params.estimateId });
|
||
} catch (error) {
|
||
const mapped = toAssistantEstimateNotFoundError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
},
|
||
|
||
async get_estimate_version_snapshot(params: {
|
||
estimateId: string;
|
||
versionId?: string;
|
||
}, ctx: ToolContext) {
|
||
assertPermission(ctx, PermissionKey.VIEW_COSTS);
|
||
const caller = createEstimateCaller(createScopedCallerContext(ctx));
|
||
try {
|
||
return await caller.getVersionSnapshot({
|
||
estimateId: params.estimateId,
|
||
...(params.versionId !== undefined ? { versionId: params.versionId } : {}),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantEstimateNotFoundError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
},
|
||
|
||
async create_estimate(params: {
|
||
projectId?: string;
|
||
projectCode?: string;
|
||
name: string;
|
||
opportunityId?: string;
|
||
baseCurrency?: string;
|
||
status?: EstimateStatus;
|
||
versionLabel?: string;
|
||
versionNotes?: string;
|
||
assumptions?: CreateEstimateInput["assumptions"];
|
||
scopeItems?: CreateEstimateInput["scopeItems"];
|
||
demandLines?: CreateEstimateInput["demandLines"];
|
||
resourceSnapshots?: CreateEstimateInput["resourceSnapshots"];
|
||
metrics?: CreateEstimateInput["metrics"];
|
||
}, ctx: ToolContext) {
|
||
const caller = createEstimateCaller(createScopedCallerContext(ctx));
|
||
let projectId = params.projectId;
|
||
if (!projectId && params.projectCode) {
|
||
const project = await resolveProjectIdentifier(ctx, params.projectCode);
|
||
if ("error" in project) {
|
||
return project;
|
||
}
|
||
projectId = project.id;
|
||
}
|
||
|
||
let estimate;
|
||
try {
|
||
estimate = await caller.create({
|
||
name: params.name,
|
||
...(projectId ? { projectId } : {}),
|
||
...(params.opportunityId !== undefined ? { opportunityId: params.opportunityId } : {}),
|
||
...(params.baseCurrency !== undefined ? { baseCurrency: params.baseCurrency } : {}),
|
||
...(params.status !== undefined ? { status: params.status } : {}),
|
||
...(params.versionLabel !== undefined ? { versionLabel: params.versionLabel } : {}),
|
||
...(params.versionNotes !== undefined ? { versionNotes: params.versionNotes } : {}),
|
||
...(params.assumptions !== undefined ? { assumptions: params.assumptions } : {}),
|
||
...(params.scopeItems !== undefined ? { scopeItems: params.scopeItems } : {}),
|
||
...(params.demandLines !== undefined ? { demandLines: params.demandLines } : {}),
|
||
...(params.resourceSnapshots !== undefined ? { resourceSnapshots: params.resourceSnapshots } : {}),
|
||
...(params.metrics !== undefined ? { metrics: params.metrics } : {}),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantEstimateCreationError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["estimate"],
|
||
success: true,
|
||
estimate,
|
||
estimateId: estimate.id,
|
||
message: `Created estimate "${estimate.name}".`,
|
||
};
|
||
},
|
||
|
||
async clone_estimate(params: {
|
||
sourceEstimateId: string;
|
||
name?: string;
|
||
projectId?: string;
|
||
projectCode?: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createEstimateCaller(createScopedCallerContext(ctx));
|
||
let projectId = params.projectId;
|
||
if (!projectId && params.projectCode) {
|
||
const project = await resolveProjectIdentifier(ctx, params.projectCode);
|
||
if ("error" in project) {
|
||
return project;
|
||
}
|
||
projectId = project.id;
|
||
}
|
||
|
||
let estimate;
|
||
try {
|
||
estimate = await caller.clone({
|
||
sourceEstimateId: params.sourceEstimateId,
|
||
...(params.name !== undefined ? { name: params.name } : {}),
|
||
...(projectId ? { projectId } : {}),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantEstimateMutationError(error, "clone");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["estimate"],
|
||
success: true,
|
||
estimate,
|
||
estimateId: estimate.id,
|
||
message: `Cloned estimate "${estimate.name}".`,
|
||
};
|
||
},
|
||
|
||
async update_estimate_draft(params: {
|
||
id: string;
|
||
projectId?: string;
|
||
projectCode?: string;
|
||
name?: string;
|
||
opportunityId?: string;
|
||
baseCurrency?: string;
|
||
status?: EstimateStatus;
|
||
versionLabel?: string;
|
||
versionNotes?: string;
|
||
assumptions?: UpdateEstimateDraftInput["assumptions"];
|
||
scopeItems?: UpdateEstimateDraftInput["scopeItems"];
|
||
demandLines?: UpdateEstimateDraftInput["demandLines"];
|
||
resourceSnapshots?: UpdateEstimateDraftInput["resourceSnapshots"];
|
||
metrics?: UpdateEstimateDraftInput["metrics"];
|
||
}, ctx: ToolContext) {
|
||
const caller = createEstimateCaller(createScopedCallerContext(ctx));
|
||
let projectId = params.projectId;
|
||
if (!projectId && params.projectCode) {
|
||
const project = await resolveProjectIdentifier(ctx, params.projectCode);
|
||
if ("error" in project) {
|
||
return project;
|
||
}
|
||
projectId = project.id;
|
||
}
|
||
|
||
let estimate;
|
||
try {
|
||
estimate = await caller.updateDraft({
|
||
id: params.id,
|
||
...(projectId ? { projectId } : {}),
|
||
...(params.name !== undefined ? { name: params.name } : {}),
|
||
...(params.opportunityId !== undefined ? { opportunityId: params.opportunityId } : {}),
|
||
...(params.baseCurrency !== undefined ? { baseCurrency: params.baseCurrency } : {}),
|
||
...(params.status !== undefined ? { status: params.status } : {}),
|
||
...(params.versionLabel !== undefined ? { versionLabel: params.versionLabel } : {}),
|
||
...(params.versionNotes !== undefined ? { versionNotes: params.versionNotes } : {}),
|
||
...(params.assumptions !== undefined ? { assumptions: params.assumptions } : {}),
|
||
...(params.scopeItems !== undefined ? { scopeItems: params.scopeItems } : {}),
|
||
...(params.demandLines !== undefined ? { demandLines: params.demandLines } : {}),
|
||
...(params.resourceSnapshots !== undefined ? { resourceSnapshots: params.resourceSnapshots } : {}),
|
||
...(params.metrics !== undefined ? { metrics: params.metrics } : {}),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantEstimateMutationError(error, "updateDraft");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["estimate"],
|
||
success: true,
|
||
estimate,
|
||
estimateId: estimate.id,
|
||
message: `Updated estimate draft "${estimate.name}".`,
|
||
};
|
||
},
|
||
|
||
async submit_estimate_version(params: {
|
||
estimateId: string;
|
||
versionId?: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createEstimateCaller(createScopedCallerContext(ctx));
|
||
let estimate;
|
||
try {
|
||
estimate = await caller.submitVersion({
|
||
estimateId: params.estimateId,
|
||
...(params.versionId !== undefined ? { versionId: params.versionId } : {}),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantEstimateMutationError(error, "submitVersion");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["estimate"],
|
||
success: true,
|
||
estimate,
|
||
estimateId: estimate.id,
|
||
message: `Submitted estimate version for "${estimate.name}".`,
|
||
};
|
||
},
|
||
|
||
async approve_estimate_version(params: {
|
||
estimateId: string;
|
||
versionId?: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createEstimateCaller(createScopedCallerContext(ctx));
|
||
let estimate;
|
||
try {
|
||
estimate = await caller.approveVersion({
|
||
estimateId: params.estimateId,
|
||
...(params.versionId !== undefined ? { versionId: params.versionId } : {}),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantEstimateMutationError(error, "approveVersion");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["estimate"],
|
||
success: true,
|
||
estimate,
|
||
estimateId: estimate.id,
|
||
message: `Approved estimate version for "${estimate.name}".`,
|
||
};
|
||
},
|
||
|
||
async create_estimate_revision(params: {
|
||
estimateId: string;
|
||
sourceVersionId?: string;
|
||
label?: string;
|
||
notes?: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createEstimateCaller(createScopedCallerContext(ctx));
|
||
let estimate;
|
||
try {
|
||
estimate = await caller.createRevision({
|
||
estimateId: params.estimateId,
|
||
...(params.sourceVersionId !== undefined ? { sourceVersionId: params.sourceVersionId } : {}),
|
||
...(params.label !== undefined ? { label: params.label } : {}),
|
||
...(params.notes !== undefined ? { notes: params.notes } : {}),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantEstimateMutationError(error, "createRevision");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["estimate"],
|
||
success: true,
|
||
estimate,
|
||
estimateId: estimate.id,
|
||
message: `Created a new estimate revision for "${estimate.name}".`,
|
||
};
|
||
},
|
||
|
||
async create_estimate_export(params: {
|
||
estimateId: string;
|
||
versionId?: string;
|
||
format: EstimateExportFormat;
|
||
}, ctx: ToolContext) {
|
||
const caller = createEstimateCaller(createScopedCallerContext(ctx));
|
||
let estimate;
|
||
try {
|
||
estimate = await caller.createExport({
|
||
estimateId: params.estimateId,
|
||
format: params.format,
|
||
...(params.versionId !== undefined ? { versionId: params.versionId } : {}),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantEstimateMutationError(error, "createExport");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["estimate"],
|
||
success: true,
|
||
estimate,
|
||
estimateId: estimate.id,
|
||
message: `Created ${params.format} export for estimate "${estimate.name}".`,
|
||
};
|
||
},
|
||
|
||
async create_estimate_planning_handoff(params: {
|
||
estimateId: string;
|
||
versionId?: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createEstimateCaller(createScopedCallerContext(ctx));
|
||
let result;
|
||
try {
|
||
result = await caller.createPlanningHandoff({
|
||
estimateId: params.estimateId,
|
||
...(params.versionId !== undefined ? { versionId: params.versionId } : {}),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantEstimateMutationError(error, "createPlanningHandoff");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["estimate", "allocation", "timeline"],
|
||
success: true,
|
||
...result,
|
||
message: `Created planning handoff for estimate ${params.estimateId}.`,
|
||
};
|
||
},
|
||
|
||
async generate_estimate_weekly_phasing(params: {
|
||
estimateId: string;
|
||
startDate: string;
|
||
endDate: string;
|
||
pattern?: "even" | "front_loaded" | "back_loaded" | "custom";
|
||
}, ctx: ToolContext) {
|
||
const caller = createEstimateCaller(createScopedCallerContext(ctx));
|
||
let result;
|
||
try {
|
||
result = await caller.generateWeeklyPhasing({
|
||
estimateId: params.estimateId,
|
||
startDate: params.startDate,
|
||
endDate: params.endDate,
|
||
...(params.pattern !== undefined ? { pattern: params.pattern } : {}),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantEstimateMutationError(error, "generateWeeklyPhasing");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["estimate"],
|
||
success: true,
|
||
...result,
|
||
message: `Generated weekly phasing for estimate ${params.estimateId}.`,
|
||
};
|
||
},
|
||
|
||
async get_estimate_weekly_phasing(params: {
|
||
estimateId: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createEstimateCaller(createScopedCallerContext(ctx));
|
||
try {
|
||
return await caller.getWeeklyPhasing({ estimateId: params.estimateId });
|
||
} catch (error) {
|
||
const mapped = toAssistantEstimateReadError(error, "weeklyPhasing");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
},
|
||
|
||
async get_estimate_commercial_terms(params: {
|
||
estimateId: string;
|
||
versionId?: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createEstimateCaller(createScopedCallerContext(ctx));
|
||
try {
|
||
return await caller.getCommercialTerms({
|
||
estimateId: params.estimateId,
|
||
...(params.versionId !== undefined ? { versionId: params.versionId } : {}),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantEstimateReadError(error, "commercialTerms");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
},
|
||
|
||
async update_estimate_commercial_terms(params: {
|
||
estimateId: string;
|
||
versionId?: string;
|
||
terms: Record<string, unknown>;
|
||
}, ctx: ToolContext) {
|
||
const caller = createEstimateCaller(createScopedCallerContext(ctx));
|
||
let result;
|
||
try {
|
||
result = await caller.updateCommercialTerms({
|
||
estimateId: params.estimateId,
|
||
terms: params.terms,
|
||
...(params.versionId !== undefined ? { versionId: params.versionId } : {}),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantEstimateMutationError(error, "updateCommercialTerms");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["estimate"],
|
||
success: true,
|
||
...result,
|
||
message: `Updated commercial terms for estimate ${params.estimateId}.`,
|
||
};
|
||
},
|
||
|
||
// ── ROLES ──
|
||
|
||
async create_role(params: {
|
||
name: string;
|
||
description?: string;
|
||
color?: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createRoleCaller(createScopedCallerContext(ctx));
|
||
let role;
|
||
try {
|
||
role = await caller.create(CreateRoleSchema.parse(params));
|
||
} catch (error) {
|
||
const mapped = toAssistantRoleMutationError(error, "create");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return { __action: "invalidate", scope: ["role"], success: true, message: `Created role: ${role.name}`, roleId: role.id, role };
|
||
},
|
||
|
||
async update_role(params: {
|
||
id: string;
|
||
name?: string;
|
||
description?: string;
|
||
color?: string;
|
||
isActive?: boolean;
|
||
}, ctx: ToolContext) {
|
||
const caller = createRoleCaller(createScopedCallerContext(ctx));
|
||
const data = UpdateRoleSchema.parse({
|
||
...(params.name !== undefined ? { name: params.name } : {}),
|
||
...(params.description !== undefined ? { description: params.description } : {}),
|
||
...(params.color !== undefined ? { color: params.color } : {}),
|
||
...(params.isActive !== undefined ? { isActive: params.isActive } : {}),
|
||
});
|
||
if (Object.keys(data).length === 0) return { error: "No fields to update" };
|
||
let role;
|
||
try {
|
||
role = await caller.update({ id: params.id, data });
|
||
} catch (error) {
|
||
const mapped = toAssistantRoleMutationError(error, "update");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return { __action: "invalidate", scope: ["role"], success: true, message: `Updated role: ${role.name}`, roleId: role.id, role };
|
||
},
|
||
|
||
async delete_role(params: { id: string }, ctx: ToolContext) {
|
||
const caller = createRoleCaller(createScopedCallerContext(ctx));
|
||
let role;
|
||
try {
|
||
role = await caller.getById({ id: params.id });
|
||
await caller.delete({ id: params.id });
|
||
} catch (error) {
|
||
const mapped = toAssistantRoleMutationError(error, "delete");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return { __action: "invalidate", scope: ["role"], success: true, message: `Deleted role: ${role.name}` };
|
||
},
|
||
|
||
// ── CLIENTS ──
|
||
|
||
async create_client(params: {
|
||
name: string;
|
||
code?: string;
|
||
parentId?: string;
|
||
sortOrder?: number;
|
||
tags?: string[];
|
||
}, ctx: ToolContext) {
|
||
const caller = createClientCaller(createScopedCallerContext(ctx));
|
||
let client;
|
||
try {
|
||
client = await caller.create(CreateClientSchema.parse(params));
|
||
} catch (error) {
|
||
const mapped = toAssistantClientMutationError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return { __action: "invalidate", scope: ["client"], success: true, message: `Created client: ${client.name}`, clientId: client.id, client };
|
||
},
|
||
|
||
async update_client(params: {
|
||
id: string;
|
||
name?: string;
|
||
code?: string | null;
|
||
sortOrder?: number;
|
||
isActive?: boolean;
|
||
parentId?: string | null;
|
||
tags?: string[];
|
||
}, ctx: ToolContext) {
|
||
const caller = createClientCaller(createScopedCallerContext(ctx));
|
||
const data = UpdateClientSchema.parse({
|
||
...(params.name !== undefined ? { name: params.name } : {}),
|
||
...(params.code !== undefined ? { code: params.code } : {}),
|
||
...(params.sortOrder !== undefined ? { sortOrder: params.sortOrder } : {}),
|
||
...(params.isActive !== undefined ? { isActive: params.isActive } : {}),
|
||
...(params.parentId !== undefined ? { parentId: params.parentId } : {}),
|
||
...(params.tags !== undefined ? { tags: params.tags } : {}),
|
||
});
|
||
if (Object.keys(data).length === 0) return { error: "No fields to update" };
|
||
let client;
|
||
try {
|
||
client = await caller.update({ id: params.id, data });
|
||
} catch (error) {
|
||
const mapped = toAssistantClientMutationError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return { __action: "invalidate", scope: ["client"], success: true, message: `Updated client: ${client.name}`, clientId: client.id, client };
|
||
},
|
||
|
||
async delete_client(params: { id: string }, ctx: ToolContext) {
|
||
const caller = createClientCaller(createScopedCallerContext(ctx));
|
||
let client;
|
||
try {
|
||
client = await caller.getById({ id: params.id });
|
||
await caller.delete({ id: params.id });
|
||
} catch (error) {
|
||
const mapped = toAssistantClientMutationError(error, "delete");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return { __action: "invalidate", scope: ["client"], success: true, message: `Deleted client: ${client.name}` };
|
||
},
|
||
|
||
// ── ADMIN / CONFIG ──
|
||
|
||
async list_countries(params: { includeInactive?: boolean; search?: string }, ctx: ToolContext) {
|
||
const caller = createCountryCaller(createScopedCallerContext(ctx));
|
||
const countries = await caller.list(
|
||
params.includeInactive
|
||
? undefined
|
||
: { isActive: true },
|
||
);
|
||
const normalizedSearch = params.search?.trim().toLowerCase();
|
||
const filteredCountries = normalizedSearch
|
||
? countries.filter((country) =>
|
||
country.code.toLowerCase().includes(normalizedSearch)
|
||
|| country.name.toLowerCase().includes(normalizedSearch))
|
||
: countries;
|
||
|
||
return {
|
||
count: filteredCountries.length,
|
||
countries: filteredCountries.map(formatCountry),
|
||
};
|
||
},
|
||
|
||
async get_country(params: { identifier: string }, ctx: ToolContext) {
|
||
const caller = createCountryCaller(createScopedCallerContext(ctx));
|
||
let country;
|
||
try {
|
||
country = await caller.getByIdentifier({ identifier: params.identifier });
|
||
} catch (error) {
|
||
const mapped = toAssistantCountryNotFoundError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
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}`,
|
||
};
|
||
},
|
||
|
||
async list_management_levels(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createManagementLevelCaller(createScopedCallerContext(ctx));
|
||
const groups = await caller.listGroups();
|
||
return groups.map((g) => ({
|
||
id: g.id,
|
||
name: g.name,
|
||
target: g.targetPercentage ? `${g.targetPercentage}%` : null,
|
||
levels: g.levels.map((l: { id: string; name: string }) => ({ id: l.id, name: l.name })),
|
||
}));
|
||
},
|
||
|
||
async list_utilization_categories(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createUtilizationCategoryCaller(createScopedCallerContext(ctx));
|
||
const categories = await caller.list();
|
||
const categoriesWithCounts = await Promise.all(
|
||
categories.map(async (category) => {
|
||
const detailed = await caller.getById({ id: category.id });
|
||
return {
|
||
category,
|
||
projectCount: detailed._count.projects,
|
||
};
|
||
}),
|
||
);
|
||
return categoriesWithCounts.map(({ category, projectCount }) => ({
|
||
id: category.id,
|
||
code: category.code,
|
||
name: category.name,
|
||
description: category.description,
|
||
projectCount,
|
||
}));
|
||
},
|
||
|
||
async list_calculation_rules(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createCalculationRuleCaller(createScopedCallerContext(ctx));
|
||
const rules = await caller.list();
|
||
return rules.map((rule) => ({
|
||
id: rule.id,
|
||
name: rule.name,
|
||
description: rule.description,
|
||
isActive: rule.isActive,
|
||
triggerType: rule.triggerType,
|
||
orderType: rule.orderType,
|
||
costEffect: rule.costEffect,
|
||
costReductionPercent: rule.costReductionPercent,
|
||
chargeabilityEffect: rule.chargeabilityEffect,
|
||
priority: rule.priority,
|
||
project: rule.project
|
||
? {
|
||
id: rule.project.id,
|
||
name: rule.project.name,
|
||
shortCode: rule.project.shortCode,
|
||
}
|
||
: null,
|
||
}));
|
||
},
|
||
|
||
async list_effort_rules(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createEffortRuleCaller(createScopedCallerContext(ctx));
|
||
const ruleSets = await caller.list();
|
||
return ruleSets.flatMap((ruleSet) => ruleSet.rules.map((rule) => ({
|
||
id: rule.id,
|
||
description: rule.description,
|
||
scopeType: rule.scopeType,
|
||
discipline: rule.discipline,
|
||
chapter: rule.chapter,
|
||
unitMode: rule.unitMode,
|
||
hoursPerUnit: rule.hoursPerUnit,
|
||
sortOrder: rule.sortOrder,
|
||
ruleSet: {
|
||
name: ruleSet.name,
|
||
isDefault: ruleSet.isDefault,
|
||
},
|
||
})));
|
||
},
|
||
|
||
async list_experience_multipliers(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createExperienceMultiplierCaller(createScopedCallerContext(ctx));
|
||
const multiplierSets = await caller.list();
|
||
return multiplierSets.flatMap((multiplierSet) => multiplierSet.rules.map((rule) => ({
|
||
id: rule.id,
|
||
description: rule.description,
|
||
chapter: rule.chapter,
|
||
location: rule.location,
|
||
level: rule.level,
|
||
costMultiplier: rule.costMultiplier,
|
||
billMultiplier: rule.billMultiplier,
|
||
shoringRatio: rule.shoringRatio,
|
||
additionalEffortRatio: rule.additionalEffortRatio,
|
||
sortOrder: rule.sortOrder,
|
||
multiplierSet: {
|
||
name: multiplierSet.name,
|
||
isDefault: multiplierSet.isDefault,
|
||
},
|
||
})));
|
||
},
|
||
|
||
async list_users(params: { limit?: number }, ctx: ToolContext) {
|
||
const caller = createUserCaller(createScopedCallerContext(ctx));
|
||
const users = await caller.list();
|
||
return users.slice(0, Math.min(params.limit ?? 50, 100));
|
||
},
|
||
|
||
async list_assignable_users(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createUserCaller(createScopedCallerContext(ctx));
|
||
return caller.listAssignable();
|
||
},
|
||
|
||
async get_current_user(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createUserCaller(createScopedCallerContext(ctx));
|
||
return caller.me();
|
||
},
|
||
|
||
async get_dashboard_layout(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createUserCaller(createScopedCallerContext(ctx));
|
||
return caller.getDashboardLayout();
|
||
},
|
||
|
||
async save_dashboard_layout(params: { layout: unknown[] }, ctx: ToolContext) {
|
||
const caller = createUserCaller(createScopedCallerContext(ctx));
|
||
const result = await caller.saveDashboardLayout({ layout: params.layout });
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["dashboard"],
|
||
success: true,
|
||
...result,
|
||
message: "Saved dashboard layout.",
|
||
};
|
||
},
|
||
|
||
async get_favorite_project_ids(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createUserCaller(createScopedCallerContext(ctx));
|
||
return caller.getFavoriteProjectIds();
|
||
},
|
||
|
||
async toggle_favorite_project(params: { projectId: string }, ctx: ToolContext) {
|
||
const caller = createUserCaller(createScopedCallerContext(ctx));
|
||
const result = await caller.toggleFavoriteProject({ projectId: params.projectId });
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["project"],
|
||
success: true,
|
||
...result,
|
||
message: result.added ? "Added project to favorites." : "Removed project from favorites.",
|
||
};
|
||
},
|
||
|
||
async get_column_preferences(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createUserCaller(createScopedCallerContext(ctx));
|
||
return caller.getColumnPreferences();
|
||
},
|
||
|
||
async set_column_preferences(params: {
|
||
view: "resources" | "projects" | "allocations" | "vacations" | "roles" | "users" | "blueprints";
|
||
visible?: string[];
|
||
sort?: { field: string; dir: "asc" | "desc" } | null;
|
||
rowOrder?: string[] | null;
|
||
}, ctx: ToolContext) {
|
||
const caller = createUserCaller(createScopedCallerContext(ctx));
|
||
const result = await caller.setColumnPreferences({
|
||
view: params.view,
|
||
...(params.visible !== undefined ? { visible: params.visible } : {}),
|
||
...(params.sort !== undefined ? { sort: params.sort } : {}),
|
||
...(params.rowOrder !== undefined ? { rowOrder: params.rowOrder } : {}),
|
||
});
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["user"],
|
||
success: true,
|
||
...result,
|
||
message: `Updated column preferences for ${params.view}.`,
|
||
};
|
||
},
|
||
|
||
async generate_totp_secret(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createUserCaller(createScopedCallerContext(ctx));
|
||
const result = await caller.generateTotpSecret();
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["user"],
|
||
success: true,
|
||
...result,
|
||
message: "Generated a new MFA TOTP secret.",
|
||
};
|
||
},
|
||
|
||
async verify_and_enable_totp(params: { token: string }, ctx: ToolContext) {
|
||
const caller = createUserCaller(createScopedCallerContext(ctx));
|
||
let result;
|
||
try {
|
||
result = await caller.verifyAndEnableTotp({ token: params.token });
|
||
} catch (error) {
|
||
const mapped = toAssistantTotpEnableError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["user"],
|
||
success: true,
|
||
...result,
|
||
message: "Enabled MFA TOTP.",
|
||
};
|
||
},
|
||
|
||
async get_mfa_status(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createUserCaller(createScopedCallerContext(ctx));
|
||
return caller.getMfaStatus();
|
||
},
|
||
|
||
async get_active_user_count(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createUserCaller(createScopedCallerContext(ctx));
|
||
return caller.activeCount();
|
||
},
|
||
|
||
async create_user(params: {
|
||
email: string;
|
||
name: string;
|
||
systemRole?: SystemRole;
|
||
password: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createUserCaller(createScopedCallerContext(ctx));
|
||
let user;
|
||
try {
|
||
user = await caller.create({
|
||
email: params.email,
|
||
name: params.name,
|
||
password: params.password,
|
||
...(params.systemRole !== undefined ? { systemRole: params.systemRole } : {}),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantUserMutationError(error, "create");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["user", "resource"],
|
||
success: true,
|
||
user,
|
||
userId: user.id,
|
||
message: `Created user ${user.name}.`,
|
||
};
|
||
},
|
||
|
||
async set_user_password(params: { userId: string; password: string }, ctx: ToolContext) {
|
||
const caller = createUserCaller(createScopedCallerContext(ctx));
|
||
let result;
|
||
try {
|
||
result = await caller.setPassword(params);
|
||
} catch (error) {
|
||
const mapped = toAssistantUserMutationError(error, "password");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["user"],
|
||
...result,
|
||
message: `Reset password for user ${params.userId}.`,
|
||
};
|
||
},
|
||
|
||
async update_user_role(params: { id: string; systemRole: SystemRole }, ctx: ToolContext) {
|
||
const caller = createUserCaller(createScopedCallerContext(ctx));
|
||
let user;
|
||
try {
|
||
user = await caller.updateRole(params);
|
||
} catch (error) {
|
||
const mapped = toAssistantUserMutationError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["user"],
|
||
success: true,
|
||
user,
|
||
userId: user.id,
|
||
message: `Updated role for ${user.name} to ${user.systemRole}.`,
|
||
};
|
||
},
|
||
|
||
async update_user_name(params: { id: string; name: string }, ctx: ToolContext) {
|
||
const caller = createUserCaller(createScopedCallerContext(ctx));
|
||
let user;
|
||
try {
|
||
user = await caller.updateName(params);
|
||
} catch (error) {
|
||
const mapped = toAssistantUserMutationError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["user"],
|
||
success: true,
|
||
user,
|
||
userId: user.id,
|
||
message: `Updated user name to ${user.name}.`,
|
||
};
|
||
},
|
||
|
||
async link_user_resource(params: { userId: string; resourceId?: string | null }, ctx: ToolContext) {
|
||
const caller = createUserCaller(createScopedCallerContext(ctx));
|
||
let result;
|
||
try {
|
||
result = await caller.linkResource({
|
||
userId: params.userId,
|
||
resourceId: params.resourceId ?? null,
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantUserResourceLinkError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["user", "resource"],
|
||
...result,
|
||
message: params.resourceId ? "Linked user to resource." : "Unlinked user resource.",
|
||
};
|
||
},
|
||
|
||
async auto_link_users_by_email(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createUserCaller(createScopedCallerContext(ctx));
|
||
const result = await caller.autoLinkAllByEmail();
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["user", "resource"],
|
||
success: true,
|
||
...result,
|
||
message: `Auto-linked ${result.linked} user(s) by email.`,
|
||
};
|
||
},
|
||
|
||
async set_user_permissions(params: {
|
||
userId: string;
|
||
overrides?: {
|
||
granted?: string[];
|
||
denied?: string[];
|
||
chapterIds?: string[];
|
||
} | null;
|
||
}, ctx: ToolContext) {
|
||
const caller = createUserCaller(createScopedCallerContext(ctx));
|
||
let user;
|
||
try {
|
||
user = await caller.setPermissions({
|
||
userId: params.userId,
|
||
overrides: params.overrides ?? null,
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantUserMutationError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["user"],
|
||
success: true,
|
||
user,
|
||
userId: user.id,
|
||
message: params.overrides ? "Updated user permission overrides." : "Cleared user permission overrides.",
|
||
};
|
||
},
|
||
|
||
async reset_user_permissions(params: { userId: string }, ctx: ToolContext) {
|
||
const caller = createUserCaller(createScopedCallerContext(ctx));
|
||
let user;
|
||
try {
|
||
user = await caller.resetPermissions(params);
|
||
} catch (error) {
|
||
const mapped = toAssistantUserMutationError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["user"],
|
||
success: true,
|
||
user,
|
||
userId: user.id,
|
||
message: "Reset user permissions to role defaults.",
|
||
};
|
||
},
|
||
|
||
async get_effective_user_permissions(params: { userId: string }, ctx: ToolContext) {
|
||
const caller = createUserCaller(createScopedCallerContext(ctx));
|
||
try {
|
||
return await caller.getEffectivePermissions(params);
|
||
} catch (error) {
|
||
const mapped = toAssistantUserMutationError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
},
|
||
|
||
async disable_user_totp(params: { userId: string }, ctx: ToolContext) {
|
||
const caller = createUserCaller(createScopedCallerContext(ctx));
|
||
let result;
|
||
try {
|
||
result = await caller.disableTotp(params);
|
||
} catch (error) {
|
||
const mapped = toAssistantUserMutationError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["user"],
|
||
success: true,
|
||
...result,
|
||
message: `Disabled TOTP for user ${params.userId}.`,
|
||
};
|
||
},
|
||
|
||
async list_notifications(params: { unreadOnly?: boolean; limit?: number }, ctx: ToolContext) {
|
||
const caller = createNotificationCaller(createScopedCallerContext(ctx));
|
||
return caller.list({
|
||
...(params.unreadOnly !== undefined ? { unreadOnly: params.unreadOnly } : {}),
|
||
...(params.limit !== undefined ? { limit: Math.min(params.limit, 100) } : {}),
|
||
});
|
||
},
|
||
|
||
async mark_notification_read(params: { notificationId?: string }, ctx: ToolContext) {
|
||
const caller = createNotificationCaller(createScopedCallerContext(ctx));
|
||
try {
|
||
await caller.markRead({
|
||
...(params.notificationId !== undefined ? { id: params.notificationId } : {}),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantNotificationReadError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["notification"],
|
||
success: true,
|
||
message: params.notificationId ? "Notification marked as read." : "All unread notifications marked as read.",
|
||
};
|
||
},
|
||
|
||
async get_unread_notification_count(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createNotificationCaller(createScopedCallerContext(ctx));
|
||
const count = await caller.unreadCount();
|
||
return { count };
|
||
},
|
||
|
||
async create_notification(params: {
|
||
userId: string;
|
||
type: string;
|
||
title: string;
|
||
body?: string;
|
||
entityId?: string;
|
||
entityType?: string;
|
||
category?: "NOTIFICATION" | "REMINDER" | "TASK" | "APPROVAL";
|
||
priority?: "LOW" | "NORMAL" | "HIGH" | "URGENT";
|
||
link?: string;
|
||
taskStatus?: "OPEN" | "IN_PROGRESS" | "DONE" | "DISMISSED";
|
||
taskAction?: string;
|
||
assigneeId?: string;
|
||
dueDate?: string;
|
||
channel?: "in_app" | "email" | "both";
|
||
senderId?: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createNotificationCaller(createScopedCallerContext(ctx));
|
||
const dueDate = parseOptionalDateTime(params.dueDate, "dueDate");
|
||
let notification;
|
||
try {
|
||
notification = await caller.create({
|
||
userId: params.userId,
|
||
type: params.type,
|
||
title: params.title,
|
||
...(params.body !== undefined ? { body: params.body } : {}),
|
||
...(params.entityId !== undefined ? { entityId: params.entityId } : {}),
|
||
...(params.entityType !== undefined ? { entityType: params.entityType } : {}),
|
||
...(params.category !== undefined ? { category: params.category } : {}),
|
||
...(params.priority !== undefined ? { priority: params.priority } : {}),
|
||
...(params.link !== undefined ? { link: params.link } : {}),
|
||
...(params.taskStatus !== undefined ? { taskStatus: params.taskStatus } : {}),
|
||
...(params.taskAction !== undefined ? { taskAction: params.taskAction } : {}),
|
||
...(params.assigneeId !== undefined ? { assigneeId: params.assigneeId } : {}),
|
||
...(dueDate ? { dueDate } : {}),
|
||
...(params.channel !== undefined ? { channel: params.channel } : {}),
|
||
...(params.senderId !== undefined ? { senderId: params.senderId } : {}),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantNotificationCreationError(error, "notification");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["notification"],
|
||
success: true,
|
||
notification,
|
||
notificationId: notification?.id ?? null,
|
||
message: `Created notification "${params.title}".`,
|
||
};
|
||
},
|
||
|
||
// ── DASHBOARD DETAIL ──
|
||
|
||
async get_dashboard_detail(params: { section?: string }, ctx: ToolContext) {
|
||
const caller = createDashboardCaller(createScopedCallerContext(ctx));
|
||
return caller.getDetail({ ...(params.section ? { section: params.section } : {}) });
|
||
},
|
||
|
||
// ── PROJECT MANAGEMENT ──
|
||
|
||
async delete_project(params: { projectId: string }, ctx: ToolContext) {
|
||
assertPermission(ctx, "manageProjects" as PermissionKey);
|
||
const project = await resolveProjectIdentifier(ctx, params.projectId);
|
||
if ("error" in project) return project;
|
||
|
||
const caller = createProjectCaller(createScopedCallerContext(ctx));
|
||
try {
|
||
await caller.delete({ id: project.id });
|
||
} catch (error) {
|
||
const mapped = toAssistantProjectNotFoundError(error, params.projectId);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["project"],
|
||
success: true,
|
||
message: `Deleted project: ${project.name} (${project.shortCode})`,
|
||
};
|
||
},
|
||
|
||
// ── ORG UNIT MANAGEMENT ──
|
||
|
||
async create_org_unit(params: {
|
||
name: string;
|
||
shortName?: string;
|
||
level: number;
|
||
parentId?: string;
|
||
sortOrder?: number;
|
||
}, ctx: ToolContext) {
|
||
const caller = createOrgUnitCaller(createScopedCallerContext(ctx));
|
||
let ou;
|
||
try {
|
||
ou = await caller.create(CreateOrgUnitSchema.parse(params));
|
||
} catch (error) {
|
||
const mapped = toAssistantOrgUnitMutationError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return { __action: "invalidate", scope: ["orgUnit"], success: true, message: `Created org unit: ${ou.name}`, orgUnitId: ou.id, orgUnit: ou };
|
||
},
|
||
|
||
async update_org_unit(params: {
|
||
id: string;
|
||
name?: string;
|
||
shortName?: string | null;
|
||
sortOrder?: number;
|
||
isActive?: boolean;
|
||
parentId?: string | null;
|
||
}, ctx: ToolContext) {
|
||
const caller = createOrgUnitCaller(createScopedCallerContext(ctx));
|
||
const data = UpdateOrgUnitSchema.parse({
|
||
...(params.name !== undefined ? { name: params.name } : {}),
|
||
...(params.shortName !== undefined ? { shortName: params.shortName } : {}),
|
||
...(params.sortOrder !== undefined ? { sortOrder: params.sortOrder } : {}),
|
||
...(params.isActive !== undefined ? { isActive: params.isActive } : {}),
|
||
...(params.parentId !== undefined ? { parentId: params.parentId } : {}),
|
||
});
|
||
if (Object.keys(data).length === 0) return { error: "No fields to update" };
|
||
let ou;
|
||
try {
|
||
ou = await caller.update({ id: params.id, data });
|
||
} catch (error) {
|
||
const mapped = toAssistantOrgUnitMutationError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return { __action: "invalidate", scope: ["orgUnit"], success: true, message: `Updated org unit: ${ou.name}`, orgUnitId: ou.id, orgUnit: ou };
|
||
},
|
||
|
||
// ─── Cover Art ───────────────────────────────────────────────────────────
|
||
|
||
async generate_project_cover(params: { projectId: string; prompt?: string }, ctx: ToolContext) {
|
||
assertPermission(ctx, "manageProjects" as PermissionKey);
|
||
const caller = createProjectCaller(createScopedCallerContext(ctx));
|
||
const project = await resolveProjectIdentifier(ctx, params.projectId);
|
||
if ("error" in project) {
|
||
return project;
|
||
}
|
||
const { coverImageUrl } = await caller.generateCover({
|
||
projectId: project.id,
|
||
...(params.prompt !== undefined ? { prompt: params.prompt } : {}),
|
||
});
|
||
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["project"],
|
||
success: true,
|
||
message: `Generated cover art for project "${project.name}"`,
|
||
coverImageUrl: coverImageUrl.slice(0, 100) + "...[truncated]",
|
||
};
|
||
},
|
||
|
||
async remove_project_cover(params: { projectId: string }, ctx: ToolContext) {
|
||
assertPermission(ctx, "manageProjects" as PermissionKey);
|
||
const caller = createProjectCaller(createScopedCallerContext(ctx));
|
||
const project = await resolveProjectIdentifier(ctx, params.projectId);
|
||
if ("error" in project) {
|
||
return project;
|
||
}
|
||
await caller.removeCover({ projectId: project.id });
|
||
|
||
return { __action: "invalidate", scope: ["project"], success: true, message: `Removed cover art from project "${project.name}"` };
|
||
},
|
||
|
||
// ── TASK MANAGEMENT ──
|
||
|
||
async list_tasks(params: {
|
||
status?: "OPEN" | "IN_PROGRESS" | "DONE" | "DISMISSED";
|
||
includeAssigned?: boolean;
|
||
limit?: number;
|
||
}, ctx: ToolContext) {
|
||
const caller = createNotificationCaller(createScopedCallerContext(ctx));
|
||
return caller.listTasks({
|
||
...(params.status !== undefined ? { status: params.status } : {}),
|
||
...(params.includeAssigned !== undefined ? { includeAssigned: params.includeAssigned } : {}),
|
||
...(params.limit !== undefined ? { limit: Math.min(params.limit, 100) } : {}),
|
||
});
|
||
},
|
||
|
||
async get_task_counts(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createNotificationCaller(createScopedCallerContext(ctx));
|
||
return caller.taskCounts();
|
||
},
|
||
|
||
async get_task_detail(params: { taskId: string }, ctx: ToolContext) {
|
||
const caller = createNotificationCaller(createScopedCallerContext(ctx));
|
||
try {
|
||
return await caller.getTaskDetail({ id: params.taskId });
|
||
} catch (error) {
|
||
const mapped = toAssistantTaskNotFoundError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
},
|
||
|
||
async update_task_status(params: { taskId: string; status: string }, ctx: ToolContext) {
|
||
const caller = createNotificationCaller(createScopedCallerContext(ctx));
|
||
let task;
|
||
try {
|
||
task = await caller.updateTaskStatus({
|
||
id: params.taskId,
|
||
status: params.status as "OPEN" | "IN_PROGRESS" | "DONE" | "DISMISSED",
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantTaskNotFoundError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["notification"],
|
||
success: true,
|
||
task,
|
||
message: `Task status updated to ${task.taskStatus ?? params.status}.`,
|
||
};
|
||
},
|
||
|
||
async execute_task_action(params: { taskId: string }, ctx: ToolContext) {
|
||
const caller = createNotificationCaller(createScopedCallerContext(ctx));
|
||
let result;
|
||
try {
|
||
result = await caller.executeTaskAction({ id: params.taskId });
|
||
} catch (error) {
|
||
const mapped = toAssistantTaskActionError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["notification"],
|
||
success: true,
|
||
task: result.task,
|
||
message: result.actionResult.message,
|
||
};
|
||
},
|
||
|
||
async create_reminder(params: {
|
||
title: string;
|
||
body?: string;
|
||
remindAt: string;
|
||
recurrence?: string;
|
||
entityId?: string;
|
||
entityType?: string;
|
||
link?: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createNotificationCaller(createScopedCallerContext(ctx));
|
||
if (!params.title.trim()) {
|
||
return { error: "Reminder title is required." };
|
||
}
|
||
if (params.title.length > 200) {
|
||
return { error: "Reminder title must be at most 200 characters." };
|
||
}
|
||
if (params.body !== undefined && params.body.length > 2000) {
|
||
return { error: "Reminder body must be at most 2000 characters." };
|
||
}
|
||
if (
|
||
params.recurrence !== undefined
|
||
&& !["daily", "weekly", "monthly"].includes(params.recurrence)
|
||
) {
|
||
return {
|
||
error: `Invalid recurrence: ${params.recurrence}. Valid values: daily, weekly, monthly.`,
|
||
};
|
||
}
|
||
const remindAt = parseDateTime(params.remindAt, "remindAt");
|
||
let reminder;
|
||
try {
|
||
reminder = await caller.createReminder({
|
||
title: params.title,
|
||
remindAt,
|
||
...(params.body !== undefined ? { body: params.body } : {}),
|
||
...(params.recurrence !== undefined ? { recurrence: params.recurrence as "daily" | "weekly" | "monthly" } : {}),
|
||
...(params.entityId !== undefined ? { entityId: params.entityId } : {}),
|
||
...(params.entityType !== undefined ? { entityType: params.entityType } : {}),
|
||
...(params.link !== undefined ? { link: params.link } : {}),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantReminderCreationError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["notification"],
|
||
success: true,
|
||
reminder,
|
||
reminderId: reminder.id,
|
||
message: `Reminder "${params.title}" created.`,
|
||
};
|
||
},
|
||
|
||
async list_reminders(params: { limit?: number }, ctx: ToolContext) {
|
||
const caller = createNotificationCaller(createScopedCallerContext(ctx));
|
||
return caller.listReminders({
|
||
...(params.limit !== undefined ? { limit: Math.min(params.limit, 100) } : {}),
|
||
});
|
||
},
|
||
|
||
async update_reminder(params: {
|
||
id: string;
|
||
title?: string;
|
||
body?: string;
|
||
remindAt?: string;
|
||
recurrence?: "daily" | "weekly" | "monthly" | null;
|
||
}, ctx: ToolContext) {
|
||
const caller = createNotificationCaller(createScopedCallerContext(ctx));
|
||
const remindAt = parseOptionalDateTime(params.remindAt, "remindAt");
|
||
let reminder;
|
||
try {
|
||
reminder = await caller.updateReminder({
|
||
id: params.id,
|
||
...(params.title !== undefined ? { title: params.title } : {}),
|
||
...(params.body !== undefined ? { body: params.body } : {}),
|
||
...(remindAt ? { remindAt } : {}),
|
||
...(params.recurrence !== undefined ? { recurrence: params.recurrence } : {}),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantReminderNotFoundError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["notification"],
|
||
success: true,
|
||
reminder,
|
||
reminderId: reminder.id,
|
||
message: `Updated reminder ${params.id}.`,
|
||
};
|
||
},
|
||
|
||
async delete_reminder(params: { id: string }, ctx: ToolContext) {
|
||
const caller = createNotificationCaller(createScopedCallerContext(ctx));
|
||
try {
|
||
await caller.deleteReminder({ id: params.id });
|
||
} catch (error) {
|
||
const mapped = toAssistantReminderNotFoundError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["notification"],
|
||
success: true,
|
||
id: params.id,
|
||
message: `Deleted reminder ${params.id}.`,
|
||
};
|
||
},
|
||
|
||
async create_task_for_user(params: {
|
||
userId: string;
|
||
title: string;
|
||
body?: string;
|
||
priority?: string;
|
||
dueDate?: string;
|
||
taskAction?: string;
|
||
entityId?: string;
|
||
entityType?: string;
|
||
link?: string;
|
||
channel?: "in_app" | "email" | "both";
|
||
}, ctx: ToolContext) {
|
||
const caller = createNotificationCaller(createScopedCallerContext(ctx));
|
||
const dueDate = parseOptionalDateTime(params.dueDate, "dueDate");
|
||
let task;
|
||
try {
|
||
task = await caller.createTask({
|
||
userId: params.userId,
|
||
title: params.title,
|
||
...(params.body !== undefined ? { body: params.body } : {}),
|
||
...(params.priority !== undefined ? { priority: params.priority as "LOW" | "NORMAL" | "HIGH" | "URGENT" } : {}),
|
||
...(dueDate ? { dueDate } : {}),
|
||
...(params.taskAction !== undefined ? { taskAction: params.taskAction } : {}),
|
||
...(params.entityId !== undefined ? { entityId: params.entityId } : {}),
|
||
...(params.entityType !== undefined ? { entityType: params.entityType } : {}),
|
||
...(params.link !== undefined ? { link: params.link } : {}),
|
||
...(params.channel !== undefined ? { channel: params.channel } : {}),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantNotificationCreationError(error, "task");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["notification"],
|
||
success: true,
|
||
task,
|
||
taskId: task?.id ?? null,
|
||
message: `Created task "${params.title}" for ${params.userId}.`,
|
||
};
|
||
},
|
||
|
||
async assign_task(params: { id: string; assigneeId: string }, ctx: ToolContext) {
|
||
const caller = createNotificationCaller(createScopedCallerContext(ctx));
|
||
let task;
|
||
try {
|
||
task = await caller.assignTask(params);
|
||
} catch (error) {
|
||
const mapped = toAssistantTaskAssignmentError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["notification"],
|
||
success: true,
|
||
task,
|
||
taskId: task.id,
|
||
message: `Assigned task ${params.id} to ${params.assigneeId}.`,
|
||
};
|
||
},
|
||
|
||
async send_broadcast(params: {
|
||
title: string;
|
||
body?: string;
|
||
targetType: string;
|
||
targetValue?: string;
|
||
category?: "NOTIFICATION" | "REMINDER" | "TASK" | "APPROVAL";
|
||
priority?: string;
|
||
channel?: string;
|
||
link?: string;
|
||
scheduledAt?: string;
|
||
taskAction?: string;
|
||
dueDate?: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createNotificationCaller(createScopedCallerContext(ctx));
|
||
const scheduledAt = parseOptionalDateTime(params.scheduledAt, "scheduledAt");
|
||
const dueDate = parseOptionalDateTime(params.dueDate, "dueDate");
|
||
let broadcast;
|
||
try {
|
||
broadcast = await caller.createBroadcast({
|
||
title: params.title,
|
||
targetType: params.targetType as "user" | "role" | "project" | "orgUnit" | "all",
|
||
...(params.body !== undefined ? { body: params.body } : {}),
|
||
...(params.link !== undefined ? { link: params.link } : {}),
|
||
...(params.category !== undefined ? { category: params.category } : {}),
|
||
...(params.priority !== undefined ? { priority: params.priority as "LOW" | "NORMAL" | "HIGH" | "URGENT" } : {}),
|
||
...(params.channel !== undefined ? { channel: params.channel as "in_app" | "email" | "both" } : {}),
|
||
...(params.targetValue !== undefined ? { targetValue: params.targetValue } : {}),
|
||
...(scheduledAt ? { scheduledAt } : {}),
|
||
...(params.taskAction !== undefined ? { taskAction: params.taskAction } : {}),
|
||
...(dueDate ? { dueDate } : {}),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantNotificationCreationError(error, "broadcast");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["notification"],
|
||
success: true,
|
||
broadcast,
|
||
broadcastId: broadcast.id,
|
||
recipientCount: broadcast.recipientCount ?? 0,
|
||
message: `Broadcast "${params.title}" created.`,
|
||
};
|
||
},
|
||
|
||
async list_broadcasts(params: { limit?: number }, ctx: ToolContext) {
|
||
const caller = createNotificationCaller(createScopedCallerContext(ctx));
|
||
return caller.listBroadcasts({
|
||
...(params.limit !== undefined ? { limit: Math.min(params.limit, 50) } : {}),
|
||
});
|
||
},
|
||
|
||
async get_broadcast_detail(params: { id: string }, ctx: ToolContext) {
|
||
const caller = createNotificationCaller(createScopedCallerContext(ctx));
|
||
try {
|
||
return await caller.getBroadcastById({ id: params.id });
|
||
} catch (error) {
|
||
const mapped = toAssistantBroadcastNotFoundError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
},
|
||
|
||
async delete_notification(params: { id: string }, ctx: ToolContext) {
|
||
const caller = createNotificationCaller(createScopedCallerContext(ctx));
|
||
try {
|
||
await caller.delete({ id: params.id });
|
||
} catch (error) {
|
||
const mapped = toAssistantNotificationDeletionError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["notification"],
|
||
success: true,
|
||
id: params.id,
|
||
message: `Deleted notification ${params.id}.`,
|
||
};
|
||
},
|
||
|
||
// ── INSIGHTS & ANOMALIES ──────────────────────────────────────────────────
|
||
|
||
async detect_anomalies(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createInsightsCaller(createScopedCallerContext(ctx));
|
||
return caller.getAnomalyDetail();
|
||
},
|
||
|
||
async get_skill_gaps(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createDashboardCaller(createScopedCallerContext(ctx));
|
||
return caller.getSkillGapSummary();
|
||
},
|
||
|
||
async get_project_health(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createDashboardCaller(createScopedCallerContext(ctx));
|
||
return caller.getProjectHealthDetail();
|
||
},
|
||
|
||
async get_budget_forecast(_params: Record<string, never>, ctx: ToolContext) {
|
||
assertPermission(ctx, "viewCosts" as PermissionKey);
|
||
|
||
const caller = createDashboardCaller(createScopedCallerContext(ctx));
|
||
return caller.getBudgetForecastDetail();
|
||
},
|
||
|
||
async get_insights_summary(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createInsightsCaller(createScopedCallerContext(ctx));
|
||
return caller.getInsightsSummary();
|
||
},
|
||
|
||
async run_report(params: {
|
||
entity: string;
|
||
columns: string[];
|
||
filters?: Array<{
|
||
field: string;
|
||
op: "eq" | "neq" | "gt" | "lt" | "gte" | "lte" | "contains" | "in";
|
||
value: string;
|
||
}>;
|
||
periodMonth?: string;
|
||
limit?: number;
|
||
}, ctx: ToolContext) {
|
||
const entity = params.entity as "resource" | "project" | "assignment" | "resource_month";
|
||
if (!["resource", "project", "assignment", "resource_month"].includes(entity)) {
|
||
return {
|
||
error:
|
||
`Unknown entity: ${params.entity}. Use resource, project, assignment, or resource_month.`,
|
||
};
|
||
}
|
||
const caller = createReportCaller(createScopedCallerContext(ctx));
|
||
const result = await caller.getReportData({
|
||
entity,
|
||
columns: params.columns,
|
||
filters: params.filters ?? [],
|
||
periodMonth: params.periodMonth,
|
||
sortDir: "asc",
|
||
limit: Math.min(params.limit ?? 50, 200),
|
||
offset: 0,
|
||
});
|
||
|
||
return {
|
||
rows: result.rows,
|
||
rowCount: result.rows.length,
|
||
columns: result.columns,
|
||
};
|
||
},
|
||
|
||
async list_comments(params: { entityType: string; entityId: string }, ctx: ToolContext) {
|
||
const caller = createCommentCaller(createScopedCallerContext(ctx));
|
||
const comments = await caller.list({
|
||
entityType: params.entityType,
|
||
entityId: params.entityId,
|
||
});
|
||
|
||
return comments.map((c) => ({
|
||
id: c.id,
|
||
author: c.author.name ?? c.author.email,
|
||
body: c.body,
|
||
resolved: c.resolved,
|
||
createdAt: c.createdAt.toISOString(),
|
||
replyCount: c.replies.length,
|
||
replies: c.replies.map((r) => ({
|
||
id: r.id,
|
||
author: r.author.name ?? r.author.email,
|
||
body: r.body,
|
||
resolved: r.resolved,
|
||
createdAt: r.createdAt.toISOString(),
|
||
})),
|
||
}));
|
||
},
|
||
|
||
async lookup_rate(params: {
|
||
clientId?: string;
|
||
chapter?: string;
|
||
managementLevelId?: string;
|
||
roleName?: string;
|
||
seniority?: string;
|
||
}, ctx: ToolContext) {
|
||
assertPermission(ctx, "viewCosts" as PermissionKey);
|
||
const caller = createRateCardCaller(createScopedCallerContext(ctx));
|
||
const result = await caller.lookupBestMatch(params);
|
||
|
||
return {
|
||
...(result.message ? { message: result.message } : {}),
|
||
bestMatch: result.bestMatch
|
||
? {
|
||
...result.bestMatch,
|
||
costRate: fmtEur(result.bestMatch.costRateCents),
|
||
billRate: result.bestMatch.billRateCents ? fmtEur(result.bestMatch.billRateCents) : null,
|
||
}
|
||
: null,
|
||
alternatives: result.alternatives.map((alternative) => ({
|
||
...alternative,
|
||
costRate: fmtEur(alternative.costRateCents),
|
||
billRate: alternative.billRateCents ? fmtEur(alternative.billRateCents) : null,
|
||
})),
|
||
totalCandidates: result.totalCandidates,
|
||
};
|
||
},
|
||
|
||
// ── SCENARIO & AI ─────────────────────────────────────────────────────────
|
||
|
||
async simulate_scenario(params: {
|
||
projectId: string;
|
||
changes: Array<{
|
||
assignmentId?: string;
|
||
resourceId?: string;
|
||
roleId?: string;
|
||
startDate: string;
|
||
endDate: string;
|
||
hoursPerDay: number;
|
||
remove?: boolean;
|
||
}>;
|
||
}, ctx: ToolContext) {
|
||
const caller = createScenarioCaller(createScopedCallerContext(ctx));
|
||
const result = await caller.simulate({
|
||
projectId: params.projectId,
|
||
changes: params.changes.map((change) => ({
|
||
...change,
|
||
startDate: new Date(change.startDate),
|
||
endDate: new Date(change.endDate),
|
||
})),
|
||
});
|
||
|
||
return {
|
||
baseline: {
|
||
...result.baseline,
|
||
totalCost: fmtEur(result.baseline.totalCostCents),
|
||
},
|
||
scenario: {
|
||
...result.scenario,
|
||
totalCost: fmtEur(result.scenario.totalCostCents),
|
||
},
|
||
delta: {
|
||
...result.delta,
|
||
cost: fmtEur(result.delta.costCents),
|
||
},
|
||
resourceImpacts: result.resourceImpacts,
|
||
warnings: result.warnings,
|
||
budgetCents: result.budgetCents,
|
||
};
|
||
},
|
||
|
||
async generate_project_narrative(params: { projectId: string }, ctx: ToolContext) {
|
||
const caller = createInsightsCaller(createScopedCallerContext(ctx));
|
||
return caller.generateProjectNarrative({ projectId: params.projectId });
|
||
},
|
||
|
||
async create_comment(params: {
|
||
entityType: string;
|
||
entityId: string;
|
||
body: string;
|
||
}, ctx: ToolContext) {
|
||
if (params.body.length === 0) {
|
||
return { error: "Comment body is required." };
|
||
}
|
||
|
||
if (params.body.length > 10_000) {
|
||
return { error: "Comment body must be at most 10000 characters." };
|
||
}
|
||
|
||
const caller = createCommentCaller(createScopedCallerContext(ctx));
|
||
let comment;
|
||
try {
|
||
comment = await caller.create({
|
||
entityType: params.entityType,
|
||
entityId: params.entityId,
|
||
body: params.body,
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantCommentCreationError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["comment"],
|
||
id: comment.id,
|
||
author: comment.author.name ?? comment.author.email,
|
||
body: comment.body,
|
||
createdAt: comment.createdAt.toISOString(),
|
||
};
|
||
},
|
||
|
||
async resolve_comment(params: {
|
||
commentId: string;
|
||
resolved?: boolean;
|
||
}, ctx: ToolContext) {
|
||
const caller = createCommentCaller(createScopedCallerContext(ctx));
|
||
let updated;
|
||
try {
|
||
updated = await caller.resolve({
|
||
id: params.commentId,
|
||
resolved: params.resolved !== false,
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantCommentResolveError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
return {
|
||
__action: "invalidate",
|
||
scope: ["comment"],
|
||
id: updated.id,
|
||
resolved: updated.resolved,
|
||
author: updated.author.name ?? updated.author.email,
|
||
body: updated.body.slice(0, 100),
|
||
};
|
||
},
|
||
|
||
async query_change_history(params: {
|
||
entityType?: string;
|
||
search?: string;
|
||
userId?: string;
|
||
daysBack?: number;
|
||
action?: string;
|
||
limit?: number;
|
||
}, ctx: ToolContext) {
|
||
const limit = Math.min(params.limit ?? 20, 50);
|
||
const daysBack = params.daysBack ?? 7;
|
||
const startDate = new Date();
|
||
startDate.setDate(startDate.getDate() - daysBack);
|
||
|
||
const caller = createAuditLogCaller(createScopedCallerContext(ctx));
|
||
const result = await caller.listDetail({
|
||
...(params.entityType ? { entityType: params.entityType } : {}),
|
||
...(params.userId ? { userId: params.userId } : {}),
|
||
...(params.action ? { action: params.action } : {}),
|
||
...(params.search ? { search: params.search } : {}),
|
||
startDate,
|
||
limit,
|
||
});
|
||
|
||
return {
|
||
filters: {
|
||
entityType: params.entityType ?? null,
|
||
userId: params.userId ?? null,
|
||
action: params.action ?? null,
|
||
search: params.search ?? null,
|
||
daysBack,
|
||
},
|
||
itemCount: result.items.length,
|
||
nextCursor: result.nextCursor ?? null,
|
||
items: result.items,
|
||
};
|
||
},
|
||
|
||
async get_entity_timeline(params: {
|
||
entityType: string;
|
||
entityId: string;
|
||
limit?: number;
|
||
}, ctx: ToolContext) {
|
||
const limit = Math.min(params.limit ?? 50, 200);
|
||
const caller = createAuditLogCaller(createScopedCallerContext(ctx));
|
||
return caller.getByEntityDetail({
|
||
entityType: params.entityType,
|
||
entityId: params.entityId,
|
||
limit,
|
||
});
|
||
},
|
||
|
||
async export_resources_csv(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createImportExportCaller(createScopedCallerContext(ctx));
|
||
const csv = await caller.exportResourcesCSV();
|
||
return {
|
||
format: "csv",
|
||
lineCount: csv.length === 0 ? 0 : csv.split("\n").length,
|
||
csv,
|
||
};
|
||
},
|
||
|
||
async export_projects_csv(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createImportExportCaller(createScopedCallerContext(ctx));
|
||
const csv = await caller.exportProjectsCSV();
|
||
return {
|
||
format: "csv",
|
||
lineCount: csv.length === 0 ? 0 : csv.split("\n").length,
|
||
csv,
|
||
};
|
||
},
|
||
|
||
async import_csv_data(params: {
|
||
entityType: "resources" | "projects" | "allocations";
|
||
rows: Array<Record<string, string>>;
|
||
dryRun?: boolean;
|
||
}, ctx: ToolContext) {
|
||
assertPermission(ctx, PermissionKey.IMPORT_DATA);
|
||
const caller = createImportExportCaller(createScopedCallerContext(ctx));
|
||
return caller.importCSV({
|
||
entityType: params.entityType,
|
||
rows: params.rows,
|
||
dryRun: params.dryRun ?? true,
|
||
});
|
||
},
|
||
|
||
async list_dispo_import_batches(params: {
|
||
status?: ImportBatchStatus;
|
||
limit?: number;
|
||
cursor?: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createDispoCaller(createScopedCallerContext(ctx));
|
||
return caller.listImportBatches({
|
||
...(params.status ? { status: params.status } : {}),
|
||
...(params.cursor ? { cursor: params.cursor } : {}),
|
||
...(params.limit !== undefined ? { limit: Math.min(Math.max(params.limit, 1), 200) } : {}),
|
||
});
|
||
},
|
||
|
||
async get_dispo_import_batch(params: {
|
||
id: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createDispoCaller(createScopedCallerContext(ctx));
|
||
try {
|
||
return await caller.getImportBatch({ id: params.id });
|
||
} catch (error) {
|
||
const mapped = toAssistantDispoImportBatchNotFoundError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
},
|
||
|
||
async stage_dispo_import_batch(params: {
|
||
chargeabilityWorkbookPath: string;
|
||
costWorkbookPath?: string;
|
||
notes?: string | null;
|
||
planningWorkbookPath: string;
|
||
referenceWorkbookPath: string;
|
||
rosterWorkbookPath?: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createDispoCaller(createScopedCallerContext(ctx));
|
||
return caller.stageImportBatch({
|
||
chargeabilityWorkbookPath: params.chargeabilityWorkbookPath,
|
||
planningWorkbookPath: params.planningWorkbookPath,
|
||
referenceWorkbookPath: params.referenceWorkbookPath,
|
||
...(params.costWorkbookPath !== undefined ? { costWorkbookPath: params.costWorkbookPath } : {}),
|
||
...(params.notes !== undefined ? { notes: params.notes } : {}),
|
||
...(params.rosterWorkbookPath !== undefined ? { rosterWorkbookPath: params.rosterWorkbookPath } : {}),
|
||
});
|
||
},
|
||
|
||
async validate_dispo_import_batch(params: {
|
||
chargeabilityWorkbookPath: string;
|
||
costWorkbookPath?: string;
|
||
importBatchId?: string;
|
||
notes?: string | null;
|
||
planningWorkbookPath: string;
|
||
referenceWorkbookPath: string;
|
||
rosterWorkbookPath?: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createDispoCaller(createScopedCallerContext(ctx));
|
||
return caller.validateImportBatch({
|
||
chargeabilityWorkbookPath: params.chargeabilityWorkbookPath,
|
||
planningWorkbookPath: params.planningWorkbookPath,
|
||
referenceWorkbookPath: params.referenceWorkbookPath,
|
||
...(params.costWorkbookPath !== undefined ? { costWorkbookPath: params.costWorkbookPath } : {}),
|
||
...(params.importBatchId !== undefined ? { importBatchId: params.importBatchId } : {}),
|
||
...(params.notes !== undefined ? { notes: params.notes } : {}),
|
||
...(params.rosterWorkbookPath !== undefined ? { rosterWorkbookPath: params.rosterWorkbookPath } : {}),
|
||
});
|
||
},
|
||
|
||
async cancel_dispo_import_batch(params: {
|
||
id: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createDispoCaller(createScopedCallerContext(ctx));
|
||
return caller.cancelImportBatch({ id: params.id });
|
||
},
|
||
|
||
async list_dispo_staged_resources(params: {
|
||
importBatchId: string;
|
||
status?: StagedRecordStatus;
|
||
limit?: number;
|
||
cursor?: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createDispoCaller(createScopedCallerContext(ctx));
|
||
return caller.listStagedResources({
|
||
importBatchId: params.importBatchId,
|
||
...(params.status !== undefined ? { status: params.status } : {}),
|
||
...(params.cursor ? { cursor: params.cursor } : {}),
|
||
...(params.limit !== undefined ? { limit: Math.min(Math.max(params.limit, 1), 200) } : {}),
|
||
});
|
||
},
|
||
|
||
async list_dispo_staged_projects(params: {
|
||
importBatchId: string;
|
||
status?: StagedRecordStatus;
|
||
isTbd?: boolean;
|
||
limit?: number;
|
||
cursor?: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createDispoCaller(createScopedCallerContext(ctx));
|
||
return caller.listStagedProjects({
|
||
importBatchId: params.importBatchId,
|
||
...(params.status !== undefined ? { status: params.status } : {}),
|
||
...(params.isTbd !== undefined ? { isTbd: params.isTbd } : {}),
|
||
...(params.cursor ? { cursor: params.cursor } : {}),
|
||
...(params.limit !== undefined ? { limit: Math.min(Math.max(params.limit, 1), 200) } : {}),
|
||
});
|
||
},
|
||
|
||
async list_dispo_staged_assignments(params: {
|
||
importBatchId: string;
|
||
status?: StagedRecordStatus;
|
||
resourceExternalId?: string;
|
||
limit?: number;
|
||
cursor?: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createDispoCaller(createScopedCallerContext(ctx));
|
||
return caller.listStagedAssignments({
|
||
importBatchId: params.importBatchId,
|
||
...(params.status !== undefined ? { status: params.status } : {}),
|
||
...(params.resourceExternalId !== undefined ? { resourceExternalId: params.resourceExternalId } : {}),
|
||
...(params.cursor ? { cursor: params.cursor } : {}),
|
||
...(params.limit !== undefined ? { limit: Math.min(Math.max(params.limit, 1), 200) } : {}),
|
||
});
|
||
},
|
||
|
||
async list_dispo_staged_vacations(params: {
|
||
importBatchId: string;
|
||
resourceExternalId?: string;
|
||
limit?: number;
|
||
cursor?: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createDispoCaller(createScopedCallerContext(ctx));
|
||
return caller.listStagedVacations({
|
||
importBatchId: params.importBatchId,
|
||
...(params.resourceExternalId !== undefined ? { resourceExternalId: params.resourceExternalId } : {}),
|
||
...(params.cursor ? { cursor: params.cursor } : {}),
|
||
...(params.limit !== undefined ? { limit: Math.min(Math.max(params.limit, 1), 200) } : {}),
|
||
});
|
||
},
|
||
|
||
async list_dispo_staged_unresolved_records(params: {
|
||
importBatchId: string;
|
||
recordType?: DispoStagedRecordType;
|
||
limit?: number;
|
||
cursor?: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createDispoCaller(createScopedCallerContext(ctx));
|
||
return caller.listStagedUnresolvedRecords({
|
||
importBatchId: params.importBatchId,
|
||
...(params.recordType !== undefined ? { recordType: params.recordType } : {}),
|
||
...(params.cursor ? { cursor: params.cursor } : {}),
|
||
...(params.limit !== undefined ? { limit: Math.min(Math.max(params.limit, 1), 200) } : {}),
|
||
});
|
||
},
|
||
|
||
async resolve_dispo_staged_record(params: {
|
||
action: "APPROVE" | "REJECT" | "SKIP";
|
||
id: string;
|
||
recordType: DispoStagedRecordType;
|
||
}, ctx: ToolContext) {
|
||
const caller = createDispoCaller(createScopedCallerContext(ctx));
|
||
return caller.resolveStagedRecord({
|
||
action: params.action,
|
||
id: params.id,
|
||
recordType: params.recordType,
|
||
});
|
||
},
|
||
|
||
async commit_dispo_import_batch(params: {
|
||
allowTbdUnresolved?: boolean;
|
||
importBatchId: string;
|
||
importTbdProjects?: boolean;
|
||
}, ctx: ToolContext) {
|
||
const caller = createDispoCaller(createScopedCallerContext(ctx));
|
||
return caller.commitImportBatch({
|
||
importBatchId: params.importBatchId,
|
||
...(params.allowTbdUnresolved !== undefined ? { allowTbdUnresolved: params.allowTbdUnresolved } : {}),
|
||
...(params.importTbdProjects !== undefined ? { importTbdProjects: params.importTbdProjects } : {}),
|
||
});
|
||
},
|
||
|
||
async get_system_settings(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createSettingsCaller(createScopedCallerContext(ctx));
|
||
return caller.getSystemSettings();
|
||
},
|
||
|
||
async update_system_settings(params: {
|
||
aiProvider?: "openai" | "azure";
|
||
azureOpenAiEndpoint?: string;
|
||
azureOpenAiDeployment?: string;
|
||
azureOpenAiApiKey?: string;
|
||
azureApiVersion?: string;
|
||
aiMaxCompletionTokens?: number;
|
||
aiTemperature?: number;
|
||
aiSummaryPrompt?: string;
|
||
scoreWeights?: {
|
||
skillDepth: number;
|
||
skillBreadth: number;
|
||
costEfficiency: number;
|
||
chargeability: number;
|
||
experience: number;
|
||
};
|
||
scoreVisibleRoles?: SystemRole[];
|
||
smtpHost?: string;
|
||
smtpPort?: number;
|
||
smtpUser?: string;
|
||
smtpPassword?: string;
|
||
smtpFrom?: string;
|
||
smtpTls?: boolean;
|
||
anonymizationEnabled?: boolean;
|
||
anonymizationDomain?: string;
|
||
anonymizationSeed?: string;
|
||
anonymizationMode?: "global";
|
||
azureDalleDeployment?: string;
|
||
azureDalleEndpoint?: string;
|
||
azureDalleApiKey?: string;
|
||
geminiApiKey?: string;
|
||
geminiModel?: string;
|
||
imageProvider?: "dalle" | "gemini";
|
||
vacationDefaultDays?: number;
|
||
timelineUndoMaxSteps?: number;
|
||
}, ctx: ToolContext) {
|
||
const caller = createSettingsCaller(createScopedCallerContext(ctx));
|
||
return caller.updateSystemSettings(params);
|
||
},
|
||
|
||
async test_ai_connection(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createSettingsCaller(createScopedCallerContext(ctx));
|
||
return caller.testAiConnection();
|
||
},
|
||
|
||
async test_smtp_connection(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createSettingsCaller(createScopedCallerContext(ctx));
|
||
return caller.testSmtpConnection();
|
||
},
|
||
|
||
async test_gemini_connection(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createSettingsCaller(createScopedCallerContext(ctx));
|
||
return caller.testGeminiConnection();
|
||
},
|
||
|
||
async get_ai_configured(_params: Record<string, never>, ctx: ToolContext) {
|
||
const settings = await ctx.db.systemSettings.findUnique({
|
||
where: { id: "singleton" },
|
||
select: {
|
||
aiProvider: true,
|
||
azureOpenAiEndpoint: true,
|
||
azureOpenAiDeployment: true,
|
||
azureOpenAiApiKey: true,
|
||
},
|
||
});
|
||
return { configured: isAiConfigured(settings) };
|
||
},
|
||
|
||
async list_system_role_configs(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createSystemRoleConfigCaller(createScopedCallerContext(ctx));
|
||
return caller.list();
|
||
},
|
||
|
||
async update_system_role_config(params: {
|
||
role: string;
|
||
label?: string;
|
||
description?: string | null;
|
||
color?: string | null;
|
||
defaultPermissions?: string[];
|
||
}, ctx: ToolContext) {
|
||
const caller = createSystemRoleConfigCaller(createScopedCallerContext(ctx));
|
||
return caller.update(params);
|
||
},
|
||
|
||
async list_webhooks(_params: Record<string, never>, ctx: ToolContext) {
|
||
const caller = createWebhookCaller(createScopedCallerContext(ctx));
|
||
const webhooks = await caller.list();
|
||
return sanitizeWebhookList(webhooks);
|
||
},
|
||
|
||
async get_webhook(params: {
|
||
id: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createWebhookCaller(createScopedCallerContext(ctx));
|
||
let webhook;
|
||
try {
|
||
webhook = await caller.getById({ id: params.id });
|
||
} catch (error) {
|
||
const mapped = toAssistantWebhookNotFoundError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return sanitizeWebhook(webhook);
|
||
},
|
||
|
||
async create_webhook(params: {
|
||
name: string;
|
||
url: string;
|
||
secret?: string;
|
||
events: string[];
|
||
isActive?: boolean;
|
||
}, ctx: ToolContext) {
|
||
const caller = createWebhookCaller(createScopedCallerContext(ctx));
|
||
let webhook;
|
||
try {
|
||
webhook = await caller.create({
|
||
name: params.name,
|
||
url: params.url,
|
||
events: params.events as [string, ...string[]],
|
||
...(params.secret !== undefined ? { secret: params.secret } : {}),
|
||
...(params.isActive !== undefined ? { isActive: params.isActive } : {}),
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantWebhookMutationError(error, "create");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return sanitizeWebhook(webhook);
|
||
},
|
||
|
||
async update_webhook(params: {
|
||
id: string;
|
||
data: {
|
||
name?: string;
|
||
url?: string;
|
||
secret?: string | null;
|
||
events?: string[];
|
||
isActive?: boolean;
|
||
};
|
||
}, ctx: ToolContext) {
|
||
const caller = createWebhookCaller(createScopedCallerContext(ctx));
|
||
let webhook;
|
||
try {
|
||
webhook = await caller.update({
|
||
id: params.id,
|
||
data: {
|
||
...(params.data.name !== undefined ? { name: params.data.name } : {}),
|
||
...(params.data.url !== undefined ? { url: params.data.url } : {}),
|
||
...(params.data.secret !== undefined ? { secret: params.data.secret } : {}),
|
||
...(params.data.events !== undefined ? { events: params.data.events as [string, ...string[]] } : {}),
|
||
...(params.data.isActive !== undefined ? { isActive: params.data.isActive } : {}),
|
||
},
|
||
});
|
||
} catch (error) {
|
||
const mapped = toAssistantWebhookMutationError(error, "update");
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return sanitizeWebhook(webhook);
|
||
},
|
||
|
||
async delete_webhook(params: {
|
||
id: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createWebhookCaller(createScopedCallerContext(ctx));
|
||
try {
|
||
await caller.delete({ id: params.id });
|
||
} catch (error) {
|
||
const mapped = toAssistantWebhookNotFoundError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
return { ok: true, id: params.id };
|
||
},
|
||
|
||
async test_webhook(params: {
|
||
id: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createWebhookCaller(createScopedCallerContext(ctx));
|
||
try {
|
||
return await caller.test({ id: params.id });
|
||
} catch (error) {
|
||
const mapped = toAssistantWebhookNotFoundError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
},
|
||
|
||
async list_audit_log_entries(params: {
|
||
entityType?: string;
|
||
entityId?: string;
|
||
userId?: string;
|
||
action?: string;
|
||
source?: string;
|
||
startDate?: string;
|
||
endDate?: string;
|
||
search?: string;
|
||
limit?: number;
|
||
cursor?: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createAuditLogCaller(createScopedCallerContext(ctx));
|
||
const result = await caller.listDetail({
|
||
...(params.entityType ? { entityType: params.entityType } : {}),
|
||
...(params.entityId ? { entityId: params.entityId } : {}),
|
||
...(params.userId ? { userId: params.userId } : {}),
|
||
...(params.action ? { action: params.action } : {}),
|
||
...(params.source ? { source: params.source } : {}),
|
||
...(params.startDate ? { startDate: parseIsoDate(params.startDate, "startDate") } : {}),
|
||
...(params.endDate ? { endDate: parseIsoDate(params.endDate, "endDate") } : {}),
|
||
...(params.search ? { search: params.search } : {}),
|
||
...(params.cursor ? { cursor: params.cursor } : {}),
|
||
...(params.limit !== undefined ? { limit: Math.min(Math.max(params.limit, 1), 100) } : {}),
|
||
});
|
||
|
||
return {
|
||
filters: {
|
||
entityType: params.entityType ?? null,
|
||
entityId: params.entityId ?? null,
|
||
userId: params.userId ?? null,
|
||
action: params.action ?? null,
|
||
source: params.source ?? null,
|
||
startDate: params.startDate ?? null,
|
||
endDate: params.endDate ?? null,
|
||
search: params.search ?? null,
|
||
},
|
||
itemCount: result.items.length,
|
||
nextCursor: result.nextCursor ?? null,
|
||
items: result.items,
|
||
};
|
||
},
|
||
|
||
async get_audit_log_entry(params: {
|
||
id: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createAuditLogCaller(createScopedCallerContext(ctx));
|
||
try {
|
||
return await caller.getByIdDetail({ id: params.id });
|
||
} catch (error) {
|
||
const mapped = toAssistantAuditLogEntryNotFoundError(error);
|
||
if (mapped) {
|
||
return mapped;
|
||
}
|
||
throw error;
|
||
}
|
||
},
|
||
|
||
async get_audit_log_timeline(params: {
|
||
startDate?: string;
|
||
endDate?: string;
|
||
limit?: number;
|
||
}, ctx: ToolContext) {
|
||
const caller = createAuditLogCaller(createScopedCallerContext(ctx));
|
||
return caller.getTimelineDetail({
|
||
...(params.startDate ? { startDate: parseIsoDate(params.startDate, "startDate") } : {}),
|
||
...(params.endDate ? { endDate: parseIsoDate(params.endDate, "endDate") } : {}),
|
||
...(params.limit !== undefined ? { limit: Math.min(Math.max(params.limit, 1), 500) } : {}),
|
||
});
|
||
},
|
||
|
||
async get_audit_activity_summary(params: {
|
||
startDate?: string;
|
||
endDate?: string;
|
||
}, ctx: ToolContext) {
|
||
const caller = createAuditLogCaller(createScopedCallerContext(ctx));
|
||
return caller.getActivitySummary({
|
||
...(params.startDate ? { startDate: parseIsoDate(params.startDate, "startDate") } : {}),
|
||
...(params.endDate ? { endDate: parseIsoDate(params.endDate, "endDate") } : {}),
|
||
});
|
||
},
|
||
|
||
async get_shoring_ratio(params: { projectId: string }, ctx: ToolContext) {
|
||
const project = await resolveProjectIdentifier(ctx, params.projectId);
|
||
if ("error" in project) return project;
|
||
|
||
const caller = createProjectCaller(createScopedCallerContext(ctx));
|
||
const result = await caller.getShoringRatio({ projectId: project.id });
|
||
|
||
if (result.totalHours <= 0) {
|
||
return `Project "${project.name}" (${project.shortCode}): No active assignments — shoring ratio not available.`;
|
||
}
|
||
|
||
const countryParts = Object.entries(result.byCountry)
|
||
.sort((a, b) => b[1].pct - a[1].pct)
|
||
.map(([code, info]) => `${code} ${info.pct}% (${info.resourceCount} people)`)
|
||
.join(", ");
|
||
|
||
const status = result.offshoreRatio >= result.threshold
|
||
? `Target met (>=${result.threshold}% offshore)`
|
||
: result.offshoreRatio >= result.threshold - 10
|
||
? `Close to target (${result.threshold}% offshore needed)`
|
||
: `Below target — only ${result.offshoreRatio}% offshore, need ${result.threshold}%`;
|
||
|
||
return `Project "${project.name}" (${project.shortCode}): ${result.onshoreRatio}% onshore (${result.onshoreCountryCode}), ${result.offshoreRatio}% offshore. ${status}. Breakdown: ${countryParts}.${result.unknownCount > 0 ? ` (${result.unknownCount} resource(s) without country)` : ""}`;
|
||
},
|
||
};
|
||
|
||
// ─── Executor ───────────────────────────────────────────────────────────────
|
||
|
||
export interface ToolAction {
|
||
type: string;
|
||
url?: string;
|
||
scope?: string[];
|
||
description?: string;
|
||
}
|
||
|
||
export interface ToolResult {
|
||
content: string;
|
||
action?: ToolAction;
|
||
data?: unknown;
|
||
}
|
||
|
||
export async function executeTool(
|
||
name: string,
|
||
args: string,
|
||
ctx: ToolContext,
|
||
): Promise<ToolResult> {
|
||
const executor = executors[name as keyof typeof executors];
|
||
if (!executor) return { content: JSON.stringify({ error: `Unknown tool: ${name}` }) };
|
||
|
||
try {
|
||
const params = JSON.parse(args);
|
||
|
||
// Audit-log all mutation tool executions (EGAI 4.1.3.1 / IAAI 3.6.26)
|
||
if (MUTATION_TOOLS.has(name)) {
|
||
logger.info(
|
||
{ tool: name, params, userId: ctx.userId, userRole: ctx.userRole },
|
||
"AI assistant mutation tool executed",
|
||
);
|
||
}
|
||
|
||
const result = await executor(params, ctx);
|
||
|
||
// Detect action payloads (e.g. navigation, invalidation)
|
||
if (result && typeof result === "object" && "__action" in (result as Record<string, unknown>)) {
|
||
const actionResult = result as Record<string, unknown>;
|
||
const actionType = actionResult.__action as string;
|
||
|
||
if (actionType === "navigate") {
|
||
const url = actionResult.url as string;
|
||
const desc = (actionResult.description as string | undefined) ?? url;
|
||
return {
|
||
content: JSON.stringify({ description: desc }),
|
||
data: { description: desc },
|
||
action: { type: "navigate", url, description: desc },
|
||
};
|
||
}
|
||
|
||
if (actionType === "invalidate") {
|
||
const scope = actionResult.scope as string[];
|
||
// Strip __action, scope, and large data from the result sent back to the AI
|
||
const { __action: _, scope: _s, coverImageUrl: _img, ...rest } = actionResult;
|
||
const content = JSON.stringify(rest);
|
||
return {
|
||
content: content.length > 4000 ? content.slice(0, 4000) + '..."' : content,
|
||
data: rest,
|
||
action: { type: "invalidate", scope },
|
||
};
|
||
}
|
||
}
|
||
|
||
// Cap tool result size to prevent oversized OpenAI conversation payloads
|
||
const content = typeof result === "string" ? result : JSON.stringify(result);
|
||
return {
|
||
content: content.length > 8000 ? content.slice(0, 8000) + '..."' : content,
|
||
...(typeof result === "string" ? {} : { data: result }),
|
||
};
|
||
} catch (err) {
|
||
const normalizedError = normalizeAssistantExecutionError(err);
|
||
logger.error(
|
||
{
|
||
tool: name,
|
||
userId: ctx.userId,
|
||
userRole: ctx.userRole,
|
||
error: err instanceof Error ? { message: err.message, stack: err.stack } : err,
|
||
},
|
||
"AI assistant tool execution failed",
|
||
);
|
||
return { content: JSON.stringify(normalizedError), data: normalizedError };
|
||
}
|
||
}
|