From 43de66e982f7cc2bc42dbd5af2632d67f7cace99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Thu, 9 Apr 2026 21:14:26 +0200 Subject: [PATCH] feat(api): add audit helpers, tool registry, shared tool manifest types, and UI primitives Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/components/ui/Badge.tsx | 29 + apps/web/src/components/ui/Button.tsx | 47 ++ apps/web/src/hooks/useLocalStorage.ts | 72 +++ ...assistant-tools-workflow-scenarios.test.ts | 551 ++++++++++++++++++ packages/api/src/lib/audit-helpers.ts | 34 ++ .../api/src/router/assistant-tool-registry.ts | 134 +++++ .../20260407_assignment_composite_indexes.sql | 8 + packages/shared/src/types/tool-manifest.ts | 29 + 8 files changed, 904 insertions(+) create mode 100644 apps/web/src/components/ui/Badge.tsx create mode 100644 apps/web/src/components/ui/Button.tsx create mode 100644 apps/web/src/hooks/useLocalStorage.ts create mode 100644 packages/api/src/__tests__/assistant-tools-workflow-scenarios.test.ts create mode 100644 packages/api/src/lib/audit-helpers.ts create mode 100644 packages/api/src/router/assistant-tool-registry.ts create mode 100644 packages/db/prisma/migrations/20260407_assignment_composite_indexes.sql create mode 100644 packages/shared/src/types/tool-manifest.ts diff --git a/apps/web/src/components/ui/Badge.tsx b/apps/web/src/components/ui/Badge.tsx new file mode 100644 index 0000000..55fdab5 --- /dev/null +++ b/apps/web/src/components/ui/Badge.tsx @@ -0,0 +1,29 @@ +import { clsx } from "clsx"; + +interface BadgeProps { + children: React.ReactNode; + variant?: "default" | "success" | "warning" | "danger" | "info"; + className?: string; +} + +const variantClasses = { + default: "bg-gray-100 text-gray-700", + success: "bg-green-100 text-green-700", + warning: "bg-yellow-100 text-yellow-700", + danger: "bg-red-100 text-red-700", + info: "bg-blue-100 text-blue-700", +}; + +export function Badge({ children, variant = "default", className }: BadgeProps) { + return ( + + {children} + + ); +} diff --git a/apps/web/src/components/ui/Button.tsx b/apps/web/src/components/ui/Button.tsx new file mode 100644 index 0000000..8ad7305 --- /dev/null +++ b/apps/web/src/components/ui/Button.tsx @@ -0,0 +1,47 @@ +import { clsx } from "clsx"; +import type { ButtonHTMLAttributes } from "react"; + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: "primary" | "secondary" | "ghost" | "danger"; + size?: "sm" | "md" | "lg"; +} + +const variantClasses = { + primary: "bg-brand-600 hover:bg-brand-700 text-white", + secondary: "bg-white hover:bg-gray-50 text-gray-700 border border-gray-300", + ghost: "text-gray-600 hover:bg-gray-100", + danger: "bg-red-600 hover:bg-red-700 text-white", +}; + +const sizeClasses = { + sm: "px-3 py-1.5 text-xs", + md: "px-4 py-2 text-sm", + lg: "px-6 py-3 text-base", +}; + +export function Button({ + variant = "primary", + size = "md", + className, + children, + disabled, + ...props +}: ButtonProps) { + return ( + + ); +} diff --git a/apps/web/src/hooks/useLocalStorage.ts b/apps/web/src/hooks/useLocalStorage.ts new file mode 100644 index 0000000..5dc6886 --- /dev/null +++ b/apps/web/src/hooks/useLocalStorage.ts @@ -0,0 +1,72 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; + +/** + * Persist a value in localStorage with SSR safety and JSON serialization. + * + * When `T` is a plain object, stored values are merged with `defaultValue` + * so fields added to the schema later get their defaults filled in. + * For primitive types (string, number, boolean) the stored value is used as-is. + * + * The optional `changeEventName` parameter causes every write to dispatch a + * CustomEvent so other instances of the hook in the same tab stay in sync. + */ +export function useLocalStorage( + key: string, + defaultValue: T, + changeEventName?: string, +): [T, (updater: T | ((prev: T) => T)) => void] { + const [value, setValue] = useState(() => read(key, defaultValue)); + + // Re-read after hydration (SSR → client) and subscribe to cross-instance events. + useEffect(() => { + setValue(read(key, defaultValue)); + + if (!changeEventName) return; + function handleChange(e: Event) { + setValue((e as CustomEvent).detail); + } + window.addEventListener(changeEventName, handleChange); + return () => window.removeEventListener(changeEventName, handleChange); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key, changeEventName]); + + const set = useCallback( + (updater: T | ((prev: T) => T)) => { + setValue((prev) => { + const next = typeof updater === "function" ? (updater as (p: T) => T)(prev) : updater; + if (typeof window !== "undefined") { + localStorage.setItem(key, JSON.stringify(next)); + if (changeEventName) { + window.dispatchEvent(new CustomEvent(changeEventName, { detail: next })); + } + } + return next; + }); + }, + [key, changeEventName], + ); + + return [value, set]; +} + +function read(key: string, defaultValue: T): T { + if (typeof window === "undefined") return defaultValue; + try { + const raw = localStorage.getItem(key); + if (!raw) return defaultValue; + const parsed = JSON.parse(raw) as T; + // For plain objects, merge with defaults to handle schema evolution. + if (isPlainObject(defaultValue) && isPlainObject(parsed)) { + return { ...defaultValue, ...parsed } as T; + } + return parsed; + } catch { + return defaultValue; + } +} + +function isPlainObject(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} diff --git a/packages/api/src/__tests__/assistant-tools-workflow-scenarios.test.ts b/packages/api/src/__tests__/assistant-tools-workflow-scenarios.test.ts new file mode 100644 index 0000000..ff31830 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-workflow-scenarios.test.ts @@ -0,0 +1,551 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { listAssignmentBookings } from "@capakraken/application"; +import { PermissionKey, SystemRole } from "@capakraken/shared"; + +vi.mock("@capakraken/application", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + approveEstimateVersion: vi.fn(), + cloneEstimate: vi.fn(), + commitDispoImportBatch: vi.fn(), + countPlanningEntries: vi.fn().mockResolvedValue({ countsByRoleId: new Map() }), + createEstimateExport: vi.fn(), + createEstimatePlanningHandoff: vi.fn(), + createEstimateRevision: vi.fn(), + assessDispoImportReadiness: vi.fn(), + loadResourceDailyAvailabilityContexts: vi.fn().mockResolvedValue(new Map()), + getDashboardDemand: vi.fn().mockResolvedValue([]), + getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + getDashboardOverview: vi.fn(), + getDashboardSkillGapSummary: vi.fn().mockResolvedValue({ + roleGaps: [], + totalOpenPositions: 0, + skillSupplyTop10: [], + resourcesByRole: [], + }), + getDashboardProjectHealth: vi.fn().mockResolvedValue([]), + getDashboardPeakTimes: vi.fn().mockResolvedValue([]), + getDashboardTopValueResources: vi.fn().mockResolvedValue([]), + getEstimateById: vi.fn(), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + stageDispoImportBatch: vi.fn(), + submitEstimateVersion: vi.fn(), + updateEstimateDraft: vi.fn(), + }; +}); + +import { executeTool } from "../router/assistant-tools.js"; +import { createToolContext } from "./assistant-tools-insights-scenarios-test-helpers.js"; + +describe("assistant tools workflow scenarios", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(listAssignmentBookings).mockResolvedValue([]); + }); + + it("capacity search then resource check: finds capacity and then checks a specific resource from the result", async () => { + vi.mocked(listAssignmentBookings).mockResolvedValue([]); + + const resourceRecord = { + id: "res_1", + displayName: "Bruce Banner", + eid: "EMP-001", + fte: 1, + availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8, saturday: 0, sunday: 0 }, + countryId: "country_de", + federalState: null, + metroCityId: null, + country: { code: "DE", dailyWorkingHours: 8 }, + metroCity: null, + areaRole: { name: "Pipeline TD" }, + chapter: "Delivery", + }; + + const db = { + resource: { + findMany: vi.fn().mockResolvedValue([resourceRecord]), + findUnique: vi.fn().mockResolvedValue(resourceRecord), + findFirst: vi.fn().mockResolvedValue(null), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([]), + }, + vacation: { + findMany: vi.fn().mockResolvedValue([]), + }, + systemSettings: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }; + + const ctx = createToolContext(db, { + userRole: SystemRole.CONTROLLER, + permissions: [PermissionKey.VIEW_PLANNING], + }); + + // Step 1: find capacity for the period + const capacityResult = await executeTool( + "find_capacity", + JSON.stringify({ + startDate: "2026-05-01", + endDate: "2026-05-30", + minHoursPerDay: 4, + chapter: "Delivery", + }), + ctx, + ); + + const capacityParsed = JSON.parse(capacityResult.content) as { + results: Array<{ id: string; name: string; eid: string }>; + totalFound: number; + }; + + expect(db.resource.findMany).toHaveBeenCalled(); + expect(capacityParsed.results).toHaveLength(1); + expect(capacityParsed.results[0].name).toBe("Bruce Banner"); + + // Step 2: use resource ID from result to check availability in a specific date window + const foundResourceId = capacityParsed.results[0].id; + expect(foundResourceId).toBe("res_1"); + + const availabilityResult = await executeTool( + "check_resource_availability", + JSON.stringify({ + resourceId: foundResourceId, + startDate: "2026-05-05", + endDate: "2026-05-09", + }), + ctx, + ); + + const availabilityParsed = JSON.parse(availabilityResult.content) as { + workingDays: number; + periodAvailableHours: number; + periodBookedHours: number; + periodRemainingHours: number; + isFullyAvailable: boolean; + }; + + // 2026-05-05 (Tue) to 2026-05-09 (Sat) = 4 working days (Tue-Fri) + expect(availabilityParsed.workingDays).toBe(4); + expect(availabilityParsed.periodAvailableHours).toBe(32); + expect(availabilityParsed.periodBookedHours).toBe(0); + expect(availabilityParsed.periodRemainingHours).toBe(32); + expect(availabilityParsed.isFullyAvailable).toBe(true); + }); + + it("vacation balance then upcoming vacations: retrieves balance and then lists upcoming vacations for the same resource", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-01T00:00:00.000Z")); + + try { + const db = { + resource: { + findUnique: vi.fn().mockResolvedValue({ + id: "res_2", + eid: "EMP-002", + displayName: "Tony Stark", + userId: "user_2", + chapter: "Engineering", + federalState: null, + countryId: "country_de", + metroCityId: null, + country: { code: "DE", name: "Germany" }, + metroCity: null, + }), + }, + holidayCalendar: { + findMany: vi.fn().mockResolvedValue([]), + }, + systemSettings: { + findUnique: vi.fn().mockResolvedValue({ vacationDefaultDays: 28 }), + }, + vacationEntitlement: { + findUnique: vi.fn().mockResolvedValue(null), + create: vi.fn().mockResolvedValue({ + id: "ent_2026_res2", + resourceId: "res_2", + year: 2026, + entitledDays: 28, + carryoverDays: 0, + usedDays: 0, + pendingDays: 0, + }), + update: vi.fn().mockResolvedValue({ + id: "ent_2026_res2", + resourceId: "res_2", + year: 2026, + entitledDays: 28, + carryoverDays: 0, + usedDays: 0, + pendingDays: 0, + }), + }, + vacation: { + findMany: vi.fn().mockImplementation(async (args: { where?: { type?: unknown; status?: unknown; startDate?: unknown } } = {}) => { + if (args?.where?.status === "APPROVED" && args?.where?.startDate) { + // list_vacations_upcoming query + return [ + { + id: "vac_1", + resourceId: "res_2", + status: "APPROVED", + type: "ANNUAL", + startDate: new Date("2026-05-10T00:00:00.000Z"), + endDate: new Date("2026-05-14T00:00:00.000Z"), + isHalfDay: false, + halfDayPart: null, + resource: { + id: "res_2", + displayName: "Tony Stark", + eid: "EMP-002", + lcrCents: 9_000, + chapter: "Engineering", + }, + requestedBy: null, + approvedBy: null, + }, + ]; + } + // vacation balance queries (by type) + return []; + }), + }, + }; + + const ctx = createToolContext(db, { userRole: SystemRole.ADMIN }); + + // Step 1: get vacation balance for the resource + const balanceResult = await executeTool( + "get_vacation_balance", + JSON.stringify({ resourceId: "res_2", year: 2026 }), + ctx, + ); + + const balanceParsed = JSON.parse(balanceResult.content) as { + resource: string; + eid: string; + year: number; + entitlement: number; + remaining: number; + }; + + expect(balanceParsed.resource).toBe("Tony Stark"); + expect(balanceParsed.eid).toBe("EMP-002"); + expect(balanceParsed.year).toBe(2026); + expect(balanceParsed.entitlement).toBe(28); + expect(balanceParsed.remaining).toBe(28); + + // Step 2: list upcoming vacations for the same resource (filter by name) + const upcomingResult = await executeTool( + "list_vacations_upcoming", + JSON.stringify({ resourceName: "Tony", daysAhead: 30, limit: 10 }), + ctx, + ); + + const upcomingParsed = JSON.parse(upcomingResult.content) as Array<{ + resource: string; + eid: string; + start: string; + end: string; + }>; + + expect(upcomingParsed).toHaveLength(1); + expect(upcomingParsed[0].resource).toBe("Tony Stark"); + expect(upcomingParsed[0].eid).toBe("EMP-002"); + expect(upcomingParsed[0].start).toBe("2026-05-10"); + expect(upcomingParsed[0].end).toBe("2026-05-14"); + } finally { + vi.useRealTimers(); + } + }); + + it("demand listing then staffing suggestions: lists open demands and then retrieves suggestions for the project", async () => { + vi.mocked(listAssignmentBookings).mockResolvedValue([]); + + const projectRecord = { + id: "project_1", + shortCode: "GDM", + name: "Gelddruckmaschine", + status: "ACTIVE", + responsiblePerson: null, + startDate: new Date("2026-06-01T00:00:00.000Z"), + endDate: new Date("2026-06-30T00:00:00.000Z"), + }; + + const db = { + demandRequirement: { + findMany: vi.fn().mockResolvedValue([ + { + id: "demand_1", + projectId: "project_1", + startDate: new Date("2026-06-01T00:00:00.000Z"), + endDate: new Date("2026-06-30T00:00:00.000Z"), + hoursPerDay: 8, + percentage: 100, + role: "Pipeline TD", + roleId: null, + headcount: 2, + status: "OPEN", + metadata: null, + createdAt: new Date("2026-05-01T00:00:00.000Z"), + updatedAt: new Date("2026-05-01T00:00:00.000Z"), + project: { + id: "project_1", + name: "Gelddruckmaschine", + shortCode: "GDM", + status: "ACTIVE", + endDate: new Date("2026-06-30T00:00:00.000Z"), + }, + roleEntity: null, + assignments: [], + }, + ]), + }, + project: { + findUnique: vi.fn().mockResolvedValue(projectRecord), + findFirst: vi.fn().mockResolvedValue(null), + }, + resource: { + findMany: vi.fn().mockResolvedValue([ + { + id: "res_3", + displayName: "Natasha Romanoff", + eid: "EMP-003", + fte: 1, + chapter: "VFX", + skills: [{ skill: "Houdini", category: "FX", proficiency: 4, isMainSkill: true }], + lcrCents: 7_500, + chargeabilityTarget: 80, + availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8, saturday: 0, sunday: 0 }, + valueScore: 85, + countryId: null, + federalState: null, + metroCityId: null, + country: null, + metroCity: null, + areaRole: { name: "Pipeline TD" }, + }, + ]), + }, + systemSettings: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }; + + const ctx = createToolContext(db, { + userRole: SystemRole.CONTROLLER, + permissions: [PermissionKey.VIEW_PLANNING, PermissionKey.VIEW_COSTS], + }); + + // Step 1: list demands (no status filter — AllocationStatus enum doesn't include OPEN) + const demandsResult = await executeTool( + "list_demands", + JSON.stringify({}), + ctx, + ); + + const demandsParsed = JSON.parse(demandsResult.content) as Array<{ + id: string; + project: string; + role: string; + status: string; + headcount: number; + remaining: number; + }>; + + expect(demandsParsed).toHaveLength(1); + expect(demandsParsed[0].id).toBe("demand_1"); + expect(demandsParsed[0].project).toBe("Gelddruckmaschine"); + expect(demandsParsed[0].status).toBe("OPEN"); + expect(demandsParsed[0].headcount).toBe(2); + expect(demandsParsed[0].remaining).toBe(2); + + // Step 2: get staffing suggestions for the project from the demand + const projectId = demandsParsed[0].id.startsWith("demand_") ? "project_1" : demandsParsed[0].id; + + const suggestionsResult = await executeTool( + "get_staffing_suggestions", + JSON.stringify({ + projectId, + startDate: "2026-06-01", + endDate: "2026-06-30", + limit: 5, + }), + ctx, + ); + + const suggestionsParsed = JSON.parse(suggestionsResult.content) as { + project: string; + suggestions: Array<{ id: string; name: string }>; + }; + + expect(suggestionsParsed.project).toBe("Gelddruckmaschine (GDM)"); + expect(Array.isArray(suggestionsParsed.suggestions)).toBe(true); + }); + + it("budget status check: retrieves budget utilization for a project", async () => { + vi.mocked(listAssignmentBookings).mockResolvedValue([ + { + projectId: "project_2", + status: "CONFIRMED", + dailyCostCents: 12_000, + hoursPerDay: 8, + startDate: new Date("2026-07-01T00:00:00.000Z"), + endDate: new Date("2026-07-02T00:00:00.000Z"), + project: { + id: "project_2", + status: "ACTIVE", + }, + }, + { + projectId: "project_2", + status: "CONFIRMED", + dailyCostCents: 8_000, + hoursPerDay: 8, + startDate: new Date("2026-07-03T00:00:00.000Z"), + endDate: new Date("2026-07-03T00:00:00.000Z"), + project: { + id: "project_2", + status: "ACTIVE", + }, + }, + ] as Awaited>); + + const db = { + project: { + findUnique: vi + .fn() + .mockResolvedValueOnce({ + id: "project_2", + name: "Raketenbauprojekt", + shortCode: "RBP", + status: "ACTIVE", + responsiblePerson: "Elon", + }) + .mockResolvedValueOnce({ + id: "project_2", + name: "Raketenbauprojekt", + shortCode: "RBP", + budgetCents: 500_000, + winProbability: 100, + startDate: new Date("2026-07-01T00:00:00.000Z"), + endDate: new Date("2026-07-31T00:00:00.000Z"), + }), + findFirst: vi.fn(), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([]), + }, + }; + + const ctx = createToolContext(db, { + userRole: SystemRole.CONTROLLER, + permissions: [PermissionKey.VIEW_COSTS], + }); + + const result = await executeTool( + "get_budget_status", + JSON.stringify({ projectId: "project_2" }), + ctx, + ); + + const parsed = JSON.parse(result.content) as { + project: string; + code: string; + budget: string; + confirmed: string; + proposed: string; + allocated: string; + remaining: string; + utilization: string; + }; + + expect(vi.mocked(listAssignmentBookings)).toHaveBeenCalledWith(db, { + projectIds: ["project_2"], + }); + expect(parsed.project).toBe("Raketenbauprojekt"); + expect(parsed.code).toBe("RBP"); + expect(parsed.budget).toBe("5.000,00 EUR"); + // Booking 1: July 1-2 (2 days) × 12000 cents = 24000 cents = 240 EUR + // Booking 2: July 3 (1 day) × 8000 cents = 8000 cents = 80 EUR + // Total confirmed: 320 EUR + expect(parsed.confirmed).toBe("320,00 EUR"); + expect(parsed.proposed).toBe("0,00 EUR"); + expect(parsed.allocated).toBe("320,00 EUR"); + expect(parsed.remaining).toBe("4.680,00 EUR"); + expect(parsed.utilization).toBe("6.4%"); + }); + + it("search resources with availability filter: finds resources in a chapter", async () => { + const db = { + resource: { + findMany: vi.fn().mockResolvedValue([ + { + id: "res_4", + eid: "EMP-004", + displayName: "Wanda Maximoff", + chapter: "FX", + fte: 1, + lcrCents: 8_000, + chargeabilityTarget: 80, + isActive: true, + areaRole: { name: "FX Artist" }, + country: { code: "DE", name: "Germany" }, + metroCity: null, + orgUnit: null, + }, + { + id: "res_5", + eid: "EMP-005", + displayName: "Pietro Maximoff", + chapter: "FX", + fte: 0.8, + lcrCents: 7_200, + chargeabilityTarget: 75, + isActive: true, + areaRole: { name: "Motion Designer" }, + country: { code: "DE", name: "Germany" }, + metroCity: null, + orgUnit: null, + }, + ]), + }, + }; + + const ctx = createToolContext(db, { + userRole: SystemRole.CONTROLLER, + permissions: [PermissionKey.VIEW_ALL_RESOURCES], + }); + + const result = await executeTool( + "search_resources", + JSON.stringify({ orgUnit: undefined, country: "DE", limit: 10 }), + ctx, + ); + + const parsed = JSON.parse(result.content) as Array<{ + id: string; + name: string; + eid: string; + chapter: string; + role: string; + }>; + + expect(db.resource.findMany).toHaveBeenCalled(); + expect(parsed).toHaveLength(2); + + const wanda = parsed.find((r) => r.id === "res_4"); + expect(wanda).toBeDefined(); + expect(wanda?.name).toBe("Wanda Maximoff"); + expect(wanda?.eid).toBe("EMP-004"); + expect(wanda?.chapter).toBe("FX"); + expect(wanda?.role).toBe("FX Artist"); + + const pietro = parsed.find((r) => r.id === "res_5"); + expect(pietro).toBeDefined(); + expect(pietro?.name).toBe("Pietro Maximoff"); + expect(pietro?.eid).toBe("EMP-005"); + }); +}); diff --git a/packages/api/src/lib/audit-helpers.ts b/packages/api/src/lib/audit-helpers.ts new file mode 100644 index 0000000..258c278 --- /dev/null +++ b/packages/api/src/lib/audit-helpers.ts @@ -0,0 +1,34 @@ +import type { PrismaClient } from "@capakraken/db"; +import { createAuditEntry } from "./audit.js"; + +type AuditAction = "CREATE" | "UPDATE" | "DELETE" | "SHIFT" | "IMPORT"; +type AuditSource = "ui" | "api" | "ai" | "import" | "cron"; + +interface BoundAuditParams { + entityType: string; + entityId: string; + entityName?: string; + action: AuditAction; + before?: Record; + after?: Record; + summary?: string; + metadata?: Record; +} + +/** + * Creates a fire-and-forget audit logger with db, userId, and source pre-bound. + * Use at the top of a procedure after resolving the current user. + * + * @example + * const audit = makeAuditLogger(ctx.db, userRecord?.id); + * audit({ entityType: "Vacation", entityId: v.id, action: "UPDATE", after: v }); + */ +export function makeAuditLogger( + db: PrismaClient, + userId: string | undefined, + source: AuditSource = "ui", +): (params: BoundAuditParams) => void { + return (params) => { + void createAuditEntry({ db, ...(userId !== undefined ? { userId } : {}), source, ...params }); + }; +} diff --git a/packages/api/src/router/assistant-tool-registry.ts b/packages/api/src/router/assistant-tool-registry.ts new file mode 100644 index 0000000..9ac9215 --- /dev/null +++ b/packages/api/src/router/assistant-tool-registry.ts @@ -0,0 +1,134 @@ +import type { ToolManifest } from "@capakraken/shared"; + +/** + * Rich metadata registry for AI assistant tools. + * Complements the OpenAI function schema (ToolDef) with category, mutation flag, + * and intent descriptions that improve model tool selection and documentation. + * + * Start: 10 high-traffic tools. Add entries as needed — the registry is additive + * and does not affect tool execution. + */ +export const TOOL_REGISTRY: ToolManifest[] = [ + // ─── Planning ────────────────────────────────────────────────────────────── + { + name: "create_allocation", + category: "planning", + isMutation: true, + intent: "Assign a resource to a project for a specific date range at a utilization percentage", + examples: [ + "Book Alice on Project X from April to June at 80%", + "Assign the lead developer to the backend project starting next Monday at full capacity", + ], + }, + { + name: "cancel_allocation", + category: "planning", + isMutation: true, + intent: "Remove a resource booking from a project", + examples: [ + "Cancel Bob's allocation on Project Y", + "Remove the allocation for resource 123 on the frontend project", + ], + }, + { + name: "list_allocations", + category: "planning", + isMutation: false, + intent: "List all allocations for a project or resource within a date range", + examples: [ + "Show me all bookings for the Alpha project this quarter", + "What projects is Alice allocated to in May?", + ], + }, + // ─── Resource ────────────────────────────────────────────────────────────── + { + name: "search_resources", + category: "resource", + isMutation: false, + intent: "Search for resources by name, skill, chapter, or seniority", + examples: [ + "Find all senior developers in the Berlin chapter", + "Who has React skills and is available next month?", + ], + }, + { + name: "check_resource_availability", + category: "resource", + isMutation: false, + intent: "Check how many hours a resource has free in a date range", + examples: [ + "Is Alice available for a new project in Q3?", + "How much capacity does the frontend chapter have next month?", + ], + }, + // ─── Staffing ────────────────────────────────────────────────────────────── + { + name: "get_staffing_suggestions", + category: "staffing", + isMutation: false, + requiresAdvanced: true, + intent: "Get ranked resource suggestions for an open demand, scored by skill match, availability, and cost", + examples: [ + "Who should I assign to fill the senior designer role on Project Z?", + "Find the best available backend engineers for a 3-month engagement starting in June", + ], + }, + { + name: "fill_demand", + category: "staffing", + isMutation: true, + intent: "Create an allocation to fill an open staffing demand", + examples: [ + "Fill the open designer demand with Alice", + "Assign Bob to satisfy the pending backend demand", + ], + }, + // ─── Vacation ────────────────────────────────────────────────────────────── + { + name: "create_vacation", + category: "vacation", + isMutation: true, + intent: "Submit a vacation or leave request for a resource", + examples: [ + "Request 2 weeks of annual leave for Alice starting August 1st", + "Book sick leave for Bob for today", + ], + }, + { + name: "approve_vacation", + category: "vacation", + isMutation: true, + intent: "Approve a pending vacation request", + examples: [ + "Approve Alice's vacation request", + "Accept the leave request with ID abc123", + ], + }, + // ─── Reporting ───────────────────────────────────────────────────────────── + { + name: "get_chargeability_report", + category: "reporting", + isMutation: false, + requiresAdvanced: true, + intent: "Generate a chargeability report showing billable vs. non-billable utilization across resources or chapters", + examples: [ + "Show me chargeability for the engineering chapter in Q1", + "What is the billable rate for the team last month?", + ], + }, +]; + +/** Lookup map for O(1) manifest access by tool name. */ +export const TOOL_REGISTRY_MAP: ReadonlyMap = new Map( + TOOL_REGISTRY.map((m) => [m.name, m]), +); + +/** Get the manifest for a given tool name, or undefined if not registered. */ +export function getToolManifest(name: string): ToolManifest | undefined { + return TOOL_REGISTRY_MAP.get(name); +} + +/** Filter the registry by category. */ +export function getToolsByCategory(category: ToolManifest["category"]): ToolManifest[] { + return TOOL_REGISTRY.filter((m) => m.category === category); +} diff --git a/packages/db/prisma/migrations/20260407_assignment_composite_indexes.sql b/packages/db/prisma/migrations/20260407_assignment_composite_indexes.sql new file mode 100644 index 0000000..2d01262 --- /dev/null +++ b/packages/db/prisma/migrations/20260407_assignment_composite_indexes.sql @@ -0,0 +1,8 @@ +-- Add composite indexes on Assignment for status+date-range queries +-- These speed up cross-project status scans and project-scoped status+date filters. + +CREATE INDEX CONCURRENTLY IF NOT EXISTS "assignments_status_startDate_endDate_idx" + ON "assignments" ("status", "startDate", "endDate"); + +CREATE INDEX CONCURRENTLY IF NOT EXISTS "assignments_projectId_status_startDate_endDate_idx" + ON "assignments" ("projectId", "status", "startDate", "endDate"); diff --git a/packages/shared/src/types/tool-manifest.ts b/packages/shared/src/types/tool-manifest.ts new file mode 100644 index 0000000..0b4559c --- /dev/null +++ b/packages/shared/src/types/tool-manifest.ts @@ -0,0 +1,29 @@ +/** + * Tool manifest types for the AI assistant. + * Provides rich metadata beyond the OpenAI function schema (ToolDef). + */ + +export type ToolCategory = + | "planning" + | "resource" + | "project" + | "vacation" + | "staffing" + | "reporting" + | "admin" + | "system"; + +export interface ToolManifest { + /** Tool name as registered in OpenAI function schema. */ + name: string; + /** Functional category for grouping and filtering. */ + category: ToolCategory; + /** True if the tool performs a write operation (used for audit and confirmation). */ + isMutation: boolean; + /** True if the tool requires advanced assistant permission. */ + requiresAdvanced?: boolean; + /** One-line description of what this tool does (for system prompts and docs). */ + intent: string; + /** Optional natural-language examples to guide model tool selection. */ + examples?: string[]; +}