Files
CapaKraken/packages/api/src/router/assistant-tools.ts
T
Hartmut 92a982b151 feat: Nearshore-Ratio indicator per project
Engine (packages/engine):
- calculateShoringRatio() pure function: onshore/offshore hours,
  country breakdown, threshold check, weighted by hours not headcount
- 12 unit tests: empty, 100% onshore/offshore, mixed ratios,
  custom threshold, case-insensitive, unknown country, FTE weighting

Schema:
- Project.shoringThreshold (default 55%) — per-project configurable
- Project.onshoreCountryCode (default "DE") — configurable onshore country

API (project router):
- getShoringRatio query: loads assignments with resource.country,
  computes ratio, returns full breakdown
- update mutation: accepts shoringThreshold + onshoreCountryCode

UI:
- ShoringIndicator: stacked horizontal bar with country segments,
  severity badge (green/yellow/red), hover tooltip, dark theme
- ShoringBadge: mini colored dot + % for project list column
- ProjectModal: "Max Offshore %" number input
- Project detail: indicator after budget status card
- Project list: "Shoring" column (default hidden, toggleable)

AI Assistant:
- get_shoring_ratio tool: human-readable breakdown with threshold alert

Colors: green (<threshold-10), yellow (threshold-10 to threshold), red (>=threshold)
Default: 55% offshore threshold, "DE" as onshore country

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-26 11:45:50 +01:00

5645 lines
205 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 } from "@planarchy/db";
import { calculateAllocation, checkDuplicateAssignment, countWorkingDays } from "@planarchy/engine/allocation";
import { computeBudgetStatus } from "@planarchy/engine";
import type { PermissionKey } from "@planarchy/shared";
import { parseTaskAction } from "@planarchy/shared";
import { createAiClient, createDalleClient, isAiConfigured, isDalleConfigured, parseAiError } from "../ai-client.js";
import { getTaskAction } from "../lib/task-actions.js";
import { fmtEur } from "../lib/format-utils.js";
import { resolveRecipients } from "../lib/notification-targeting.js";
import {
emitNotificationCreated,
emitTaskAssigned,
emitTaskCompleted,
emitTaskStatusChanged,
emitBroadcastSent,
} from "../sse/event-bus.js";
// ─── Types ──────────────────────────────────────────────────────────────────
export type ToolContext = {
db: typeof prisma;
userId: string;
userRole: string;
permissions: Set<PermissionKey>;
};
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>;
// ─── Helpers ────────────────────────────────────────────────────────────────
function fmtDate(d: Date | null | undefined): string | null {
return d ? d.toISOString().slice(0, 10) : null;
}
function assertPermission(ctx: ToolContext, perm: PermissionKey): void {
if (!ctx.permissions.has(perm)) {
throw new Error(`Permission denied: you need the "${perm}" permission to perform this action.`);
}
}
// ─── 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: "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 vacation/leave balance for a resource: entitlement, taken, remaining days.",
parameters: {
type: "object",
properties: {
resourceId: { type: "string", description: "Resource ID or 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_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: "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, SUBMITTED, APPROVED, REJECTED" },
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" },
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" },
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"],
},
},
},
// ── 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", "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. Requires manageVacations permission (or self-service). Always confirm with the user.",
parameters: {
type: "object",
properties: {
resourceId: { type: "string", description: "Resource ID or name" },
type: { type: "string", description: "Type: VACATION, SICK, PARENTAL, SPECIAL, PUBLIC_HOLIDAY" },
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 pending vacation request. Requires manageVacations permission. 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. Requires manageVacations permission. 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. Requires manageVacations permission. 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 vacation entitlement for a resource for a year. Requires admin permission. Always confirm first.",
parameters: {
type: "object",
properties: {
resourceId: { type: "string", description: "Resource ID or name" },
year: { type: "integer", description: "Year" },
entitledDays: { type: "number", description: "Number of entitled vacation days" },
carryoverDays: { type: "number", description: "Carryover days from previous year" },
},
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 detailed estimate with versions, line items, totals, and commercial terms.",
parameters: {
type: "object",
properties: {
estimateId: { type: "string", description: "Estimate ID" },
},
required: ["estimateId"],
},
},
},
{
type: "function",
function: {
name: "create_estimate",
description: "Create a new estimate for a project. Requires manageEstimates permission. Always confirm first.",
parameters: {
type: "object",
properties: {
name: { type: "string", description: "Estimate name" },
projectId: { type: "string", description: "Project ID or short code" },
},
required: ["name", "projectId"],
},
},
},
// ── ROLES ──
{
type: "function",
function: {
name: "create_role",
description: "Create a new role. Requires manageResources permission. Always confirm first.",
parameters: {
type: "object",
properties: {
name: { type: "string", description: "Role name" },
color: { type: "string", description: "Hex color (e.g. #3b82f6). Default: #6b7280" },
},
required: ["name"],
},
},
},
{
type: "function",
function: {
name: "update_role",
description: "Update a role's name or color. Requires manageResources permission. Always confirm first.",
parameters: {
type: "object",
properties: {
id: { type: "string", description: "Role ID" },
name: { type: "string", description: "New name" },
color: { type: "string", description: "New hex color" },
},
required: ["id"],
},
},
},
{
type: "function",
function: {
name: "delete_role",
description: "Delete a role. Requires manageResources 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 manageProjects permission. Always confirm first.",
parameters: {
type: "object",
properties: {
name: { type: "string", description: "Client name" },
code: { type: "string", description: "Client code" },
},
required: ["name"],
},
},
},
{
type: "function",
function: {
name: "update_client",
description: "Update a client. Requires manageProjects permission. 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" },
},
required: ["id"],
},
},
},
// ── ADMIN / CONFIG READ TOOLS ──
{
type: "function",
function: {
name: "list_countries",
description: "List available countries with their working hours and metro cities.",
parameters: { type: "object", properties: {} },
},
},
{
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 system users with their roles and linked resources. Requires admin permission.",
parameters: {
type: "object",
properties: {
limit: { type: "integer", description: "Max results. Default: 50" },
},
},
},
},
{
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 a notification as read.",
parameters: {
type: "object",
properties: {
notificationId: { type: "string", description: "Notification ID" },
},
required: ["notificationId"],
},
},
},
// ── 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. Requires admin permission. 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)" },
},
required: ["name", "level"],
},
},
},
{
type: "function",
function: {
name: "update_org_unit",
description: "Update an organizational unit. Requires admin permission. 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" },
},
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 open/pending tasks and approvals for the current user. Returns actionable items that need attention.",
parameters: {
type: "object",
properties: {
status: { type: "string", enum: ["OPEN", "IN_PROGRESS", "DONE", "DISMISSED"], description: "Filter by status. Default: OPEN" },
limit: { type: "integer", description: "Max results (default 10)" },
},
},
},
},
{
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. Can be one-shot or recurring.",
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.)" },
},
required: ["title", "remindAt"],
},
},
},
{
type: "function",
function: {
name: "create_task_for_user",
description: "Create a task for a specific user. Requires manageProjects or manageResources permission. The task appears in their task list.",
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" },
},
required: ["userId", "title"],
},
},
},
{
type: "function",
function: {
name: "send_broadcast",
description: "Send a notification to a group of users (by role, project members, org unit, or all). Requires manager permission.",
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" },
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" },
},
required: ["title", "targetType"],
},
},
},
// ── 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, or assignments with flexible column selection and filtering.",
parameters: {
type: "object",
properties: {
entity: { type: "string", enum: ["resource", "project", "assignment"], 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",
},
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 activity history for changes to projects, resources, allocations, vacations, or any entity. Can filter by entity type, entity name, user, date range, or action type.",
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 complete change history for a specific entity (project, resource, etc). Shows who made what changes and when.",
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: "get_shoring_ratio",
description: "Get the onshore/offshore staffing ratio for a project. Shows the percentage of work hours allocated to each country, whether the project exceeds its nearshore threshold, and a full country breakdown.",
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,
db: ToolContext["db"],
): Promise<{ displayName: string } | { error: string }> {
// Exact match first (case-insensitive)
const exact = await db.resource.findFirst({
where: { displayName: { equals: name, mode: "insensitive" }, isActive: true },
select: { displayName: true },
});
if (exact) return { displayName: exact.displayName };
// Fuzzy: contains search
const candidates = await db.resource.findMany({
where: { displayName: { contains: name, mode: "insensitive" }, isActive: true },
select: { displayName: true, eid: true },
take: 5,
});
if (candidates.length === 1) return { displayName: candidates[0]!.displayName };
if (candidates.length > 1) {
const list = candidates.map((c) => `${c.displayName} (${c.eid})`).join(", ");
return { error: `Multiple resources match "${name}": ${list}. Please specify the exact name.` };
}
return { error: `No active resource found matching "${name}". The responsible person must be an existing resource.` };
}
// ─── 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 limit = Math.min(params.limit ?? 50, 100);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const where: Record<string, any> = {};
if (params.isActive !== false) where.isActive = true;
if (params.query) {
where.OR = [
{ displayName: { contains: params.query, mode: "insensitive" } },
{ eid: { contains: params.query, mode: "insensitive" } },
{ chapter: { contains: params.query, mode: "insensitive" } },
];
}
if (params.country) {
where.country = {
OR: [
{ code: { equals: params.country, mode: "insensitive" } },
{ name: { contains: params.country, mode: "insensitive" } },
],
};
}
if (params.metroCity) {
where.metroCity = { name: { contains: params.metroCity, mode: "insensitive" } };
}
if (params.orgUnit) {
where.orgUnit = { name: { contains: params.orgUnit, mode: "insensitive" } };
}
if (params.roleName) {
where.areaRole = { name: { contains: params.roleName, mode: "insensitive" } };
}
const resources = await ctx.db.resource.findMany({
where,
select: {
id: true, eid: true, displayName: true, chapter: true, fte: true,
lcrCents: true, chargeabilityTarget: true, isActive: true,
areaRole: { select: { name: true } },
country: { select: { code: true, name: true } },
metroCity: { select: { name: true } },
orgUnit: { select: { name: true } },
},
take: limit,
orderBy: { displayName: "asc" },
});
return resources.map((r) => ({
id: r.id,
eid: r.eid,
name: r.displayName,
chapter: r.chapter,
role: r.areaRole?.name ?? null,
country: r.country?.name ?? r.country?.code ?? null,
countryCode: r.country?.code ?? null,
metroCity: r.metroCity?.name ?? null,
orgUnit: r.orgUnit?.name ?? null,
fte: r.fte,
lcr: fmtEur(r.lcrCents),
chargeabilityTarget: `${r.chargeabilityTarget}%`,
active: r.isActive,
}));
},
async get_resource(params: { identifier: string }, ctx: ToolContext) {
const sel = {
id: true, eid: true, displayName: true, email: true, chapter: true,
fte: true, lcrCents: true, ucrCents: true, chargeabilityTarget: true,
isActive: true, availability: true, skills: true, postalCode: true, federalState: true,
areaRole: { select: { name: true, color: true } },
country: { select: { code: true, name: true, dailyWorkingHours: true } },
metroCity: { select: { name: true } },
managementLevelGroup: { select: { name: true, targetPercentage: true } },
orgUnit: { select: { name: true, level: true } },
_count: { select: { assignments: true, vacations: true } },
} as const;
let resource = await ctx.db.resource.findUnique({ where: { id: params.identifier }, select: sel });
if (!resource) {
resource = await ctx.db.resource.findUnique({ where: { eid: params.identifier }, select: sel });
}
if (!resource) {
resource = await ctx.db.resource.findFirst({
where: { displayName: { contains: params.identifier, mode: "insensitive" } },
select: sel,
});
}
// Try word-level matching if exact substring fails
if (!resource) {
const words = params.identifier.split(/[\s,._\-/]+/).filter((w) => w.length >= 2);
if (words.length > 0) {
const candidates = await ctx.db.resource.findMany({
where: { OR: words.map((w) => ({ displayName: { contains: w, mode: "insensitive" as const } })) },
select: sel,
take: 5,
});
if (candidates.length === 1) {
resource = candidates[0]!;
} else if (candidates.length > 1) {
return {
error: `Resource not found: "${params.identifier}". Did you mean one of these?`,
suggestions: candidates.map((r) => ({ id: r.id, eid: r.eid, name: r.displayName })),
};
}
}
}
if (!resource) return { error: `Resource not found: ${params.identifier}` };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const skills = (resource.skills as any[]) ?? [];
return {
id: resource.id,
eid: resource.eid,
name: resource.displayName,
email: resource.email,
chapter: resource.chapter,
role: resource.areaRole?.name ?? null,
country: resource.country?.name ?? resource.country?.code ?? null,
countryCode: resource.country?.code ?? null,
countryHours: resource.country?.dailyWorkingHours ?? 8,
metroCity: resource.metroCity?.name ?? null,
fte: resource.fte,
lcr: fmtEur(resource.lcrCents),
ucr: fmtEur(resource.ucrCents),
chargeabilityTarget: `${resource.chargeabilityTarget}%`,
managementLevel: resource.managementLevelGroup?.name ?? null,
orgUnit: resource.orgUnit?.name ?? null,
postalCode: resource.postalCode,
federalState: resource.federalState,
active: resource.isActive,
totalAssignments: resource._count.assignments,
totalVacations: resource._count.vacations,
skillCount: skills.length,
topSkills: skills.slice(0, 10).map((s: { name?: string; level?: number }) => `${s.name ?? "?"} (${s.level ?? "?"})`),
};
},
async search_projects(params: { query?: string; status?: string; limit?: number }, ctx: ToolContext) {
const limit = Math.min(params.limit ?? 20, 50);
const sel = {
id: true, shortCode: true, name: true, status: true,
budgetCents: true, winProbability: true, startDate: true, endDate: true,
client: { select: { name: true } },
_count: { select: { assignments: true, estimates: true } },
} as const;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const where: Record<string, any> = {};
if (params.status) where.status = params.status;
if (params.query) {
// First try exact substring match
where.OR = [
{ name: { contains: params.query, mode: "insensitive" } },
{ shortCode: { contains: params.query, mode: "insensitive" } },
];
}
let projects = await ctx.db.project.findMany({ where, select: sel, take: limit, orderBy: { name: "asc" } });
// If no results and query has multiple words, try word-level fuzzy matching
if (projects.length === 0 && params.query) {
const words = params.query.split(/[\s,._\-/]+/).filter((w) => w.length >= 2);
if (words.length > 1) {
// Search for any word matching in name or shortCode
const wordConditions = words.flatMap((word) => [
{ name: { contains: word, mode: "insensitive" } },
{ shortCode: { contains: word, mode: "insensitive" } },
]);
const fuzzyWhere: Record<string, unknown> = { OR: wordConditions };
if (params.status) fuzzyWhere.status = params.status;
const candidates = await ctx.db.project.findMany({
where: fuzzyWhere,
select: sel,
take: limit * 2,
orderBy: { name: "asc" },
});
// Rank by number of matching words
const ranked = candidates.map((p) => {
const lower = `${p.name} ${p.shortCode}`.toLowerCase();
const matchCount = words.filter((w) => lower.includes(w.toLowerCase())).length;
return { project: p, matchCount };
});
ranked.sort((a, b) => b.matchCount - a.matchCount);
projects = ranked.slice(0, limit).map((r) => r.project);
}
}
const formatted = projects.map((p) => ({
id: p.id,
code: p.shortCode,
name: p.name,
status: p.status,
budget: p.budgetCents > 0 ? fmtEur(p.budgetCents) : "Not set",
winProbability: `${p.winProbability}%`,
start: fmtDate(p.startDate),
end: fmtDate(p.endDate),
client: p.client?.name ?? null,
assignmentCount: p._count.assignments,
estimateCount: p._count.estimates,
}));
// If fuzzy results, indicate that these are suggestions
if (projects.length > 0 && params.query) {
const exactMatch = projects.some((p) =>
p.name.toLowerCase().includes(params.query!.toLowerCase()) ||
p.shortCode.toLowerCase().includes(params.query!.toLowerCase()),
);
if (!exactMatch) {
return { suggestions: formatted, note: `No exact match for "${params.query}". These projects match some of the search terms:` };
}
}
return formatted;
},
async get_project(params: { identifier: string }, ctx: ToolContext) {
const sel = {
id: true, shortCode: true, name: true, status: true, orderType: true,
allocationType: true, budgetCents: true, winProbability: true,
startDate: true, endDate: true, responsiblePerson: true,
client: { select: { name: true } },
utilizationCategory: { select: { code: true, name: true } },
_count: { select: { assignments: true, estimates: true } },
} as const;
let project = await ctx.db.project.findUnique({ where: { id: params.identifier }, select: sel });
if (!project) {
project = await ctx.db.project.findUnique({ where: { shortCode: params.identifier }, select: sel });
}
if (!project) return { error: `Project not found: ${params.identifier}` };
// Fetch top allocations for context
const topAllocs = await ctx.db.assignment.findMany({
where: { projectId: project.id, status: { not: "CANCELLED" } },
select: {
resource: { select: { displayName: true, eid: true } },
role: true, status: true, hoursPerDay: true,
startDate: true, endDate: true,
},
take: 10,
orderBy: { startDate: "desc" },
});
return {
id: project.id,
code: project.shortCode,
name: project.name,
status: project.status,
orderType: project.orderType,
allocationType: project.allocationType,
budget: project.budgetCents > 0 ? fmtEur(project.budgetCents) : "Not set",
budgetCents: project.budgetCents,
winProbability: `${project.winProbability}%`,
start: fmtDate(project.startDate),
end: fmtDate(project.endDate),
responsible: project.responsiblePerson,
client: project.client?.name ?? null,
category: project.utilizationCategory?.name ?? null,
assignmentCount: project._count.assignments,
estimateCount: project._count.estimates,
topAllocations: topAllocs.map((a) => ({
resource: a.resource.displayName,
eid: a.resource.eid,
role: a.role ?? null,
status: a.status,
hoursPerDay: a.hoursPerDay,
start: fmtDate(a.startDate),
end: fmtDate(a.endDate),
})),
};
},
async list_allocations(params: {
resourceId?: string; projectId?: string;
resourceName?: string; projectCode?: string;
status?: string; limit?: number;
}, ctx: ToolContext) {
const limit = Math.min(params.limit ?? 30, 50);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const where: Record<string, any> = {};
if (params.resourceId) where.resourceId = params.resourceId;
if (params.projectId) where.projectId = params.projectId;
if (params.status) where.status = params.status;
if (params.resourceName) {
where.resource = { displayName: { contains: params.resourceName, mode: "insensitive" } };
}
if (params.projectCode) {
where.project = { shortCode: { contains: params.projectCode, mode: "insensitive" } };
}
const allocs = await ctx.db.assignment.findMany({
where,
select: {
id: true, status: true, hoursPerDay: true, dailyCostCents: true,
startDate: true, endDate: true, role: true,
resource: { select: { displayName: true, eid: true } },
project: { select: { name: true, shortCode: true } },
},
take: limit,
orderBy: { startDate: "desc" },
});
return allocs.map((a) => ({
id: a.id,
resource: a.resource.displayName,
resourceEid: a.resource.eid,
project: a.project.name,
projectCode: a.project.shortCode,
role: a.role ?? null,
status: a.status,
hoursPerDay: a.hoursPerDay,
dailyCost: fmtEur(a.dailyCostCents),
start: fmtDate(a.startDate),
end: fmtDate(a.endDate),
}));
},
async get_budget_status(params: { projectId: string }, ctx: ToolContext) {
const sel = { id: true, name: true, shortCode: true, budgetCents: true, winProbability: true, startDate: true, endDate: true } as const;
let project = await ctx.db.project.findUnique({ where: { id: params.projectId }, select: sel });
if (!project) {
project = await ctx.db.project.findUnique({ where: { shortCode: params.projectId }, select: sel });
}
if (!project) return { error: `Project not found: ${params.projectId}` };
const allocs = await ctx.db.assignment.findMany({
where: { projectId: project.id },
select: { status: true, dailyCostCents: true, startDate: true, endDate: true, hoursPerDay: true },
});
if (project.budgetCents <= 0) {
return {
project: project.name,
code: project.shortCode,
budget: "Not set",
note: "No budget defined for this project",
totalAllocations: allocs.length,
};
}
const status = computeBudgetStatus(
project.budgetCents,
project.winProbability,
allocs.map((a) => ({
status: a.status as unknown as string,
dailyCostCents: a.dailyCostCents,
startDate: a.startDate,
endDate: a.endDate,
hoursPerDay: a.hoursPerDay,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
})) as any,
project.startDate ?? new Date(),
project.endDate ?? new Date(),
);
return {
project: project.name,
code: project.shortCode,
budget: fmtEur(project.budgetCents),
confirmed: fmtEur(status.confirmedCents),
proposed: fmtEur(status.proposedCents),
allocated: fmtEur(status.allocatedCents),
remaining: fmtEur(status.remainingCents),
utilization: `${status.utilizationPercent.toFixed(1)}%`,
winWeighted: fmtEur(status.winProbabilityWeightedCents),
};
},
async get_vacation_balance(params: { resourceId: string; year?: number }, ctx: ToolContext) {
const year = params.year ?? new Date().getFullYear();
let resource = await ctx.db.resource.findUnique({
where: { id: params.resourceId },
select: { id: true, displayName: true },
});
if (!resource) {
resource = await ctx.db.resource.findFirst({
where: { displayName: { contains: params.resourceId, mode: "insensitive" } },
select: { id: true, displayName: true },
});
}
if (!resource) return { error: `Resource not found: ${params.resourceId}` };
const entitlement = await ctx.db.vacationEntitlement.findUnique({
where: { resourceId_year: { resourceId: resource.id, year } },
});
const vacations = await ctx.db.vacation.findMany({
where: {
resourceId: resource.id,
status: { in: ["APPROVED", "PENDING"] },
startDate: { gte: new Date(`${year}-01-01`) },
endDate: { lte: new Date(`${year}-12-31`) },
},
select: { type: true, status: true, startDate: true, endDate: true, isHalfDay: true },
});
let takenDays = 0;
let pendingDays = 0;
for (const v of vacations) {
if (v.type === "PUBLIC_HOLIDAY") continue;
const days = v.isHalfDay ? 0.5 : Math.ceil((v.endDate.getTime() - v.startDate.getTime()) / (1000 * 60 * 60 * 24)) + 1;
if (v.status === "APPROVED") takenDays += days;
else pendingDays += days;
}
return {
resource: resource.displayName,
year,
entitlement: entitlement?.entitledDays ?? "Not set",
carryOver: entitlement?.carryoverDays ?? 0,
taken: takenDays,
pending: pendingDays,
remaining: entitlement ? entitlement.entitledDays + (entitlement.carryoverDays ?? 0) - takenDays : "Unknown (no entitlement set)",
};
},
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 now = new Date();
const until = new Date(now.getTime() + daysAhead * 24 * 60 * 60 * 1000);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const where: Record<string, any> = {
status: "APPROVED",
endDate: { gte: now },
startDate: { lte: until },
};
if (params.resourceName) {
where.resource = { displayName: { contains: params.resourceName, mode: "insensitive" } };
}
if (params.chapter) {
where.resource = { ...where.resource, chapter: { contains: params.chapter, mode: "insensitive" } };
}
const vacations = await ctx.db.vacation.findMany({
where,
select: {
id: true, type: true, startDate: true, endDate: true, isHalfDay: true, halfDayPart: true,
resource: { select: { displayName: true, eid: true, chapter: true } },
},
take: limit,
orderBy: { startDate: "asc" },
});
return vacations.map((v) => ({
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,
halfDayPart: v.halfDayPart,
}));
},
async list_roles(_params: Record<string, never>, ctx: ToolContext) {
const roles = await ctx.db.role.findMany({
select: { id: true, name: true, color: true },
orderBy: { name: "asc" },
});
return roles;
},
async search_by_skill(params: { skill: string }, ctx: ToolContext) {
const all = await ctx.db.resource.findMany({
where: { isActive: true },
select: { id: true, eid: true, displayName: true, skills: true },
});
const lower = params.skill.toLowerCase();
const matched = all.filter((r) => {
const skills = (r.skills as Array<{ name?: string }>) ?? [];
return skills.some((s) => s.name?.toLowerCase().includes(lower));
}).slice(0, 20);
return matched.map((r) => {
const skills = (r.skills as Array<{ name?: string; level?: number }>) ?? [];
const match = skills.find((s) => s.name?.toLowerCase().includes(lower));
return { id: r.id, eid: r.eid, name: r.displayName, matchedSkill: match?.name, level: match?.level };
});
},
async get_statistics(_params: Record<string, never>, ctx: ToolContext) {
const [
resourceCount, projectCount, activeProjectCount,
allocationCount, vacationCount, estimateCount,
projects, chapters,
] = await Promise.all([
ctx.db.resource.count({ where: { isActive: true } }),
ctx.db.project.count(),
ctx.db.project.count({ where: { status: "ACTIVE" } }),
ctx.db.assignment.count(),
ctx.db.vacation.count({ where: { status: "APPROVED" } }),
ctx.db.estimate.count(),
ctx.db.project.findMany({ select: { status: true, budgetCents: true } }),
ctx.db.resource.groupBy({
by: ["chapter"],
where: { isActive: true },
_count: true,
orderBy: { _count: { chapter: "desc" } },
}),
]);
const totalBudgetCents = projects.reduce((sum, p) => sum + (p.budgetCents ?? 0), 0);
const statusBreakdown: Record<string, number> = {};
for (const p of projects) {
statusBreakdown[p.status] = (statusBreakdown[p.status] ?? 0) + 1;
}
return {
activeResources: resourceCount,
totalProjects: projectCount,
activeProjects: activeProjectCount,
totalAllocations: allocationCount,
approvedVacations: vacationCount,
totalEstimates: estimateCount,
totalBudget: totalBudgetCents > 0 ? fmtEur(totalBudgetCents) : "N/A",
projectsByStatus: statusBreakdown,
topChapters: chapters.slice(0, 10).map((c) => ({
chapter: c.chapter ?? "Unassigned",
count: c._count,
})),
};
},
async get_chargeability(params: { resourceId: string; month?: string }, ctx: ToolContext) {
// Resolve resource
const sel = {
id: true, displayName: true, eid: true, fte: true, chargeabilityTarget: true,
availability: true,
country: { select: { dailyWorkingHours: true } },
} as const;
let resource = await ctx.db.resource.findUnique({ where: { id: params.resourceId }, select: sel });
if (!resource) {
resource = await ctx.db.resource.findUnique({ where: { eid: params.resourceId }, select: sel });
}
if (!resource) {
resource = await ctx.db.resource.findFirst({
where: { displayName: { contains: params.resourceId, mode: "insensitive" } },
select: sel,
});
}
if (!resource) return { error: `Resource not found: ${params.resourceId}` };
// Parse month
const now = new Date();
const month = params.month ?? `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
const [yearStr, moStr] = month.split("-");
const year = Number(yearStr);
const mo = Number(moStr);
const start = new Date(year, mo - 1, 1);
const end = new Date(year, mo, 0); // last day of month
// Get allocations in this month
const allocs = await ctx.db.assignment.findMany({
where: {
resourceId: resource.id,
status: { not: "CANCELLED" },
startDate: { lte: end },
endDate: { gte: start },
},
select: {
hoursPerDay: true, startDate: true, endDate: true, status: true,
project: { select: { name: true, shortCode: true } },
},
});
// Count working days in month
const dailyHours = resource.country?.dailyWorkingHours ?? 8;
let workingDays = 0;
const d = new Date(start);
while (d <= end) {
const dow = d.getDay();
if (dow !== 0 && dow !== 6) workingDays++;
d.setDate(d.getDate() + 1);
}
const availableHours = workingDays * dailyHours * resource.fte;
// Sum booked hours (simplified: intersection of alloc dates with month)
let bookedHours = 0;
const allocDetails: Array<{ project: string; code: string; hours: number; status: string }> = [];
for (const a of allocs) {
const overlapStart = a.startDate > start ? a.startDate : start;
const overlapEnd = a.endDate < end ? a.endDate : end;
let days = 0;
const cur = new Date(overlapStart);
while (cur <= overlapEnd) {
const dow = cur.getDay();
if (dow !== 0 && dow !== 6) days++;
cur.setDate(cur.getDate() + 1);
}
const hours = days * a.hoursPerDay;
bookedHours += hours;
allocDetails.push({
project: a.project.name,
code: a.project.shortCode,
hours: Math.round(hours * 10) / 10,
status: a.status,
});
}
const chargeabilityPercent = availableHours > 0
? Math.round((bookedHours / availableHours) * 1000) / 10
: 0;
return {
resource: resource.displayName,
eid: resource.eid,
month,
fte: resource.fte,
target: `${resource.chargeabilityTarget}%`,
workingDays,
availableHours: Math.round(availableHours * 10) / 10,
bookedHours: Math.round(bookedHours * 10) / 10,
chargeability: `${chargeabilityPercent}%`,
onTarget: chargeabilityPercent >= resource.chargeabilityTarget,
allocations: allocDetails,
};
},
async search_estimates(params: {
projectCode?: string; query?: string; status?: string; limit?: number;
}, ctx: ToolContext) {
const limit = Math.min(params.limit ?? 20, 50);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const where: Record<string, any> = {};
if (params.status) where.status = params.status;
if (params.query) {
where.name = { contains: params.query, mode: "insensitive" };
}
if (params.projectCode) {
where.project = { shortCode: { contains: params.projectCode, mode: "insensitive" } };
}
const estimates = await ctx.db.estimate.findMany({
where,
select: {
id: true, name: true, status: true, latestVersionNumber: true,
project: { select: { name: true, shortCode: true } },
_count: { select: { versions: true } },
createdAt: true,
updatedAt: true,
},
take: limit,
orderBy: { updatedAt: "desc" },
});
return estimates.map((e) => ({
id: e.id,
name: e.name,
status: e.status,
project: e.project?.name ?? null,
projectCode: e.project?.shortCode ?? null,
versions: e._count.versions,
latestVersion: e.latestVersionNumber,
updated: fmtDate(e.updatedAt),
}));
},
async list_clients(params: { query?: string; limit?: number }, ctx: ToolContext) {
const limit = Math.min(params.limit ?? 20, 50);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const where: Record<string, any> = { isActive: true };
if (params.query) {
where.OR = [
{ name: { contains: params.query, mode: "insensitive" } },
{ code: { contains: params.query, mode: "insensitive" } },
];
}
const clients = await ctx.db.client.findMany({
where,
select: {
id: true, name: true, code: true,
_count: { select: { projects: true } },
},
take: limit,
orderBy: { name: "asc" },
});
return clients.map((c) => ({
id: c.id,
name: c.name,
code: c.code,
projectCount: c._count.projects,
}));
},
async list_org_units(params: { level?: number }, ctx: ToolContext) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const where: Record<string, any> = { isActive: true };
if (params.level !== undefined) where.level = params.level;
const units = await ctx.db.orgUnit.findMany({
where,
select: {
id: true, name: true, shortName: true, level: true,
parent: { select: { name: true } },
_count: { select: { resources: true } },
},
orderBy: [{ level: "asc" }, { sortOrder: "asc" }],
});
return units.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);
// Validate resource and project exist
const [resource, project] = await Promise.all([
ctx.db.resource.findUnique({ where: { id: params.resourceId }, select: { id: true, displayName: true, eid: true, lcrCents: true } }),
ctx.db.project.findUnique({ where: { id: params.projectId }, select: { id: true, name: true, shortCode: true } }),
]);
if (!resource) return { error: `Resource not found: ${params.resourceId}` };
if (!project) return { error: `Project not found: ${params.projectId}` };
const dailyCostCents = Math.round(resource.lcrCents * params.hoursPerDay);
const startDate = new Date(params.startDate);
const endDate = new Date(params.endDate);
// Check for overlapping duplicate assignments (same resource + project + overlapping dates)
const existingAssignments = await ctx.db.assignment.findMany({
where: { resourceId: resource.id, status: { not: "CANCELLED" } },
select: { id: true, resourceId: true, projectId: true, startDate: true, endDate: true, status: true },
});
const dupCheck = checkDuplicateAssignment(resource.id, project.id, startDate, endDate, existingAssignments);
if (dupCheck.isDuplicate) {
return { error: dupCheck.message + " Use update_allocation_status to modify the existing assignment." };
}
// Check for existing CANCELLED allocation with same unique key — reactivate it
const existing = await ctx.db.assignment.findUnique({
where: {
unique_assignment: {
resourceId: resource.id,
projectId: project.id,
startDate,
endDate,
},
},
select: { id: true, status: true },
});
if (existing) {
if (existing.status === "CANCELLED") {
// Reactivate the cancelled allocation
const updated = await ctx.db.assignment.update({
where: { id: existing.id },
data: {
status: "PROPOSED",
hoursPerDay: params.hoursPerDay,
percentage: (params.hoursPerDay / 8) * 100,
dailyCostCents,
...(params.role ? { role: params.role } : {}),
},
select: { id: true, status: true },
});
return {
__action: "invalidate",
scope: ["allocation", "timeline"],
success: true,
message: `Reactivated allocation: ${resource.displayName}${project.name} (${project.shortCode}), ${params.hoursPerDay}h/day, ${params.startDate} to ${params.endDate}`,
allocationId: updated.id,
status: updated.status,
};
}
return {
error: `An allocation already exists for this resource/project/dates with status ${existing.status}. No new allocation created.`,
};
}
const assignment = await ctx.db.assignment.create({
data: {
resourceId: resource.id,
projectId: project.id,
startDate,
endDate,
hoursPerDay: params.hoursPerDay,
percentage: (params.hoursPerDay / 8) * 100,
dailyCostCents,
status: "PROPOSED",
...(params.role ? { role: params.role } : {}),
},
select: { id: true, status: true },
});
return {
__action: "invalidate",
scope: ["allocation", "timeline"],
success: true,
message: `Created allocation: ${resource.displayName}${project.name} (${project.shortCode}), ${params.hoursPerDay}h/day, ${params.startDate} to ${params.endDate}`,
allocationId: assignment.id,
status: assignment.status,
};
},
async cancel_allocation(params: {
allocationId?: string;
resourceName?: string; projectCode?: string;
startDate?: string; endDate?: string;
}, ctx: ToolContext) {
assertPermission(ctx, "manageAllocations" as PermissionKey);
let assignment;
if (params.allocationId) {
assignment = await ctx.db.assignment.findUnique({
where: { id: params.allocationId },
select: {
id: true, status: true, startDate: true, endDate: true,
resource: { select: { displayName: true } },
project: { select: { name: true, shortCode: true } },
},
});
} else if (params.resourceName && params.projectCode) {
// Find by resource + project + date overlap
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const where: Record<string, any> = {
resource: { displayName: { contains: params.resourceName, mode: "insensitive" } },
project: { shortCode: { contains: params.projectCode, mode: "insensitive" } },
status: { not: "CANCELLED" },
};
if (params.startDate) where.startDate = { gte: new Date(params.startDate) };
if (params.endDate) where.endDate = { lte: new Date(params.endDate) };
assignment = await ctx.db.assignment.findFirst({
where,
select: {
id: true, status: true, startDate: true, endDate: true,
resource: { select: { displayName: true } },
project: { select: { name: true, shortCode: true } },
},
orderBy: { startDate: "desc" },
});
}
if (!assignment) return { error: "Allocation not found with the given criteria." };
await ctx.db.assignment.update({
where: { id: assignment.id },
data: { status: "CANCELLED" },
});
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(", ")}` };
}
let assignment;
if (params.allocationId) {
assignment = await ctx.db.assignment.findUnique({
where: { id: params.allocationId },
select: {
id: true, status: true, startDate: true, endDate: true,
resource: { select: { displayName: true } },
project: { select: { name: true, shortCode: true } },
},
});
} else if (params.resourceName && params.projectCode) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const where: Record<string, any> = {
resource: { displayName: { contains: params.resourceName, mode: "insensitive" } },
project: { shortCode: { contains: params.projectCode, mode: "insensitive" } },
};
if (params.startDate) where.startDate = new Date(params.startDate);
assignment = await ctx.db.assignment.findFirst({
where,
select: {
id: true, status: true, startDate: true, endDate: true,
resource: { select: { displayName: true } },
project: { select: { name: true, shortCode: true } },
},
orderBy: { startDate: "desc" },
});
}
if (!assignment) return { error: "Allocation not found with the given criteria." };
const oldStatus = assignment.status;
await ctx.db.assignment.update({
where: { id: assignment.id },
data: { status: params.newStatus as "PROPOSED" | "CONFIRMED" | "ACTIVE" | "COMPLETED" | "CANCELLED" },
});
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 data: Record<string, unknown> = {};
if (params.displayName !== undefined) data.displayName = params.displayName;
if (params.fte !== undefined) data.fte = params.fte;
if (params.lcrCents !== undefined) data.lcrCents = params.lcrCents;
if (params.chapter !== undefined) data.chapter = params.chapter;
if (params.chargeabilityTarget !== undefined) data.chargeabilityTarget = params.chargeabilityTarget;
if (Object.keys(data).length === 0) return { error: "No fields to update" };
const resource = await ctx.db.resource.update({
where: { id: params.id },
data,
select: { id: true, eid: true, displayName: true },
});
return { __action: "invalidate", scope: ["resource"], success: true, message: `Updated resource ${resource.displayName} (${resource.eid})`, updatedFields: Object.keys(data) };
},
async update_project(params: {
id: string; name?: string; budgetCents?: number;
winProbability?: number; status?: string;
responsiblePerson?: string;
}, ctx: ToolContext) {
assertPermission(ctx, "manageProjects" as PermissionKey);
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.db);
if ("error" in result) return { error: result.error };
data.responsiblePerson = result.displayName;
}
if (Object.keys(data).length === 0) return { error: "No fields to update" };
const project = await ctx.db.project.update({
where: { id: params.id },
data,
select: { id: true, shortCode: true, name: true },
});
return { __action: "invalidate", scope: ["project"], success: true, message: `Updated project ${project.name} (${project.shortCode})`, updatedFields: Object.keys(data) };
},
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);
// Validate enums
const validOrderTypes = ["BD", "CHARGEABLE", "INTERNAL", "OVERHEAD"];
if (!validOrderTypes.includes(params.orderType)) {
return { error: `Invalid orderType: ${params.orderType}. Valid: ${validOrderTypes.join(", ")}` };
}
const allocationType = params.allocationType ?? "INT";
if (!["INT", "EXT"].includes(allocationType)) {
return { error: `Invalid allocationType: ${allocationType}. Valid: INT, EXT` };
}
const status = params.status ?? "DRAFT";
const validStatuses = ["DRAFT", "ACTIVE", "ON_HOLD", "COMPLETED", "CANCELLED"];
if (!validStatuses.includes(status)) {
return { error: `Invalid status: ${status}. Valid: ${validStatuses.join(", ")}` };
}
// Validate short code format
if (!/^[A-Z0-9_-]+$/.test(params.shortCode)) {
return { error: `Invalid shortCode: "${params.shortCode}". Must be uppercase alphanumeric with hyphens/underscores.` };
}
// Check uniqueness
const existing = await ctx.db.project.findUnique({
where: { shortCode: params.shortCode },
select: { id: true },
});
if (existing) {
return { error: `A project with short code "${params.shortCode}" already exists.` };
}
// Validate dates
const startDate = new Date(params.startDate);
const endDate = new Date(params.endDate);
if (isNaN(startDate.getTime())) return { error: `Invalid startDate: ${params.startDate}` };
if (isNaN(endDate.getTime())) return { error: `Invalid endDate: ${params.endDate}` };
if (endDate < startDate) return { error: "endDate must be after startDate" };
// Validate responsible person against existing resources
let resolvedResponsible: string | undefined;
if (params.responsiblePerson) {
const result = await resolveResponsiblePerson(params.responsiblePerson, ctx.db);
if ("error" in result) return { error: result.error };
resolvedResponsible = result.displayName;
}
// Optional: look up blueprint by name
let blueprintId: string | undefined;
if (params.blueprintName) {
const bp = await ctx.db.blueprint.findFirst({
where: { name: { contains: params.blueprintName, mode: "insensitive" } },
select: { id: true, name: true },
});
if (!bp) return { error: `Blueprint not found: "${params.blueprintName}"` };
blueprintId = bp.id;
}
// Optional: look up client by name
let clientId: string | undefined;
if (params.clientName) {
const client = await ctx.db.client.findFirst({
where: { name: { contains: params.clientName, mode: "insensitive" } },
select: { id: true, name: true },
});
if (!client) return { error: `Client not found: "${params.clientName}"` };
clientId = client.id;
}
const project = await ctx.db.project.create({
data: {
shortCode: params.shortCode,
name: params.name,
orderType: params.orderType,
allocationType,
budgetCents: params.budgetCents,
startDate,
endDate,
winProbability: params.winProbability ?? 100,
status,
...(resolvedResponsible ? { responsiblePerson: resolvedResponsible } : {}),
...(params.color ? { color: params.color } : {}),
...(blueprintId ? { blueprintId } : {}),
...(clientId ? { clientId } : {}),
staffingReqs: [],
dynamicFields: {},
} as Parameters<typeof ctx.db.project.create>[0]["data"],
select: { id: true, shortCode: true, name: true, status: true },
});
await ctx.db.auditLog.create({
data: {
entityType: "Project",
entityId: project.id,
action: "CREATE",
changes: { after: project },
},
});
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);
const existing = await ctx.db.resource.findUnique({ where: { eid: params.eid }, select: { id: true } });
if (existing) return { error: `Resource with EID "${params.eid}" already exists.` };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data: Record<string, any> = {
eid: params.eid,
displayName: params.displayName,
fte: params.fte ?? 1,
lcrCents: params.lcrCents,
ucrCents: params.ucrCents ?? Math.round(params.lcrCents * 0.7),
chargeabilityTarget: params.chargeabilityTarget ?? 80,
isActive: true,
skills: [],
};
if (params.email) data.email = params.email;
if (params.chapter) data.chapter = params.chapter;
if (params.postalCode) data.postalCode = params.postalCode;
if (params.roleName) {
const role = await ctx.db.role.findFirst({
where: { name: { contains: params.roleName, mode: "insensitive" } },
select: { id: true },
});
if (role) data.roleId = role.id;
}
if (params.countryCode) {
const country = await ctx.db.country.findFirst({
where: { code: { equals: params.countryCode, mode: "insensitive" } },
select: { id: true },
});
if (country) data.countryId = country.id;
}
if (params.orgUnitName) {
const ou = await ctx.db.orgUnit.findFirst({
where: { name: { contains: params.orgUnitName, mode: "insensitive" } },
select: { id: true },
});
if (ou) data.orgUnitId = ou.id;
}
const resource = await ctx.db.resource.create({
data: data as Parameters<typeof ctx.db.resource.create>[0]["data"],
select: { id: true, eid: true, displayName: true },
});
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);
let resource = await ctx.db.resource.findUnique({
where: { id: params.identifier },
select: { id: true, displayName: true, eid: true },
});
if (!resource) {
resource = await ctx.db.resource.findUnique({
where: { eid: params.identifier },
select: { id: true, displayName: true, eid: true },
});
}
if (!resource) {
resource = await ctx.db.resource.findFirst({
where: { displayName: { contains: params.identifier, mode: "insensitive" } },
select: { id: true, displayName: true, eid: true },
});
}
if (!resource) return { error: `Resource not found: ${params.identifier}` };
await ctx.db.resource.update({ where: { id: resource.id }, data: { isActive: false } });
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) {
assertPermission(ctx, "manageVacations" as PermissionKey);
const validTypes = ["VACATION", "SICK", "PARENTAL", "SPECIAL", "PUBLIC_HOLIDAY"];
if (!validTypes.includes(params.type)) {
return { error: `Invalid type: ${params.type}. Valid: ${validTypes.join(", ")}` };
}
let resource = await ctx.db.resource.findUnique({
where: { id: params.resourceId },
select: { id: true, displayName: true },
});
if (!resource) {
resource = await ctx.db.resource.findFirst({
where: { displayName: { contains: params.resourceId, mode: "insensitive" } },
select: { id: true, displayName: true },
});
}
if (!resource) return { error: `Resource not found: ${params.resourceId}` };
// We need a requestedById — use a system/admin user as fallback
const systemUser = await ctx.db.user.findFirst({ select: { id: true }, orderBy: { createdAt: "asc" } });
if (!systemUser) return { error: "No users found in system to set as requester." };
const vacation = await ctx.db.vacation.create({
data: {
resourceId: resource.id,
type: params.type as unknown as import("@planarchy/db").VacationType,
startDate: new Date(params.startDate),
endDate: new Date(params.endDate),
status: "PENDING",
requestedById: systemUser.id,
...(params.isHalfDay ? { isHalfDay: true } : {}),
...(params.halfDayPart ? { halfDayPart: params.halfDayPart } : {}),
...(params.note ? { note: params.note } : {}),
},
select: { id: true, status: true },
});
return {
__action: "invalidate",
scope: ["vacation"],
success: true,
message: `Created ${params.type} for ${resource.displayName}: ${params.startDate} to ${params.endDate} (status: PENDING)`,
vacationId: vacation.id,
};
},
async approve_vacation(params: { vacationId: string }, ctx: ToolContext) {
assertPermission(ctx, "manageVacations" as PermissionKey);
const vacation = await ctx.db.vacation.findUnique({
where: { id: params.vacationId },
select: { id: true, status: true, resource: { select: { displayName: true } } },
});
if (!vacation) return { error: `Vacation not found: ${params.vacationId}` };
if (vacation.status !== "PENDING") return { error: `Vacation is ${vacation.status}, not PENDING` };
await ctx.db.vacation.update({ where: { id: params.vacationId }, data: { status: "APPROVED" } });
return {
__action: "invalidate",
scope: ["vacation"],
success: true,
message: `Approved vacation for ${vacation.resource.displayName}`,
};
},
async reject_vacation(params: { vacationId: string; reason?: string }, ctx: ToolContext) {
assertPermission(ctx, "manageVacations" as PermissionKey);
const vacation = await ctx.db.vacation.findUnique({
where: { id: params.vacationId },
select: { id: true, status: true, resource: { select: { displayName: true } } },
});
if (!vacation) return { error: `Vacation not found: ${params.vacationId}` };
if (vacation.status !== "PENDING") return { error: `Vacation is ${vacation.status}, not PENDING` };
await ctx.db.vacation.update({
where: { id: params.vacationId },
data: { status: "REJECTED", ...(params.reason ? { rejectionReason: params.reason } : {}) },
});
return {
__action: "invalidate",
scope: ["vacation"],
success: true,
message: `Rejected vacation for ${vacation.resource.displayName}${params.reason ? `: ${params.reason}` : ""}`,
};
},
async cancel_vacation(params: { vacationId: string }, ctx: ToolContext) {
assertPermission(ctx, "manageVacations" as PermissionKey);
const vacation = await ctx.db.vacation.findUnique({
where: { id: params.vacationId },
select: { id: true, status: true, resource: { select: { displayName: true } } },
});
if (!vacation) return { error: `Vacation not found: ${params.vacationId}` };
await ctx.db.vacation.update({ where: { id: params.vacationId }, data: { status: "CANCELLED" } });
return {
__action: "invalidate",
scope: ["vacation"],
success: true,
message: `Cancelled vacation for ${vacation.resource.displayName}`,
};
},
async get_pending_vacation_approvals(params: { limit?: number }, ctx: ToolContext) {
const limit = Math.min(params.limit ?? 20, 50);
const vacations = await ctx.db.vacation.findMany({
where: { status: "PENDING" },
select: {
id: true, type: true, startDate: true, endDate: true, isHalfDay: true,
resource: { select: { displayName: true, eid: true, chapter: true } },
},
take: limit,
orderBy: { createdAt: "asc" },
});
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,
}));
},
async get_team_vacation_overlap(params: {
resourceId: string; startDate: string; endDate: string;
}, ctx: ToolContext) {
const resource = await ctx.db.resource.findUnique({
where: { id: params.resourceId },
select: { id: true, displayName: true, chapter: true },
});
if (!resource) return { error: `Resource not found: ${params.resourceId}` };
const start = new Date(params.startDate);
const end = new Date(params.endDate);
// Find team members in same chapter
const teamVacations = await ctx.db.vacation.findMany({
where: {
resource: { chapter: resource.chapter, id: { not: resource.id }, isActive: true },
status: { in: ["APPROVED", "PENDING"] },
startDate: { lte: end },
endDate: { gte: start },
},
select: {
type: true, startDate: true, endDate: true, status: true,
resource: { select: { displayName: true } },
},
take: 20,
});
return {
resource: resource.displayName,
chapter: resource.chapter,
period: `${params.startDate} to ${params.endDate}`,
overlappingVacations: teamVacations.map((v) => ({
resource: v.resource.displayName,
type: v.type,
status: v.status,
start: fmtDate(v.startDate),
end: fmtDate(v.endDate),
})),
overlapCount: teamVacations.length,
};
},
// ── ENTITLEMENT ──
async get_entitlement_summary(params: { year?: number; resourceName?: string }, ctx: ToolContext) {
const year = params.year ?? new Date().getFullYear();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const where: Record<string, any> = { year };
if (params.resourceName) {
where.resource = { displayName: { contains: params.resourceName, mode: "insensitive" } };
}
const entitlements = await ctx.db.vacationEntitlement.findMany({
where,
select: {
entitledDays: true, carryoverDays: true, usedDays: true,
resource: { select: { displayName: true, eid: true } },
},
take: 50,
orderBy: { resource: { displayName: "asc" } },
});
return entitlements.map((e) => ({
resource: e.resource.displayName,
eid: e.resource.eid,
year,
entitled: e.entitledDays,
carryover: e.carryoverDays ?? 0,
used: e.usedDays,
remaining: e.entitledDays + (e.carryoverDays ?? 0) - e.usedDays,
}));
},
async set_entitlement(params: {
resourceId: string; year: number; entitledDays: number; carryoverDays?: number;
}, ctx: ToolContext) {
assertPermission(ctx, "manageVacations" as PermissionKey);
let resource = await ctx.db.resource.findUnique({
where: { id: params.resourceId },
select: { id: true, displayName: true },
});
if (!resource) {
resource = await ctx.db.resource.findFirst({
where: { displayName: { contains: params.resourceId, mode: "insensitive" } },
select: { id: true, displayName: true },
});
}
if (!resource) return { error: `Resource not found: ${params.resourceId}` };
await ctx.db.vacationEntitlement.upsert({
where: { resourceId_year: { resourceId: resource.id, year: params.year } },
create: {
resourceId: resource.id,
year: params.year,
entitledDays: params.entitledDays,
carryoverDays: params.carryoverDays ?? 0,
usedDays: 0,
},
update: {
entitledDays: params.entitledDays,
...(params.carryoverDays !== undefined ? { carryoverDays: params.carryoverDays } : {}),
},
});
return {
success: true,
message: `Set entitlement for ${resource.displayName} (${params.year}): ${params.entitledDays} days${params.carryoverDays ? ` + ${params.carryoverDays} carryover` : ""}`,
};
},
// ── DEMAND / STAFFING ──
async list_demands(params: { projectId?: string; status?: string; limit?: number }, ctx: ToolContext) {
const limit = Math.min(params.limit ?? 30, 50);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const where: Record<string, any> = {};
if (params.status) where.status = params.status;
if (params.projectId) {
const project = await ctx.db.project.findFirst({
where: {
OR: [
{ id: params.projectId },
{ shortCode: { contains: params.projectId, mode: "insensitive" } },
],
},
select: { id: true },
});
if (project) where.projectId = project.id;
}
const demands = await ctx.db.demandRequirement.findMany({
where,
select: {
id: true, status: true, headcount: true,
hoursPerDay: true, startDate: true, endDate: true,
role: true, // String? field
roleEntity: { select: { name: true, color: true } },
project: { select: { name: true, shortCode: true } },
_count: { select: { assignments: true } },
},
take: limit,
orderBy: { startDate: "desc" },
});
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._count.assignments,
remaining: d.headcount - d._count.assignments,
hoursPerDay: d.hoursPerDay,
start: fmtDate(d.startDate),
end: fmtDate(d.endDate),
}));
},
async create_demand(params: {
projectId: string; roleName: string; headcount?: number;
hoursPerDay: number; startDate: string; endDate: string;
}, ctx: ToolContext) {
assertPermission(ctx, "manageAllocations" as PermissionKey);
const project = await ctx.db.project.findFirst({
where: {
OR: [
{ id: params.projectId },
{ shortCode: { contains: params.projectId, mode: "insensitive" } },
],
},
select: { id: true, name: true, shortCode: true },
});
if (!project) return { error: `Project not found: ${params.projectId}` };
const role = await ctx.db.role.findFirst({
where: { name: { contains: params.roleName, mode: "insensitive" } },
select: { id: true, name: true },
});
if (!role) return { error: `Role not found: ${params.roleName}` };
const demand = await ctx.db.demandRequirement.create({
data: {
projectId: project.id,
roleId: role.id,
headcount: params.headcount ?? 1,
hoursPerDay: params.hoursPerDay,
percentage: (params.hoursPerDay / 8) * 100,
startDate: new Date(params.startDate),
endDate: new Date(params.endDate),
status: "PROPOSED",
},
select: { id: true },
});
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 demand = await ctx.db.demandRequirement.findUnique({
where: { id: params.demandId },
select: {
id: true, status: true, headcount: true,
hoursPerDay: true, startDate: true, endDate: true,
roleId: true, role: true,
roleEntity: { select: { name: true } },
project: { select: { id: true, name: true, shortCode: true } },
_count: { select: { assignments: true } },
},
});
if (!demand) return { error: `Demand not found: ${params.demandId}` };
const filledCount = demand._count.assignments;
if (filledCount >= demand.headcount) return { error: "Demand is already fully filled." };
let resource = await ctx.db.resource.findUnique({
where: { id: params.resourceId },
select: { id: true, displayName: true, lcrCents: true },
});
if (!resource) {
resource = await ctx.db.resource.findFirst({
where: { displayName: { contains: params.resourceId, mode: "insensitive" } },
select: { id: true, displayName: true, lcrCents: true },
});
}
if (!resource) return { error: `Resource not found: ${params.resourceId}` };
// Check for overlapping duplicate assignments (same resource + project + overlapping dates)
const existingAssignments = await ctx.db.assignment.findMany({
where: { resourceId: resource.id, status: { not: "CANCELLED" } },
select: { id: true, resourceId: true, projectId: true, startDate: true, endDate: true, status: true },
});
const dupCheck = checkDuplicateAssignment(resource.id, demand.project.id, demand.startDate, demand.endDate, existingAssignments);
if (dupCheck.isDuplicate) {
return { error: dupCheck.message + " Use update_allocation_status to modify the existing assignment." };
}
const roleName = demand.roleEntity?.name ?? demand.role ?? null;
const dailyCostCents = Math.round(resource.lcrCents * demand.hoursPerDay);
const assignment = await ctx.db.assignment.create({
data: {
resourceId: resource.id,
projectId: demand.project.id,
startDate: demand.startDate,
endDate: demand.endDate,
hoursPerDay: demand.hoursPerDay,
percentage: (demand.hoursPerDay / 8) * 100,
dailyCostCents,
status: "PROPOSED",
...(roleName ? { role: roleName } : {}),
demandRequirementId: demand.id,
},
select: { id: true },
});
// Check if all headcount positions are now filled
const newFilled = filledCount + 1;
if (newFilled >= demand.headcount) {
await ctx.db.demandRequirement.update({
where: { id: demand.id },
data: { status: "COMPLETED" },
});
}
return {
__action: "invalidate",
scope: ["allocation", "timeline"],
success: true,
message: `Assigned ${resource.displayName} to ${roleName ?? "demand"} on ${demand.project.name} (${demand.project.shortCode})`,
assignmentId: assignment.id,
};
},
async check_resource_availability(params: {
resourceId: string; startDate: string; endDate: string;
}, ctx: ToolContext) {
let resource = await ctx.db.resource.findUnique({
where: { id: params.resourceId },
select: { id: true, displayName: true, fte: true },
});
if (!resource) {
resource = await ctx.db.resource.findUnique({
where: { eid: params.resourceId },
select: { id: true, displayName: true, fte: true },
});
}
if (!resource) {
resource = await ctx.db.resource.findFirst({
where: { displayName: { contains: params.resourceId, mode: "insensitive" } },
select: { id: true, displayName: true, fte: true },
});
}
if (!resource) return { error: `Resource not found: ${params.resourceId}` };
const start = new Date(params.startDate);
const end = new Date(params.endDate);
const [allocations, vacations] = await Promise.all([
ctx.db.assignment.findMany({
where: {
resourceId: resource.id,
status: { not: "CANCELLED" },
startDate: { lte: end },
endDate: { gte: start },
},
select: {
hoursPerDay: true, startDate: true, endDate: true, status: true,
project: { select: { name: true, shortCode: true } },
},
}),
ctx.db.vacation.findMany({
where: {
resourceId: resource.id,
status: { in: ["APPROVED", "PENDING"] },
startDate: { lte: end },
endDate: { gte: start },
},
select: { type: true, startDate: true, endDate: true, isHalfDay: true },
}),
]);
const totalHoursBooked = allocations.reduce((sum, a) => sum + a.hoursPerDay, 0);
const maxHours = resource.fte * 8;
const availableHoursPerDay = Math.max(0, maxHours - totalHoursBooked);
return {
resource: resource.displayName,
period: `${params.startDate} to ${params.endDate}`,
fte: resource.fte,
maxHoursPerDay: maxHours,
currentBookedHoursPerDay: totalHoursBooked,
availableHoursPerDay,
isFullyAvailable: totalHoursBooked === 0 && vacations.length === 0,
existingAllocations: allocations.map((a) => ({
project: `${a.project.name} (${a.project.shortCode})`,
hoursPerDay: a.hoursPerDay,
status: a.status,
start: fmtDate(a.startDate),
end: fmtDate(a.endDate),
})),
vacations: vacations.map((v) => ({
type: v.type,
start: fmtDate(v.startDate),
end: fmtDate(v.endDate),
isHalfDay: v.isHalfDay,
})),
};
},
async get_staffing_suggestions(params: {
projectId: string; roleName?: string;
startDate?: string; endDate?: string; limit?: number;
}, ctx: ToolContext) {
const limit = params.limit ?? 5;
const project = await ctx.db.project.findFirst({
where: {
OR: [
{ id: params.projectId },
{ shortCode: { contains: params.projectId, mode: "insensitive" } },
],
},
select: { id: true, name: true, shortCode: true, startDate: true, endDate: true },
});
if (!project) return { error: `Project not found: ${params.projectId}` };
const start = params.startDate ? new Date(params.startDate) : project.startDate ?? new Date();
const end = params.endDate ? new Date(params.endDate) : project.endDate ?? new Date();
// Find available resources
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const resourceWhere: Record<string, any> = { isActive: true };
if (params.roleName) {
resourceWhere.areaRole = { name: { contains: params.roleName, mode: "insensitive" } };
}
const resources = await ctx.db.resource.findMany({
where: resourceWhere,
select: {
id: true, displayName: true, eid: true, fte: true, lcrCents: true,
areaRole: { select: { name: true } },
chapter: true,
assignments: {
where: {
status: { not: "CANCELLED" },
startDate: { lte: end },
endDate: { gte: start },
},
select: { hoursPerDay: true },
},
},
take: 50,
});
// Score by availability
const scored = resources.map((r) => {
const bookedHours = r.assignments.reduce((sum, a) => sum + a.hoursPerDay, 0);
const maxHours = r.fte * 8;
const available = Math.max(0, maxHours - bookedHours);
return {
id: r.id,
name: r.displayName,
eid: r.eid,
role: r.areaRole?.name ?? null,
chapter: r.chapter,
fte: r.fte,
lcr: fmtEur(r.lcrCents),
availableHoursPerDay: Math.round(available * 10) / 10,
utilization: maxHours > 0 ? Math.round((bookedHours / maxHours) * 100) : 0,
};
})
.filter((r) => r.availableHoursPerDay > 0)
.sort((a, b) => b.availableHoursPerDay - a.availableHoursPerDay)
.slice(0, limit);
return {
project: `${project.name} (${project.shortCode})`,
period: `${fmtDate(start)} to ${fmtDate(end)}`,
suggestions: scored,
};
},
async find_capacity(params: {
startDate: string; endDate: string;
minHoursPerDay?: number; roleName?: string;
chapter?: string; limit?: number;
}, ctx: ToolContext) {
const limit = params.limit ?? 20;
const minHours = params.minHoursPerDay ?? 4;
const start = new Date(params.startDate);
const end = new Date(params.endDate);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const where: Record<string, any> = { isActive: true };
if (params.roleName) {
where.areaRole = { name: { contains: params.roleName, mode: "insensitive" } };
}
if (params.chapter) {
where.chapter = { contains: params.chapter, mode: "insensitive" };
}
const resources = await ctx.db.resource.findMany({
where,
select: {
id: true, displayName: true, eid: true, fte: true,
areaRole: { select: { name: true } },
chapter: true,
assignments: {
where: {
status: { not: "CANCELLED" },
startDate: { lte: end },
endDate: { gte: start },
},
select: { hoursPerDay: true },
},
},
take: 100,
});
const available = resources
.map((r) => {
const booked = r.assignments.reduce((sum, a) => sum + a.hoursPerDay, 0);
const maxH = r.fte * 8;
return {
id: r.id,
name: r.displayName,
eid: r.eid,
role: r.areaRole?.name ?? null,
chapter: r.chapter,
availableHoursPerDay: Math.round((maxH - booked) * 10) / 10,
};
})
.filter((r) => r.availableHoursPerDay >= minHours)
.sort((a, b) => b.availableHoursPerDay - a.availableHoursPerDay)
.slice(0, limit);
return {
period: `${params.startDate} to ${params.endDate}`,
minHoursFilter: minHours,
results: available,
totalFound: available.length,
};
},
// ── BLUEPRINT ──
async list_blueprints(_params: Record<string, never>, ctx: ToolContext) {
const blueprints = await ctx.db.blueprint.findMany({
select: {
id: true, name: true,
_count: { select: { projects: true } },
},
orderBy: { name: "asc" },
});
return blueprints.map((b) => ({
id: b.id,
name: b.name,
projectCount: b._count.projects,
}));
},
async get_blueprint(params: { identifier: string }, ctx: ToolContext) {
let bp = await ctx.db.blueprint.findUnique({
where: { id: params.identifier },
select: { id: true, name: true, fieldDefs: true, rolePresets: true },
});
if (!bp) {
bp = await ctx.db.blueprint.findFirst({
where: { name: { contains: params.identifier, mode: "insensitive" } },
select: { id: true, name: true, fieldDefs: true, rolePresets: true },
});
}
if (!bp) return { error: `Blueprint not found: ${params.identifier}` };
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 limit = Math.min(params.limit ?? 20, 50);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const where: Record<string, any> = { isActive: true };
if (params.query) {
where.name = { contains: params.query, mode: "insensitive" };
}
const cards = await ctx.db.rateCard.findMany({
where,
select: {
id: true, name: true, effectiveFrom: true, effectiveTo: true,
_count: { select: { lines: true } },
},
take: limit,
orderBy: { effectiveFrom: "desc" },
});
return cards.map((c) => ({
id: c.id,
name: c.name,
effectiveFrom: fmtDate(c.effectiveFrom),
effectiveTo: fmtDate(c.effectiveTo),
lineCount: c._count.lines,
}));
},
async resolve_rate(params: { resourceId?: string; roleName?: string; date?: string }, ctx: ToolContext) {
const date = params.date ? new Date(params.date) : new Date();
// Find active rate card for this date
const card = await ctx.db.rateCard.findFirst({
where: {
isActive: true,
effectiveFrom: { lte: date },
OR: [
{ effectiveTo: null },
{ effectiveTo: { gte: date } },
],
},
select: {
id: true, name: true,
lines: {
select: {
id: true, costRateCents: true, billRateCents: true,
role: { select: { name: true } },
seniority: true, chapter: true, location: true,
},
},
},
orderBy: { effectiveFrom: "desc" },
});
if (!card) return { error: "No active rate card found for the given date." };
// If resource specified, try to match their role
if (params.resourceId) {
let resource = await ctx.db.resource.findUnique({
where: { id: params.resourceId },
select: { displayName: true, areaRole: { select: { name: true } }, managementLevelGroup: { select: { name: true } } },
});
if (!resource) {
resource = await ctx.db.resource.findFirst({
where: { displayName: { contains: params.resourceId, mode: "insensitive" } },
select: { displayName: true, areaRole: { select: { name: true } }, managementLevelGroup: { select: { name: true } } },
});
}
if (resource) {
const match = card.lines.find((l) =>
l.role?.name === resource!.areaRole?.name,
);
if (match) {
return {
rateCard: card.name,
resource: resource.displayName,
rate: fmtEur(match.costRateCents),
rateCents: match.costRateCents,
matchedBy: match.role?.name ? `role: ${match.role.name}` : "unknown",
};
}
}
}
// Return all lines
return {
rateCard: card.name,
lines: card.lines.map((l) => ({
role: l.role?.name ?? null,
seniority: l.seniority,
chapter: l.chapter,
location: l.location,
costRate: fmtEur(l.costRateCents),
billRate: l.billRateCents != null ? fmtEur(l.billRateCents) : null,
})),
};
},
// ── ESTIMATES ──
async get_estimate_detail(params: { estimateId: string }, ctx: ToolContext) {
const estimate = await ctx.db.estimate.findUnique({
where: { id: params.estimateId },
select: {
id: true, name: true, status: true, latestVersionNumber: true,
project: { select: { name: true, shortCode: true } },
versions: {
select: {
id: true, versionNumber: true, status: true, label: true,
notes: true, lockedAt: true,
_count: { select: { demandLines: true } },
},
orderBy: { versionNumber: "desc" },
take: 5,
},
},
});
if (!estimate) return { error: `Estimate not found: ${params.estimateId}` };
return {
id: estimate.id,
name: estimate.name,
status: estimate.status,
project: estimate.project?.name ?? null,
projectCode: estimate.project?.shortCode ?? null,
latestVersion: estimate.latestVersionNumber,
versions: estimate.versions.map((v) => ({
id: v.id,
version: v.versionNumber,
status: v.status,
label: v.label,
demandLineCount: v._count.demandLines,
lockedAt: v.lockedAt ? fmtDate(v.lockedAt) : null,
})),
};
},
async create_estimate(params: { name: string; projectId: string }, ctx: ToolContext) {
assertPermission(ctx, "manageProjects" as PermissionKey);
const project = await ctx.db.project.findFirst({
where: {
OR: [
{ id: params.projectId },
{ shortCode: { contains: params.projectId, mode: "insensitive" } },
],
},
select: { id: true, name: true, shortCode: true },
});
if (!project) return { error: `Project not found: ${params.projectId}` };
const estimate = await ctx.db.estimate.create({
data: {
name: params.name,
projectId: project.id,
status: "DRAFT",
latestVersionNumber: 0,
},
select: { id: true, name: true },
});
return {
__action: "invalidate",
scope: ["estimate"],
success: true,
message: `Created estimate "${estimate.name}" for ${project.name} (${project.shortCode})`,
estimateId: estimate.id,
};
},
// ── ROLES ──
async create_role(params: { name: string; color?: string }, ctx: ToolContext) {
assertPermission(ctx, "manageResources" as PermissionKey);
const role = await ctx.db.role.create({
data: { name: params.name, color: params.color ?? "#6b7280" },
select: { id: true, name: true },
});
return { __action: "invalidate", scope: ["role"], success: true, message: `Created role: ${role.name}`, roleId: role.id };
},
async update_role(params: { id: string; name?: string; color?: string }, ctx: ToolContext) {
assertPermission(ctx, "manageResources" as PermissionKey);
const data: Record<string, unknown> = {};
if (params.name !== undefined) data.name = params.name;
if (params.color !== undefined) data.color = params.color;
if (Object.keys(data).length === 0) return { error: "No fields to update" };
const role = await ctx.db.role.update({ where: { id: params.id }, data, select: { id: true, name: true } });
return { __action: "invalidate", scope: ["role"], success: true, message: `Updated role: ${role.name}` };
},
async delete_role(params: { id: string }, ctx: ToolContext) {
assertPermission(ctx, "manageResources" as PermissionKey);
const role = await ctx.db.role.findUnique({ where: { id: params.id }, select: { name: true, _count: { select: { areaResources: true } } } });
if (!role) return { error: `Role not found: ${params.id}` };
if (role._count.areaResources > 0) return { error: `Cannot delete role "${role.name}" — ${role._count.areaResources} resources are assigned to it.` };
await ctx.db.role.delete({ where: { id: params.id } });
return { __action: "invalidate", scope: ["role"], success: true, message: `Deleted role: ${role.name}` };
},
// ── CLIENTS ──
async create_client(params: { name: string; code?: string }, ctx: ToolContext) {
assertPermission(ctx, "manageProjects" as PermissionKey);
const client = await ctx.db.client.create({
data: { name: params.name, ...(params.code ? { code: params.code } : {}) },
select: { id: true, name: true, code: true },
});
return { __action: "invalidate", scope: ["client"], success: true, message: `Created client: ${client.name}`, clientId: client.id };
},
async update_client(params: { id: string; name?: string; code?: string }, ctx: ToolContext) {
assertPermission(ctx, "manageProjects" as PermissionKey);
const data: Record<string, unknown> = {};
if (params.name !== undefined) data.name = params.name;
if (params.code !== undefined) data.code = params.code;
if (Object.keys(data).length === 0) return { error: "No fields to update" };
const client = await ctx.db.client.update({ where: { id: params.id }, data, select: { id: true, name: true } });
return { __action: "invalidate", scope: ["client"], success: true, message: `Updated client: ${client.name}` };
},
// ── ADMIN / CONFIG ──
async list_countries(_params: Record<string, never>, ctx: ToolContext) {
const countries = await ctx.db.country.findMany({
select: {
id: true, code: true, name: true, dailyWorkingHours: true,
metroCities: { select: { id: true, name: true }, orderBy: { name: "asc" as const } },
},
orderBy: { name: "asc" },
});
return countries.map((c) => ({
id: c.id,
code: c.code,
name: c.name,
dailyWorkingHours: c.dailyWorkingHours,
cities: c.metroCities.map((ci) => ci.name),
}));
},
async list_management_levels(_params: Record<string, never>, ctx: ToolContext) {
const groups = await ctx.db.managementLevelGroup.findMany({
select: {
id: true, name: true, targetPercentage: true,
levels: { select: { id: true, name: true }, orderBy: { name: "asc" as const } },
},
orderBy: { name: "asc" },
});
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 cats = await ctx.db.utilizationCategory.findMany({
select: {
id: true, code: true, name: true, description: true,
_count: { select: { projects: true } },
},
orderBy: { code: "asc" },
});
return cats.map((c) => ({
id: c.id,
code: c.code,
name: c.name,
description: c.description,
projectCount: c._count.projects,
}));
},
async list_calculation_rules(_params: Record<string, never>, ctx: ToolContext) {
const rules = await ctx.db.calculationRule.findMany({
select: {
id: true, name: true, description: true, isActive: true,
triggerType: true, orderType: true,
costEffect: true, costReductionPercent: true,
chargeabilityEffect: true, priority: true,
},
orderBy: { priority: "asc" },
});
return rules;
},
async list_effort_rules(_params: Record<string, never>, ctx: ToolContext) {
const rules = await ctx.db.effortRule.findMany({
select: {
id: true, description: true, scopeType: true,
discipline: true, chapter: true, unitMode: true,
hoursPerUnit: true, sortOrder: true,
ruleSet: { select: { name: true, isDefault: true } },
},
orderBy: { sortOrder: "asc" },
});
return rules;
},
async list_experience_multipliers(_params: Record<string, never>, ctx: ToolContext) {
const multipliers = await ctx.db.experienceMultiplierRule.findMany({
select: {
id: true, description: true, chapter: true, location: true,
level: true, costMultiplier: true, billMultiplier: true,
shoringRatio: true, additionalEffortRatio: true, sortOrder: true,
multiplierSet: { select: { name: true, isDefault: true } },
},
orderBy: { sortOrder: "asc" },
});
return multipliers;
},
async list_users(params: { limit?: number }, ctx: ToolContext) {
const limit = Math.min(params.limit ?? 50, 100);
const users = await ctx.db.user.findMany({
select: {
id: true, name: true, email: true, systemRole: true,
resource: { select: { displayName: true, eid: true } },
},
take: limit,
orderBy: { name: "asc" },
});
return users.map((u) => ({
id: u.id,
name: u.name,
email: u.email,
role: u.systemRole,
linkedResource: u.resource?.displayName ?? null,
linkedEid: u.resource?.eid ?? null,
}));
},
async list_notifications(params: { unreadOnly?: boolean; limit?: number }, ctx: ToolContext) {
const limit = Math.min(params.limit ?? 20, 50);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const where: Record<string, any> = {};
if (params.unreadOnly) where.readAt = null;
const notifications = await ctx.db.notification.findMany({
where,
select: {
id: true, type: true, title: true, body: true, readAt: true,
createdAt: true,
},
take: limit,
orderBy: { createdAt: "desc" },
});
return notifications.map((n) => ({
id: n.id,
type: n.type,
title: n.title,
message: n.body,
read: n.readAt !== null,
created: fmtDate(n.createdAt),
}));
},
async mark_notification_read(params: { notificationId: string }, ctx: ToolContext) {
await ctx.db.notification.update({
where: { id: params.notificationId },
data: { readAt: new Date() },
});
return { success: true, message: "Notification marked as read" };
},
// ── DASHBOARD DETAIL ──
async get_dashboard_detail(params: { section?: string }, ctx: ToolContext) {
const section = params.section ?? "all";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result: Record<string, any> = {};
if (section === "all" || section === "peak_times") {
// Peak allocation months
const allocs = await ctx.db.assignment.findMany({
where: { status: { not: "CANCELLED" } },
select: { startDate: true, endDate: true, hoursPerDay: true },
});
const monthCounts: Record<string, number> = {};
for (const a of allocs) {
const month = `${a.startDate.getFullYear()}-${String(a.startDate.getMonth() + 1).padStart(2, "0")}`;
monthCounts[month] = (monthCounts[month] ?? 0) + a.hoursPerDay;
}
const sorted = Object.entries(monthCounts).sort((a, b) => b[1] - a[1]).slice(0, 6);
result.peakTimes = sorted.map(([m, h]) => ({ month: m, totalHoursPerDay: Math.round(h * 10) / 10 }));
}
if (section === "all" || section === "top_resources") {
const resources = await ctx.db.resource.findMany({
where: { isActive: true },
select: {
displayName: true, eid: true, lcrCents: true,
_count: { select: { assignments: true } },
},
orderBy: { lcrCents: "desc" },
take: 10,
});
result.topResources = resources.map((r) => ({
name: r.displayName,
eid: r.eid,
lcr: fmtEur(r.lcrCents),
allocations: r._count.assignments,
}));
}
if (section === "all" || section === "demand_pipeline") {
const demands = await ctx.db.demandRequirement.findMany({
where: { status: { in: ["PROPOSED", "CONFIRMED"] } },
select: {
headcount: true,
role: true,
roleEntity: { select: { name: true } },
project: { select: { name: true, shortCode: true } },
_count: { select: { assignments: true } },
},
take: 15,
orderBy: { startDate: "asc" },
});
result.demandPipeline = demands.map((d) => ({
project: `${d.project.name} (${d.project.shortCode})`,
role: d.roleEntity?.name ?? d.role ?? "Unspecified",
needed: d.headcount - d._count.assignments,
}));
}
if (section === "all" || section === "chargeability_overview") {
const chapters = await ctx.db.resource.groupBy({
by: ["chapter"],
where: { isActive: true },
_count: true,
_avg: { chargeabilityTarget: true },
});
result.chargeabilityByChapter = chapters.map((c) => ({
chapter: c.chapter ?? "Unassigned",
headcount: c._count,
avgTarget: c._avg.chargeabilityTarget ? `${Math.round(c._avg.chargeabilityTarget)}%` : null,
}));
}
return result;
},
// ── PROJECT MANAGEMENT ──
async delete_project(params: { projectId: string }, ctx: ToolContext) {
assertPermission(ctx, "manageProjects" as PermissionKey);
let project = await ctx.db.project.findUnique({
where: { id: params.projectId },
select: { id: true, name: true, shortCode: true, status: true },
});
if (!project) {
project = await ctx.db.project.findUnique({
where: { shortCode: params.projectId },
select: { id: true, name: true, shortCode: true, status: true },
});
}
if (!project) return { error: `Project not found: ${params.projectId}` };
if (project.status !== "DRAFT") return { error: `Only DRAFT projects can be deleted. This project is ${project.status}.` };
await ctx.db.project.delete({ where: { id: project.id } });
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 }, ctx: ToolContext) {
assertPermission(ctx, "manageResources" as PermissionKey);
const ou = await ctx.db.orgUnit.create({
data: {
name: params.name,
...(params.shortName ? { shortName: params.shortName } : {}),
level: params.level,
...(params.parentId ? { parentId: params.parentId } : {}),
isActive: true,
},
select: { id: true, name: true },
});
return { __action: "invalidate", scope: ["orgUnit"], success: true, message: `Created org unit: ${ou.name}`, orgUnitId: ou.id };
},
async update_org_unit(params: { id: string; name?: string; shortName?: string }, ctx: ToolContext) {
assertPermission(ctx, "manageResources" as PermissionKey);
const data: Record<string, unknown> = {};
if (params.name !== undefined) data.name = params.name;
if (params.shortName !== undefined) data.shortName = params.shortName;
if (Object.keys(data).length === 0) return { error: "No fields to update" };
const ou = await ctx.db.orgUnit.update({ where: { id: params.id }, data, select: { id: true, name: true } });
return { __action: "invalidate", scope: ["orgUnit"], success: true, message: `Updated org unit: ${ou.name}` };
},
// ─── Cover Art ───────────────────────────────────────────────────────────
async generate_project_cover(params: { projectId: string; prompt?: string }, ctx: ToolContext) {
assertPermission(ctx, "manageProjects" as PermissionKey);
const project = await ctx.db.project.findUnique({
where: { id: params.projectId },
include: { client: { select: { name: true } } },
});
if (!project) return { error: `Project not found: ${params.projectId}` };
const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
const imageProvider = settings?.imageProvider ?? "dalle";
const { isGeminiConfigured: isGeminiOk } = await import("../gemini-client.js");
const useGemini = imageProvider === "gemini" && isGeminiOk(settings);
const useDalle = imageProvider === "dalle" && isDalleConfigured(settings);
if (!useGemini && !useDalle) {
return { error: "No image provider configured. Set up DALL-E or Gemini in Admin → Settings." };
}
const clientName = project.client?.name ? ` for ${project.client.name}` : "";
const basePrompt = `Professional cover art for a 3D automotive visualization project: "${project.name}"${clientName}. Style: cinematic, modern, photorealistic CGI rendering, dramatic lighting, studio environment. No text or typography in the image.`;
const finalPrompt = params.prompt ? `${basePrompt} Additional direction: ${params.prompt}` : basePrompt;
let coverImageUrl: string;
if (useGemini) {
try {
const { generateGeminiImage, parseGeminiError } = await import("../gemini-client.js");
coverImageUrl = await generateGeminiImage(
settings!.geminiApiKey!,
finalPrompt,
settings!.geminiModel ?? undefined,
);
} catch (err) {
const { parseGeminiError: parseErr } = await import("../gemini-client.js");
return { error: `Gemini error: ${parseErr(err)}` };
}
} else {
const dalleClient = createDalleClient(settings!);
const model = settings!.aiProvider === "azure" ? settings!.azureDalleDeployment! : "dall-e-3";
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const response: any = await dalleClient.images.generate({
model,
prompt: finalPrompt,
size: "1024x1024",
n: 1,
response_format: "b64_json",
});
const b64 = response.data?.[0]?.b64_json;
if (!b64) return { error: "No image data returned from DALL-E" };
coverImageUrl = `data:image/png;base64,${b64}`;
} catch (err) {
return { error: `DALL-E error: ${parseAiError(err)}` };
}
}
await ctx.db.project.update({ where: { id: params.projectId }, data: { coverImageUrl } });
return {
__action: "invalidate",
scope: ["project"],
success: true,
message: `Generated cover art for project "${project.name}" using ${useGemini ? "Gemini" : "DALL-E"}`,
coverImageUrl: coverImageUrl.slice(0, 100) + "...[truncated]",
};
},
async remove_project_cover(params: { projectId: string }, ctx: ToolContext) {
assertPermission(ctx, "manageProjects" as PermissionKey);
const project = await ctx.db.project.findUnique({
where: { id: params.projectId },
select: { id: true, name: true },
});
if (!project) return { error: `Project not found: ${params.projectId}` };
await ctx.db.project.update({ where: { id: params.projectId }, data: { coverImageUrl: null } });
return { __action: "invalidate", scope: ["project"], success: true, message: `Removed cover art from project "${project.name}"` };
},
// ── TASK MANAGEMENT ──
async list_tasks(params: { status?: string; limit?: number }, ctx: ToolContext) {
const limit = Math.min(params.limit ?? 10, 50);
const status = params.status ?? "OPEN";
const tasks = await ctx.db.notification.findMany({
where: {
OR: [
{ userId: ctx.userId },
{ assigneeId: ctx.userId },
],
category: { in: ["TASK", "APPROVAL"] },
taskStatus: status as "OPEN" | "IN_PROGRESS" | "DONE" | "DISMISSED",
},
select: {
id: true, title: true, body: true, priority: true,
taskStatus: true, taskAction: true, dueDate: true,
entityId: true, entityType: true, createdAt: true,
},
take: limit,
orderBy: [{ priority: "desc" }, { createdAt: "desc" }],
});
return tasks.map((t) => ({
id: t.id,
title: t.title,
body: t.body,
priority: t.priority,
taskStatus: t.taskStatus,
taskAction: t.taskAction,
dueDate: fmtDate(t.dueDate),
entityId: t.entityId,
entityType: t.entityType,
createdAt: fmtDate(t.createdAt),
}));
},
async get_task_detail(params: { taskId: string }, ctx: ToolContext) {
const task = await ctx.db.notification.findUnique({
where: { id: params.taskId },
select: {
id: true, title: true, body: true, type: true, priority: true,
category: true, taskStatus: true, taskAction: true,
dueDate: true, entityId: true, entityType: true,
completedAt: true, completedBy: true,
createdAt: true, userId: true, assigneeId: true,
sender: { select: { name: true } },
},
});
if (!task) return { error: `Task not found: ${params.taskId}` };
// Verify the user has access to this task
if (task.userId !== ctx.userId && task.assigneeId !== ctx.userId) {
return { error: "Access denied: this task does not belong to you" };
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result: Record<string, any> = {
id: task.id,
title: task.title,
body: task.body,
type: task.type,
priority: task.priority,
category: task.category,
taskStatus: task.taskStatus,
taskAction: task.taskAction,
dueDate: fmtDate(task.dueDate),
entityId: task.entityId,
entityType: task.entityType,
completedAt: fmtDate(task.completedAt),
completedBy: task.completedBy,
createdAt: fmtDate(task.createdAt),
senderName: task.sender?.name ?? null,
};
// Enrich with linked entity details
if (task.entityId && task.entityType) {
try {
if (task.entityType === "project") {
const project = await ctx.db.project.findUnique({
where: { id: task.entityId },
select: { id: true, name: true, shortCode: true, status: true },
});
if (project) result.linkedEntity = project;
} else if (task.entityType === "vacation") {
const vacation = await ctx.db.vacation.findUnique({
where: { id: task.entityId },
select: {
id: true, type: true, status: true, startDate: true, endDate: true,
resource: { select: { displayName: true } },
},
});
if (vacation) {
result.linkedEntity = {
id: vacation.id,
type: vacation.type,
status: vacation.status,
startDate: fmtDate(vacation.startDate),
endDate: fmtDate(vacation.endDate),
resourceName: vacation.resource.displayName,
};
}
} else if (task.entityType === "assignment" || task.entityType === "allocation") {
const assignment = await ctx.db.assignment.findUnique({
where: { id: task.entityId },
select: {
id: true, status: true, startDate: true, endDate: true,
resource: { select: { displayName: true } },
project: { select: { name: true } },
},
});
if (assignment) {
result.linkedEntity = {
id: assignment.id,
status: assignment.status,
startDate: fmtDate(assignment.startDate),
endDate: fmtDate(assignment.endDate),
resourceName: assignment.resource.displayName,
projectName: assignment.project.name,
};
}
}
} catch {
// Entity may have been deleted — ignore
}
}
return result;
},
async update_task_status(params: { taskId: string; status: string }, ctx: ToolContext) {
const task = await ctx.db.notification.findUnique({
where: { id: params.taskId },
select: { id: true, userId: true, assigneeId: true, taskStatus: true },
});
if (!task) return { error: `Task not found: ${params.taskId}` };
if (task.userId !== ctx.userId && task.assigneeId !== ctx.userId) {
return { error: "Access denied: this task does not belong to you" };
}
const newStatus = params.status as "OPEN" | "IN_PROGRESS" | "DONE" | "DISMISSED";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data: Record<string, any> = { taskStatus: newStatus };
if (newStatus === "DONE") {
data.completedAt = new Date();
data.completedBy = "ai-assistant";
}
await ctx.db.notification.update({
where: { id: params.taskId },
data,
});
emitTaskStatusChanged(task.userId, task.id);
if (newStatus === "DONE") {
emitTaskCompleted(task.userId, task.id);
}
return { __action: "invalidate", scope: ["notification"], success: true, message: `Task status updated to ${newStatus}` };
},
async execute_task_action(params: { taskId: string }, ctx: ToolContext) {
// 1. Fetch the notification
const task = await ctx.db.notification.findUnique({
where: { id: params.taskId },
select: {
id: true, userId: true, assigneeId: true,
taskAction: true, taskStatus: true,
},
});
if (!task) return { error: `Task not found: ${params.taskId}` };
if (task.userId !== ctx.userId && task.assigneeId !== ctx.userId) {
return { error: "Access denied: this task does not belong to you" };
}
if (!task.taskAction) {
return { error: "This task has no executable action" };
}
if (task.taskStatus === "DONE") {
return { error: "This task is already completed" };
}
// 2. Parse taskAction
const parsed = parseTaskAction(task.taskAction);
if (!parsed) {
return { error: `Invalid taskAction format: ${task.taskAction}` };
}
// 3. Look up handler in TASK_ACTION_REGISTRY
const handler = getTaskAction(parsed.action);
if (!handler) {
return { error: `Unknown action: ${parsed.action}` };
}
// 4. Check permission
if (handler.permission && !ctx.permissions.has(handler.permission as PermissionKey)) {
return { error: `Permission denied: you need "${handler.permission}" to perform this action` };
}
// 5. Execute the action
const actionResult = await handler.execute(parsed.entityId, ctx.db, ctx.userId);
if (!actionResult.success) {
return { error: actionResult.message };
}
// 6. Mark the task as DONE
await ctx.db.notification.update({
where: { id: params.taskId },
data: {
taskStatus: "DONE",
completedAt: new Date(),
completedBy: "ai-assistant",
},
});
emitTaskCompleted(task.userId, task.id);
return {
__action: "invalidate",
scope: ["notification"],
success: true,
message: actionResult.message,
action: parsed.action,
entityId: parsed.entityId,
};
},
async create_reminder(params: {
title: string;
body?: string;
remindAt: string;
recurrence?: string;
entityId?: string;
entityType?: string;
}, ctx: ToolContext) {
const remindAt = new Date(params.remindAt);
if (isNaN(remindAt.getTime())) {
return { error: "Invalid remindAt date format. Use ISO 8601 (e.g. 2026-03-20T09:00:00Z)" };
}
const notification = await ctx.db.notification.create({
data: {
userId: ctx.userId,
type: "REMINDER",
title: params.title,
category: "REMINDER",
remindAt,
nextRemindAt: remindAt,
...(params.body !== undefined ? { body: params.body } : {}),
...(params.recurrence !== undefined ? { recurrence: params.recurrence } : {}),
...(params.entityId !== undefined ? { entityId: params.entityId } : {}),
...(params.entityType !== undefined ? { entityType: params.entityType } : {}),
},
});
emitNotificationCreated(ctx.userId, notification.id);
return {
__action: "invalidate",
scope: ["notification"],
success: true,
message: `Reminder "${params.title}" created for ${fmtDate(remindAt)}`,
reminderId: notification.id,
...(params.recurrence !== undefined ? { recurrence: params.recurrence } : {}),
};
},
async create_task_for_user(params: {
userId: string;
title: string;
body?: string;
priority?: string;
dueDate?: string;
taskAction?: string;
entityId?: string;
entityType?: string;
}, ctx: ToolContext) {
assertPermission(ctx, "manageProjects" as PermissionKey);
// Verify target user exists
const targetUser = await ctx.db.user.findUnique({
where: { id: params.userId },
select: { id: true, name: true },
});
if (!targetUser) return { error: `User not found: ${params.userId}` };
const notification = await ctx.db.notification.create({
data: {
userId: params.userId,
type: "TASK_ASSIGNED",
title: params.title,
category: "TASK",
taskStatus: "OPEN",
senderId: ctx.userId,
priority: (params.priority ?? "NORMAL") as "LOW" | "NORMAL" | "HIGH" | "URGENT",
...(params.body !== undefined ? { body: params.body } : {}),
...(params.dueDate !== undefined ? { dueDate: new Date(params.dueDate) } : {}),
...(params.taskAction !== undefined ? { taskAction: params.taskAction } : {}),
...(params.entityId !== undefined ? { entityId: params.entityId } : {}),
...(params.entityType !== undefined ? { entityType: params.entityType } : {}),
},
});
emitTaskAssigned(params.userId, notification.id);
return {
__action: "invalidate",
scope: ["notification"],
success: true,
message: `Task "${params.title}" created for ${targetUser.name ?? params.userId}`,
taskId: notification.id,
};
},
async send_broadcast(params: {
title: string;
body?: string;
targetType: string;
targetValue?: string;
priority?: string;
channel?: string;
link?: string;
}, ctx: ToolContext) {
assertPermission(ctx, "manageProjects" as PermissionKey);
// Resolve recipients
const recipientIds = await resolveRecipients(
params.targetType,
params.targetValue,
ctx.db,
ctx.userId, // exclude sender
);
if (recipientIds.length === 0) {
return { error: "No recipients found for the given target" };
}
const priority = (params.priority ?? "NORMAL") as "LOW" | "NORMAL" | "HIGH" | "URGENT";
const channel = params.channel ?? "in_app";
// Create broadcast record
const broadcast = await ctx.db.notificationBroadcast.create({
data: {
senderId: ctx.userId,
title: params.title,
targetType: params.targetType,
priority,
channel,
recipientCount: recipientIds.length,
sentAt: new Date(),
...(params.body !== undefined ? { body: params.body } : {}),
...(params.targetValue !== undefined ? { targetValue: params.targetValue } : {}),
...(params.link !== undefined ? { link: params.link } : {}),
},
});
// Create individual notifications for each recipient
await ctx.db.notification.createMany({
data: recipientIds.map((userId) => ({
userId,
type: "BROADCAST",
title: params.title,
category: "NOTIFICATION" as const,
priority,
channel,
senderId: ctx.userId,
sourceId: broadcast.id,
...(params.body !== undefined ? { body: params.body } : {}),
...(params.link !== undefined ? { link: params.link } : {}),
})),
});
// Emit SSE events for each recipient
for (const userId of recipientIds) {
emitNotificationCreated(userId, broadcast.id);
}
emitBroadcastSent(broadcast.id, recipientIds.length);
return {
__action: "invalidate",
scope: ["notification"],
success: true,
message: `Broadcast "${params.title}" sent to ${recipientIds.length} recipients`,
broadcastId: broadcast.id,
recipientCount: recipientIds.length,
};
},
// ── INSIGHTS & ANOMALIES ──────────────────────────────────────────────────
async detect_anomalies(_params: Record<string, never>, ctx: ToolContext) {
const now = new Date();
const twoWeeksFromNow = new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000);
const anomalies: Array<{
type: string;
severity: string;
entityId: string;
entityName: string;
message: string;
}> = [];
const projects = await ctx.db.project.findMany({
where: { status: { in: ["ACTIVE", "DRAFT"] } },
include: {
demandRequirements: {
select: {
id: true,
headcount: true,
startDate: true,
endDate: true,
status: true,
_count: { select: { assignments: true } },
},
},
assignments: {
select: {
id: true,
resourceId: true,
startDate: true,
endDate: true,
hoursPerDay: true,
dailyCostCents: true,
status: true,
},
},
},
});
function countBizDays(start: Date, end: Date): number {
let count = 0;
const d = new Date(start);
while (d <= end) {
const dow = d.getDay();
if (dow !== 0 && dow !== 6) count++;
d.setDate(d.getDate() + 1);
}
return count;
}
for (const project of projects) {
// Budget anomaly
if (project.budgetCents > 0) {
const totalDays = countBizDays(project.startDate, project.endDate);
const elapsedDays = countBizDays(project.startDate, now < project.endDate ? now : project.endDate);
if (totalDays > 0 && elapsedDays > 0) {
const expectedBurnRate = elapsedDays / totalDays;
const totalCostCents = project.assignments.reduce((s, a) => {
const aStart = a.startDate < project.startDate ? project.startDate : a.startDate;
const aEnd = a.endDate > now ? now : a.endDate;
if (aEnd < aStart) return s;
return s + a.dailyCostCents * countBizDays(aStart, aEnd);
}, 0);
const actualBurnRate = totalCostCents / project.budgetCents;
if (actualBurnRate > expectedBurnRate * 1.2) {
const overSpendPct = Math.round(((actualBurnRate - expectedBurnRate) / expectedBurnRate) * 100);
anomalies.push({
type: "budget",
severity: actualBurnRate > expectedBurnRate * 1.5 ? "critical" : "warning",
entityId: project.id,
entityName: project.name,
message: `Burning budget ${overSpendPct}% faster than expected. ${Math.round(actualBurnRate * 100)}% spent at ${Math.round(expectedBurnRate * 100)}% timeline.`,
});
}
}
}
// Staffing anomaly
const upcomingDemands = project.demandRequirements.filter(
(d) => d.startDate <= twoWeeksFromNow && d.endDate >= now,
);
for (const demand of upcomingDemands) {
const unfilledCount = demand.headcount - demand._count.assignments;
const unfillPct = demand.headcount > 0 ? unfilledCount / demand.headcount : 0;
if (unfillPct > 0.3) {
anomalies.push({
type: "staffing",
severity: unfillPct > 0.6 ? "critical" : "warning",
entityId: project.id,
entityName: project.name,
message: `${unfilledCount} of ${demand.headcount} positions unfilled, starting ${demand.startDate.toISOString().slice(0, 10)}.`,
});
}
}
// Timeline anomaly
const overruns = project.assignments.filter(
(a) => a.endDate > project.endDate && (a.status === "ACTIVE" || a.status === "CONFIRMED"),
);
if (overruns.length > 0) {
anomalies.push({
type: "timeline",
severity: "warning",
entityId: project.id,
entityName: project.name,
message: `${overruns.length} assignment(s) extend beyond the project end date (${project.endDate.toISOString().slice(0, 10)}).`,
});
}
}
// Utilization anomaly
const resources = await ctx.db.resource.findMany({
where: { isActive: true },
select: { id: true, displayName: true, availability: true },
});
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
const activeAssignments = await ctx.db.assignment.findMany({
where: {
status: { in: ["ACTIVE", "CONFIRMED"] },
startDate: { lte: periodEnd },
endDate: { gte: periodStart },
},
select: { resourceId: true, hoursPerDay: true },
});
const resourceHoursMap = new Map<string, number>();
for (const a of activeAssignments) {
resourceHoursMap.set(a.resourceId, (resourceHoursMap.get(a.resourceId) ?? 0) + a.hoursPerDay);
}
for (const resource of resources) {
const avail = resource.availability as Record<string, number> | null;
if (!avail) continue;
const dailyAvail = Object.values(avail).reduce((s, h) => s + (h ?? 0), 0) / 5;
if (dailyAvail <= 0) continue;
const booked = resourceHoursMap.get(resource.id) ?? 0;
const pct = Math.round((booked / dailyAvail) * 100);
if (pct > 110) {
anomalies.push({
type: "utilization",
severity: pct > 130 ? "critical" : "warning",
entityId: resource.id,
entityName: resource.displayName,
message: `Resource at ${pct}% utilization (${booked.toFixed(1)}h/${dailyAvail.toFixed(1)}h per day).`,
});
} else if (pct < 40 && booked > 0) {
anomalies.push({
type: "utilization",
severity: "warning",
entityId: resource.id,
entityName: resource.displayName,
message: `Resource at only ${pct}% utilization (${booked.toFixed(1)}h/${dailyAvail.toFixed(1)}h per day).`,
});
}
}
anomalies.sort((a, b) => {
if (a.severity !== b.severity) return a.severity === "critical" ? -1 : 1;
return a.type.localeCompare(b.type);
});
return { anomalies, count: anomalies.length };
},
async get_skill_gaps(_params: Record<string, never>, ctx: ToolContext) {
const now = new Date();
// Get active demand requirements with their roles
const demands = await ctx.db.demandRequirement.findMany({
where: {
project: { status: { in: ["ACTIVE", "DRAFT"] } },
status: { not: "CANCELLED" },
endDate: { gte: now },
},
select: {
id: true,
role: true,
headcount: true,
roleEntity: { select: { name: true } },
_count: { select: { assignments: true } },
},
});
// Aggregate demand by role
const demandByRole = new Map<string, { needed: number; filled: number }>();
for (const d of demands) {
const roleName = d.roleEntity?.name ?? d.role ?? "Unknown";
const existing = demandByRole.get(roleName) ?? { needed: 0, filled: 0 };
existing.needed += d.headcount;
existing.filled += Math.min(d._count.assignments, d.headcount);
demandByRole.set(roleName, existing);
}
// Get active resources with skills
const resources = await ctx.db.resource.findMany({
where: { isActive: true },
select: {
id: true,
skills: true,
areaRole: { select: { name: true } },
},
});
// Count skill supply
const skillSupply = new Map<string, number>();
for (const r of resources) {
const skills = (r.skills ?? []) as Array<{ skill: string; level?: number }>;
for (const s of skills) {
const key = s.skill.toLowerCase();
skillSupply.set(key, (skillSupply.get(key) ?? 0) + 1);
}
}
// Build role gaps
const roleGaps = [...demandByRole.entries()]
.map(([role, { needed, filled }]) => ({
role,
needed,
filled,
gap: needed - filled,
fillRate: needed > 0 ? Math.round((filled / needed) * 100) : 100,
}))
.filter((g) => g.gap > 0)
.sort((a, b) => b.gap - a.gap);
// Count supply by role
const supplyByRole = new Map<string, number>();
for (const r of resources) {
const roleName = r.areaRole?.name;
if (roleName) {
supplyByRole.set(roleName, (supplyByRole.get(roleName) ?? 0) + 1);
}
}
return {
roleGaps,
totalOpenPositions: roleGaps.reduce((s, g) => s + g.gap, 0),
skillSupplyTop10: [...skillSupply.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([skill, count]) => ({ skill, resourceCount: count })),
resourcesByRole: [...supplyByRole.entries()]
.sort((a, b) => b[1] - a[1])
.map(([role, count]) => ({ role, count })),
};
},
async get_project_health(_params: Record<string, never>, ctx: ToolContext) {
const now = new Date();
function countBizDays(start: Date, end: Date): number {
let count = 0;
const d = new Date(start);
while (d <= end) {
const dow = d.getDay();
if (dow !== 0 && dow !== 6) count++;
d.setDate(d.getDate() + 1);
}
return count;
}
const projects = await ctx.db.project.findMany({
where: { status: { in: ["ACTIVE", "DRAFT"] } },
include: {
demandRequirements: {
select: {
headcount: true,
_count: { select: { assignments: true } },
},
},
assignments: {
where: { status: { not: "CANCELLED" } },
select: {
dailyCostCents: true,
startDate: true,
endDate: true,
status: true,
},
},
},
});
const healthScores = projects.map((project) => {
// Budget score (0-100)
let budgetScore = 100;
if (project.budgetCents > 0) {
const totalCostCents = project.assignments.reduce((s, a) => {
const days = countBizDays(a.startDate, a.endDate);
return s + a.dailyCostCents * days;
}, 0);
const budgetUsedPct = (totalCostCents / project.budgetCents) * 100;
const totalDays = countBizDays(project.startDate, project.endDate);
const elapsedDays = countBizDays(project.startDate, now < project.endDate ? now : project.endDate);
const timelinePct = totalDays > 0 ? (elapsedDays / totalDays) * 100 : 0;
// Score: penalize if budget burn is significantly ahead of timeline
if (timelinePct > 0 && budgetUsedPct > timelinePct * 1.5) {
budgetScore = Math.max(0, 100 - Math.round(budgetUsedPct - timelinePct));
} else if (budgetUsedPct > 90) {
budgetScore = Math.max(0, 100 - Math.round((budgetUsedPct - 90) * 5));
}
}
// Staffing score (0-100)
const totalDemand = project.demandRequirements.reduce((s, d) => s + d.headcount, 0);
const filledDemand = project.demandRequirements.reduce(
(s, d) => s + Math.min(d._count.assignments, d.headcount),
0,
);
const staffingScore = totalDemand > 0 ? Math.round((filledDemand / totalDemand) * 100) : 100;
// Timeline score (0-100)
const overrunCount = project.assignments.filter(
(a) => a.endDate > project.endDate && (a.status === "ACTIVE" || a.status === "CONFIRMED"),
).length;
const timelineScore = overrunCount > 0
? Math.max(0, 100 - overrunCount * 20)
: 100;
const overall = Math.round((budgetScore + staffingScore + timelineScore) / 3);
return {
projectId: project.id,
projectName: project.name,
shortCode: project.shortCode,
status: project.status,
overall,
budget: budgetScore,
staffing: staffingScore,
timeline: timelineScore,
rating: overall >= 80 ? "healthy" : overall >= 50 ? "at_risk" : "critical",
};
});
healthScores.sort((a, b) => a.overall - b.overall);
return {
projects: healthScores,
summary: {
healthy: healthScores.filter((p) => p.rating === "healthy").length,
atRisk: healthScores.filter((p) => p.rating === "at_risk").length,
critical: healthScores.filter((p) => p.rating === "critical").length,
},
};
},
async get_budget_forecast(_params: Record<string, never>, ctx: ToolContext) {
assertPermission(ctx, "viewCosts" as PermissionKey);
const now = new Date();
function countBizDays(start: Date, end: Date): number {
let count = 0;
const d = new Date(start);
while (d <= end) {
const dow = d.getDay();
if (dow !== 0 && dow !== 6) count++;
d.setDate(d.getDate() + 1);
}
return count;
}
const projects = await ctx.db.project.findMany({
where: {
status: { in: ["ACTIVE", "DRAFT"] },
budgetCents: { gt: 0 },
},
include: {
assignments: {
where: { status: { not: "CANCELLED" } },
select: {
dailyCostCents: true,
startDate: true,
endDate: true,
},
},
},
});
const forecasts = projects.map((project) => {
const totalDays = countBizDays(project.startDate, project.endDate);
const elapsedDays = countBizDays(project.startDate, now < project.endDate ? now : project.endDate);
// Cost spent so far (up to now)
const spentCents = project.assignments.reduce((s, a) => {
const aStart = a.startDate < project.startDate ? project.startDate : a.startDate;
const aEnd = a.endDate > now ? now : a.endDate;
if (aEnd < aStart) return s;
return s + a.dailyCostCents * countBizDays(aStart, aEnd);
}, 0);
// Total projected cost (full assignment durations)
const projectedCostCents = project.assignments.reduce((s, a) => {
return s + a.dailyCostCents * countBizDays(a.startDate, a.endDate);
}, 0);
const budgetCents = project.budgetCents;
const utilization = budgetCents > 0 ? Math.round((spentCents / budgetCents) * 100) : 0;
const timelinePct = totalDays > 0 ? Math.round((elapsedDays / totalDays) * 100) : 0;
const burnStatus = timelinePct > 0
? utilization > timelinePct * 1.2
? "ahead"
: utilization < timelinePct * 0.8
? "behind"
: "on_track"
: "not_started";
return {
projectId: project.id,
projectName: project.name,
shortCode: project.shortCode,
budget: fmtEur(budgetCents),
budgetCents,
spent: fmtEur(spentCents),
spentCents,
remaining: fmtEur(budgetCents - spentCents),
remainingCents: budgetCents - spentCents,
projected: fmtEur(projectedCostCents),
projectedCents: projectedCostCents,
utilization: `${utilization}%`,
timelineProgress: `${timelinePct}%`,
burnStatus,
};
});
forecasts.sort((a, b) => b.spentCents - a.spentCents);
return { forecasts };
},
async get_insights_summary(_params: Record<string, never>, ctx: ToolContext) {
const now = new Date();
const twoWeeksFromNow = new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000);
function countBizDays(start: Date, end: Date): number {
let count = 0;
const d = new Date(start);
while (d <= end) {
const dow = d.getDay();
if (dow !== 0 && dow !== 6) count++;
d.setDate(d.getDate() + 1);
}
return count;
}
const projects = await ctx.db.project.findMany({
where: { status: { in: ["ACTIVE", "DRAFT"] } },
include: {
demandRequirements: {
select: {
headcount: true,
startDate: true,
endDate: true,
_count: { select: { assignments: true } },
},
},
assignments: {
select: {
resourceId: true,
startDate: true,
endDate: true,
hoursPerDay: true,
dailyCostCents: true,
status: true,
},
},
},
});
let budgetCount = 0;
let staffingCount = 0;
let timelineCount = 0;
let criticalCount = 0;
for (const project of projects) {
if (project.budgetCents > 0) {
const totalDays = countBizDays(project.startDate, project.endDate);
const elapsedDays = countBizDays(project.startDate, now < project.endDate ? now : project.endDate);
if (totalDays > 0 && elapsedDays > 0) {
const expectedBurnRate = elapsedDays / totalDays;
const totalCostCents = project.assignments.reduce((s, a) => {
const aStart = a.startDate < project.startDate ? project.startDate : a.startDate;
const aEnd = a.endDate > now ? now : a.endDate;
if (aEnd < aStart) return s;
return s + a.dailyCostCents * countBizDays(aStart, aEnd);
}, 0);
const actualBurnRate = totalCostCents / project.budgetCents;
if (actualBurnRate > expectedBurnRate * 1.2) {
budgetCount++;
if (actualBurnRate > expectedBurnRate * 1.5) criticalCount++;
}
}
}
const upcomingDemands = project.demandRequirements.filter(
(d) => d.startDate <= twoWeeksFromNow && d.endDate >= now,
);
for (const demand of upcomingDemands) {
const unfillPct = demand.headcount > 0 ? (demand.headcount - demand._count.assignments) / demand.headcount : 0;
if (unfillPct > 0.3) {
staffingCount++;
if (unfillPct > 0.6) criticalCount++;
}
}
const overruns = project.assignments.filter(
(a) => a.endDate > project.endDate && (a.status === "ACTIVE" || a.status === "CONFIRMED"),
);
if (overruns.length > 0) timelineCount++;
}
// Utilization
const resources = await ctx.db.resource.findMany({
where: { isActive: true },
select: { id: true, availability: true },
});
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
const activeAssignments = await ctx.db.assignment.findMany({
where: {
status: { in: ["ACTIVE", "CONFIRMED"] },
startDate: { lte: periodEnd },
endDate: { gte: periodStart },
},
select: { resourceId: true, hoursPerDay: true },
});
const resourceHoursMap = new Map<string, number>();
for (const a of activeAssignments) {
resourceHoursMap.set(a.resourceId, (resourceHoursMap.get(a.resourceId) ?? 0) + a.hoursPerDay);
}
let utilizationCount = 0;
for (const resource of resources) {
const avail = resource.availability as Record<string, number> | null;
if (!avail) continue;
const dailyAvail = Object.values(avail).reduce((s, h) => s + (h ?? 0), 0) / 5;
if (dailyAvail <= 0) continue;
const booked = resourceHoursMap.get(resource.id) ?? 0;
const pct = Math.round((booked / dailyAvail) * 100);
if (pct > 110) {
utilizationCount++;
if (pct > 130) criticalCount++;
} else if (pct < 40 && booked > 0) {
utilizationCount++;
}
}
const total = budgetCount + staffingCount + timelineCount + utilizationCount;
return { total, criticalCount, budget: budgetCount, staffing: staffingCount, timeline: timelineCount, utilization: utilizationCount };
},
async run_report(params: {
entity: string;
columns: string[];
filters?: Array<{ field: string; op: string; value: string }>;
limit?: number;
}, ctx: ToolContext) {
const entity = params.entity as "resource" | "project" | "assignment";
if (!["resource", "project", "assignment"].includes(entity)) {
return { error: `Unknown entity: ${params.entity}. Use resource, project, or assignment.` };
}
const columns = params.columns;
const filters = params.filters ?? [];
const limit = Math.min(params.limit ?? 50, 200);
// Build Prisma select from columns
const COLUMN_DEFS: Record<string, Array<{ key: string; prismaPath?: string }>> = {
resource: [
{ key: "id" }, { key: "eid" }, { key: "displayName" }, { key: "email" }, { key: "chapter" },
{ key: "resourceType" }, { key: "lcrCents" }, { key: "ucrCents" }, { key: "chargeabilityTarget" },
{ key: "fte" }, { key: "isActive" }, { key: "postalCode" }, { key: "federalState" },
{ key: "country.name", prismaPath: "country" }, { key: "metroCity.name", prismaPath: "metroCity" },
{ key: "orgUnit.name", prismaPath: "orgUnit" }, { key: "areaRole.name", prismaPath: "areaRole" },
{ key: "createdAt" }, { key: "updatedAt" },
],
project: [
{ key: "id" }, { key: "shortCode" }, { key: "name" }, { key: "orderType" }, { key: "allocationType" },
{ key: "status" }, { key: "winProbability" }, { key: "budgetCents" },
{ key: "startDate" }, { key: "endDate" }, { key: "responsiblePerson" },
{ key: "client.name", prismaPath: "client" }, { key: "createdAt" }, { key: "updatedAt" },
],
assignment: [
{ key: "id" }, { key: "resource.displayName", prismaPath: "resource" }, { key: "resource.eid", prismaPath: "resource" },
{ key: "project.name", prismaPath: "project" }, { key: "project.shortCode", prismaPath: "project" },
{ key: "startDate" }, { key: "endDate" }, { key: "hoursPerDay" }, { key: "percentage" },
{ key: "role" }, { key: "roleEntity.name", prismaPath: "roleEntity" },
{ key: "dailyCostCents" }, { key: "status" }, { key: "createdAt" }, { key: "updatedAt" },
],
};
const entityDefs = COLUMN_DEFS[entity]!;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const select: Record<string, any> = { id: true };
for (const colKey of columns) {
const def = entityDefs.find((c) => c.key === colKey);
if (!def) continue;
if (colKey.includes(".")) {
const relationName = def.prismaPath ?? colKey.split(".")[0]!;
const fieldName = colKey.split(".").slice(1).join(".");
if (!select[relationName]) {
select[relationName] = { select: {} };
}
select[relationName].select[fieldName] = true;
} else {
select[colKey] = true;
}
}
// Build where from filters (only scalar top-level fields)
const SCALAR_FIELDS: Record<string, Set<string>> = {
resource: new Set(["id", "eid", "displayName", "email", "chapter", "resourceType", "lcrCents", "ucrCents", "chargeabilityTarget", "fte", "isActive", "postalCode", "federalState"]),
project: new Set(["id", "shortCode", "name", "orderType", "allocationType", "status", "winProbability", "budgetCents", "startDate", "endDate", "responsiblePerson"]),
assignment: new Set(["id", "startDate", "endDate", "hoursPerDay", "percentage", "role", "dailyCostCents", "status"]),
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const where: Record<string, any> = {};
for (const filter of filters) {
if (!SCALAR_FIELDS[entity]!.has(filter.field)) continue;
let value: unknown = filter.value;
// Try number conversion
const num = Number(filter.value);
if (!Number.isNaN(num) && filter.op !== "contains" && filter.op !== "in") {
value = num;
}
switch (filter.op) {
case "eq": where[filter.field] = value; break;
case "neq": where[filter.field] = { not: value }; break;
case "gt": where[filter.field] = { gt: value }; break;
case "lt": where[filter.field] = { lt: value }; break;
case "gte": where[filter.field] = { gte: value }; break;
case "lte": where[filter.field] = { lte: value }; break;
case "contains": where[filter.field] = { contains: String(filter.value), mode: "insensitive" }; break;
case "in": where[filter.field] = { in: filter.value.split(",").map((v: string) => v.trim()) }; break;
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const model = entity === "resource" ? ctx.db.resource : entity === "project" ? ctx.db.project : ctx.db.assignment;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rows = await (model as any).findMany({ select, where, take: limit });
// Flatten nested relations
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const flatRows = (rows as any[]).map((row: Record<string, unknown>) => {
const flat: Record<string, unknown> = {};
for (const [key, val] of Object.entries(row)) {
if (val !== null && typeof val === "object" && !(val instanceof Date) && !Array.isArray(val)) {
for (const [subKey, subVal] of Object.entries(val as Record<string, unknown>)) {
flat[`${key}.${subKey}`] = subVal;
}
} else {
flat[key] = val;
}
}
return flat;
});
return { rows: flatRows, rowCount: flatRows.length, columns: ["id", ...columns.filter((c) => c !== "id")] };
},
async list_comments(params: { entityType: string; entityId: string }, ctx: ToolContext) {
const comments = await ctx.db.comment.findMany({
where: {
entityType: params.entityType,
entityId: params.entityId,
parentId: null,
},
include: {
author: { select: { id: true, name: true, email: true } },
replies: {
include: {
author: { select: { id: true, name: true, email: true } },
},
orderBy: { createdAt: "asc" as const },
},
},
orderBy: { createdAt: "asc" as const },
});
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);
// Find rate cards applicable — prefer client-specific, then generic
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rateCardWhere: Record<string, any> = { isActive: true };
if (params.clientId) {
rateCardWhere.OR = [
{ clientId: params.clientId },
{ clientId: null },
];
}
const rateCards = await ctx.db.rateCard.findMany({
where: rateCardWhere,
include: {
lines: {
select: {
id: true,
chapter: true,
seniority: true,
costRateCents: true,
billRateCents: true,
role: { select: { id: true, name: true } },
},
},
client: { select: { id: true, name: true } },
},
orderBy: [{ effectiveFrom: "desc" }],
});
if (rateCards.length === 0) {
return { message: "No active rate cards found." };
}
// Resolve role name to ID if needed
let roleId: string | undefined;
if (params.roleName) {
const role = await ctx.db.role.findFirst({
where: { name: { contains: params.roleName, mode: "insensitive" } },
select: { id: true, name: true },
});
if (role) roleId = role.id;
}
// Score each line across all rate cards
type ScoredLine = {
rateCardName: string;
clientName: string | null;
lineId: string;
chapter: string | null;
seniority: string | null;
roleName: string | null;
costRate: string;
billRate: string | null;
score: number;
};
const scoredLines: ScoredLine[] = [];
for (const card of rateCards) {
for (const line of card.lines) {
let score = 0;
let mismatch = false;
if (roleId && line.role) {
if (line.role.id === roleId) score += 4;
else mismatch = true;
}
if (params.chapter && line.chapter) {
if (line.chapter.toLowerCase() === params.chapter.toLowerCase()) score += 2;
else mismatch = true;
}
if (params.seniority && line.seniority) {
if (line.seniority.toLowerCase() === params.seniority.toLowerCase()) score += 1;
else mismatch = true;
}
// Prefer client-specific cards
if (params.clientId && card.client?.id === params.clientId) score += 3;
if (!mismatch) {
scoredLines.push({
rateCardName: card.name,
clientName: card.client?.name ?? null,
lineId: line.id,
chapter: line.chapter,
seniority: line.seniority,
roleName: line.role?.name ?? null,
costRate: fmtEur(line.costRateCents),
billRate: line.billRateCents ? fmtEur(line.billRateCents) : null,
score,
});
}
}
}
scoredLines.sort((a, b) => b.score - a.score);
const best = scoredLines[0];
return {
bestMatch: best ?? null,
alternatives: scoredLines.slice(1, 4),
totalCandidates: scoredLines.length,
};
},
// ── 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) {
assertPermission(ctx, "manageAllocations" as PermissionKey);
const DEFAULT_AVAILABILITY = {
monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8, saturday: 0, sunday: 0,
} as const;
const project = await ctx.db.project.findUnique({
where: { id: params.projectId },
select: { id: true, name: true, budgetCents: true, startDate: true, endDate: true },
});
if (!project) return { error: "Project not found" };
// Load current assignments
const currentAssignments = await ctx.db.assignment.findMany({
where: { projectId: params.projectId, status: { not: "CANCELLED" } },
include: {
resource: {
select: { id: true, displayName: true, lcrCents: true, availability: true, chargeabilityTarget: true },
},
},
});
// Compute baseline
let baselineCostCents = 0;
let baselineHours = 0;
for (const a of currentAssignments) {
const availability = (a.resource?.availability as typeof DEFAULT_AVAILABILITY) ?? DEFAULT_AVAILABILITY;
const result = calculateAllocation({
lcrCents: a.resource?.lcrCents ?? 0,
hoursPerDay: a.hoursPerDay,
startDate: a.startDate,
endDate: a.endDate,
availability,
});
baselineCostCents += result.totalCostCents;
baselineHours += result.totalHours;
}
// Collect resource IDs
const resourceIds = new Set<string>();
for (const c of params.changes) { if (c.resourceId) resourceIds.add(c.resourceId); }
for (const a of currentAssignments) { if (a.resourceId) resourceIds.add(a.resourceId); }
const resources = await ctx.db.resource.findMany({
where: { id: { in: [...resourceIds] } },
select: { id: true, displayName: true, lcrCents: true, availability: true, chargeabilityTarget: true },
});
const resourceMap = new Map(resources.map((r) => [r.id, r]));
// Build scenario entries
const removedIds = new Set(params.changes.filter((c) => c.remove && c.assignmentId).map((c) => c.assignmentId!));
const modifiedIds = new Set(params.changes.filter((c) => !c.remove && c.assignmentId).map((c) => c.assignmentId!));
const scenarioEntries: Array<{
resourceId: string | null;
lcrCents: number;
hoursPerDay: number;
startDate: Date;
endDate: Date;
availability: typeof DEFAULT_AVAILABILITY;
}> = [];
for (const a of currentAssignments) {
if (removedIds.has(a.id) || modifiedIds.has(a.id)) continue;
scenarioEntries.push({
resourceId: a.resourceId,
lcrCents: a.resource?.lcrCents ?? 0,
hoursPerDay: a.hoursPerDay,
startDate: a.startDate,
endDate: a.endDate,
availability: (a.resource?.availability as typeof DEFAULT_AVAILABILITY) ?? DEFAULT_AVAILABILITY,
});
}
for (const c of params.changes) {
if (c.remove) continue;
const resource = c.resourceId ? resourceMap.get(c.resourceId) : null;
scenarioEntries.push({
resourceId: c.resourceId ?? null,
lcrCents: resource?.lcrCents ?? 0,
hoursPerDay: c.hoursPerDay,
startDate: new Date(c.startDate),
endDate: new Date(c.endDate),
availability: (resource?.availability as typeof DEFAULT_AVAILABILITY) ?? DEFAULT_AVAILABILITY,
});
}
// Compute scenario totals
let scenarioCostCents = 0;
let scenarioHours = 0;
for (const entry of scenarioEntries) {
const result = calculateAllocation({
lcrCents: entry.lcrCents,
hoursPerDay: entry.hoursPerDay,
startDate: entry.startDate,
endDate: entry.endDate,
availability: entry.availability,
});
scenarioCostCents += result.totalCostCents;
scenarioHours += result.totalHours;
}
const warnings: string[] = [];
const budgetCents = project.budgetCents ?? 0;
if (budgetCents > 0 && scenarioCostCents > budgetCents) {
const overBudgetPct = Math.round(((scenarioCostCents - budgetCents) / budgetCents) * 100);
warnings.push(`Scenario exceeds budget by ${overBudgetPct}%`);
}
return {
baseline: {
totalCost: fmtEur(baselineCostCents),
totalCostCents: baselineCostCents,
totalHours: baselineHours,
headcount: currentAssignments.length,
},
scenario: {
totalCost: fmtEur(scenarioCostCents),
totalCostCents: scenarioCostCents,
totalHours: scenarioHours,
headcount: scenarioEntries.length,
},
delta: {
costCents: scenarioCostCents - baselineCostCents,
cost: fmtEur(scenarioCostCents - baselineCostCents),
hours: scenarioHours - baselineHours,
headcount: scenarioEntries.length - currentAssignments.length,
},
warnings,
budgetCents,
};
},
async generate_project_narrative(params: { projectId: string }, ctx: ToolContext) {
function countBizDays(start: Date, end: Date): number {
let count = 0;
const d = new Date(start);
while (d <= end) {
const dow = d.getDay();
if (dow !== 0 && dow !== 6) count++;
d.setDate(d.getDate() + 1);
}
return count;
}
const [project, settings] = await Promise.all([
ctx.db.project.findUnique({
where: { id: params.projectId },
include: {
demandRequirements: {
select: {
headcount: true,
_count: { select: { assignments: true } },
},
},
assignments: {
select: {
status: true,
dailyCostCents: true,
startDate: true,
endDate: true,
resource: { select: { displayName: true } },
},
},
},
}),
ctx.db.systemSettings.findUnique({ where: { id: "singleton" } }),
]);
if (!project) return { error: "Project not found" };
if (!isAiConfigured(settings)) {
return { error: "AI is not configured. Please set credentials in Admin > Settings." };
}
const now = new Date();
const totalDays = countBizDays(project.startDate, project.endDate);
const elapsedDays = countBizDays(project.startDate, now < project.endDate ? now : project.endDate);
const progressPercent = totalDays > 0 ? Math.round((elapsedDays / totalDays) * 100) : 0;
const totalDemandHeadcount = project.demandRequirements.reduce((s, d) => s + d.headcount, 0);
const filledDemandHeadcount = project.demandRequirements.reduce(
(s, d) => s + Math.min(d._count.assignments, d.headcount), 0,
);
const staffingPercent = totalDemandHeadcount > 0 ? Math.round((filledDemandHeadcount / totalDemandHeadcount) * 100) : 100;
const totalCostCents = project.assignments.reduce((s, a) => {
const days = countBizDays(a.startDate, a.endDate);
return s + a.dailyCostCents * days;
}, 0);
const budgetCents = project.budgetCents;
const budgetUsedPercent = budgetCents > 0 ? Math.round((totalCostCents / budgetCents) * 100) : 0;
const overrunCount = project.assignments.filter((a) => a.endDate > project.endDate).length;
const dataContext = [
`Project: ${project.name} (${project.shortCode})`,
`Status: ${project.status}`,
`Timeline: ${project.startDate.toISOString().slice(0, 10)} to ${project.endDate.toISOString().slice(0, 10)} (${progressPercent}% elapsed)`,
`Budget: ${fmtEur(budgetCents)} | Estimated cost: ${fmtEur(totalCostCents)} (${budgetUsedPercent}% of budget)`,
`Staffing: ${filledDemandHeadcount}/${totalDemandHeadcount} positions filled (${staffingPercent}%)`,
`Active assignments: ${project.assignments.filter((a) => a.status === "ACTIVE" || a.status === "CONFIRMED").length}`,
overrunCount > 0
? `Timeline risk: ${overrunCount} assignment(s) extend beyond project end date`
: "No timeline overruns detected",
].join("\n");
const prompt = `Generate a concise executive summary for this project covering: budget status, staffing completeness, timeline risk, and key action items. Be specific with numbers. Keep it to 3-5 sentences.\n\n${dataContext}`;
try {
const client = createAiClient(settings!);
const model = settings!.azureOpenAiDeployment!;
const maxTokens = settings!.aiMaxCompletionTokens ?? 300;
const temperature = settings!.aiTemperature ?? 1;
const completion = await client.chat.completions.create({
messages: [
{ role: "system", content: "You are a project management analyst providing brief executive summaries. Be factual and action-oriented." },
{ role: "user", content: prompt },
],
max_completion_tokens: maxTokens,
model,
...(temperature !== 1 ? { temperature } : {}),
});
const narrative = completion.choices[0]?.message?.content?.trim() ?? "";
if (!narrative) return { error: "AI returned an empty response." };
const generatedAt = new Date().toISOString();
// Cache in project dynamicFields
const existingDynamic = (project.dynamicFields as Record<string, unknown>) ?? {};
await ctx.db.project.update({
where: { id: params.projectId },
data: {
dynamicFields: {
...existingDynamic,
aiNarrative: narrative,
aiNarrativeGeneratedAt: generatedAt,
},
},
});
return { narrative, generatedAt };
} catch (err) {
return { error: `AI call failed: ${parseAiError(err)}` };
}
},
async create_comment(params: {
entityType: string;
entityId: string;
body: string;
}, ctx: ToolContext) {
// Resolve the DB user from the assistant tool context userId
const comment = await ctx.db.comment.create({
data: {
entityType: params.entityType,
entityId: params.entityId,
authorId: ctx.userId,
body: params.body,
mentions: [],
},
include: {
author: { select: { id: true, name: true, email: true } },
},
});
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 existing = await ctx.db.comment.findUnique({
where: { id: params.commentId },
select: { id: true, authorId: true, body: true },
});
if (!existing) return { error: "Comment not found" };
// Only the author or an admin can resolve
const dbUser = await ctx.db.user.findUnique({
where: { id: ctx.userId },
select: { systemRole: true },
});
if (existing.authorId !== ctx.userId && dbUser?.systemRole !== "ADMIN") {
return { error: "Only the comment author or an admin can resolve comments" };
}
const resolved = params.resolved !== false;
const updated = await ctx.db.comment.update({
where: { id: params.commentId },
data: { resolved },
include: {
author: { select: { id: true, name: true, email: true } },
},
});
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 where: Record<string, unknown> = {
createdAt: { gte: startDate },
};
if (params.entityType) where.entityType = params.entityType;
if (params.action) where.action = params.action;
if (params.userId) where.userId = params.userId;
if (params.search) {
where.OR = [
{ entityName: { contains: params.search, mode: "insensitive" } },
{ summary: { contains: params.search, mode: "insensitive" } },
{ entityType: { contains: params.search, mode: "insensitive" } },
];
}
const entries = await ctx.db.auditLog.findMany({
where,
include: {
user: { select: { id: true, name: true, email: true } },
},
orderBy: { createdAt: "desc" },
take: limit,
});
if (entries.length === 0) {
return `No changes found in the last ${daysBack} days matching your criteria.`;
}
const lines = entries.map((e) => {
const who = e.user?.name ?? e.user?.email ?? "System";
const when = e.createdAt.toISOString().slice(0, 16).replace("T", " ");
const name = e.entityName ? ` "${e.entityName}"` : "";
const summary = e.summary ? `${e.summary}` : "";
return `[${when}] ${who}: ${e.action} ${e.entityType}${name}${summary}`;
});
return `Found ${entries.length} changes (last ${daysBack} days):\n\n${lines.join("\n")}`;
},
async get_entity_timeline(params: {
entityType: string;
entityId: string;
limit?: number;
}, ctx: ToolContext) {
const limit = Math.min(params.limit ?? 50, 200);
const entries = await ctx.db.auditLog.findMany({
where: {
entityType: params.entityType,
entityId: params.entityId,
},
include: {
user: { select: { id: true, name: true, email: true } },
},
orderBy: { createdAt: "desc" },
take: limit,
});
if (entries.length === 0) {
return `No change history found for ${params.entityType} ${params.entityId}.`;
}
const entityName = entries[0]?.entityName ?? params.entityId;
const lines = entries.map((e) => {
const who = e.user?.name ?? e.user?.email ?? "System";
const when = e.createdAt.toISOString().slice(0, 16).replace("T", " ");
const summary = e.summary ?? e.action;
const source = e.source ? ` (via ${e.source})` : "";
// Include changed fields summary for UPDATE actions
const changes = e.changes as Record<string, unknown> | null;
const diff = changes?.diff as Record<string, { old: unknown; new: unknown }> | undefined;
let diffSummary = "";
if (diff && Object.keys(diff).length > 0) {
const fields = Object.entries(diff)
.slice(0, 3)
.map(([k, v]) => `${k}: ${JSON.stringify(v.old)}${JSON.stringify(v.new)}`)
.join("; ");
diffSummary = `\n Changed: ${fields}`;
if (Object.keys(diff).length > 3) {
diffSummary += ` (+${Object.keys(diff).length - 3} more)`;
}
}
return `[${when}] ${who}${source}: ${summary}${diffSummary}`;
});
return `Change history for ${params.entityType} "${entityName}" (${entries.length} entries):\n\n${lines.join("\n")}`;
},
async get_shoring_ratio(params: { projectId: string }, ctx: ToolContext) {
const sel = { id: true, name: true, shortCode: true, shoringThreshold: true, onshoreCountryCode: true } as const;
let project = await ctx.db.project.findUnique({ where: { id: params.projectId }, select: sel });
if (!project) {
project = await ctx.db.project.findUnique({ where: { shortCode: params.projectId }, select: sel });
}
if (!project) return { error: `Project not found: ${params.projectId}` };
const assignments = await ctx.db.assignment.findMany({
where: { projectId: project.id, status: { not: "CANCELLED" } },
include: { resource: { include: { country: { select: { code: true } } } } },
});
if (assignments.length === 0) {
return `Project "${project.name}" (${project.shortCode}): No active assignments — shoring ratio not available.`;
}
const { calculateShoringRatio: calcShoring } = await import("@planarchy/engine/allocation");
const mapped = assignments.map((a) => {
const start = new Date(a.startDate);
const end = new Date(a.endDate);
const diffDays = Math.max(1, Math.round((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)) + 1);
const workingDays = Math.max(1, Math.round(diffDays / 7 * 5));
return {
resourceId: a.resourceId,
countryCode: a.resource.country?.code ?? null,
hoursPerDay: a.hoursPerDay,
workingDays,
};
});
const threshold = project.shoringThreshold ?? 55;
const onshoreCode = project.onshoreCountryCode ?? "DE";
const result = calcShoring(mapped, threshold, onshoreCode);
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 warning = result.isAboveThreshold ? ` -- Above ${threshold}% offshore threshold!` : "";
return `Project "${project.name}" (${project.shortCode}): ${result.onshoreRatio}% onshore (${onshoreCode}), ${result.offshoreRatio}% offshore. Breakdown: ${countryParts}.${warning}${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;
}
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);
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 }),
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,
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 };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { content: JSON.stringify({ error: msg }) };
}
}