feat(api): add audit helpers, tool registry, shared tool manifest types, and UI primitives

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-09 21:14:26 +02:00
parent 1a67af6761
commit 43de66e982
8 changed files with 904 additions and 0 deletions
+29
View File
@@ -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 (
<span
className={clsx(
"inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium",
variantClasses[variant],
className,
)}
>
{children}
</span>
);
}
+47
View File
@@ -0,0 +1,47 @@
import { clsx } from "clsx";
import type { ButtonHTMLAttributes } from "react";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
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 (
<button
className={clsx(
"inline-flex items-center justify-center font-medium rounded-lg transition-all duration-75",
"focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2",
"active:scale-[0.97]",
"disabled:opacity-50 disabled:cursor-not-allowed",
variantClasses[variant],
sizeClasses[size],
className,
)}
disabled={disabled}
{...props}
>
{children}
</button>
);
}
+72
View File
@@ -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<T>(
key: string,
defaultValue: T,
changeEventName?: string,
): [T, (updater: T | ((prev: T) => T)) => void] {
const [value, setValue] = useState<T>(() => 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<T>).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<T>(changeEventName, { detail: next }));
}
}
return next;
});
},
[key, changeEventName],
);
return [value, set];
}
function read<T>(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<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
}
@@ -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<typeof import("@capakraken/application")>();
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<ReturnType<typeof listAssignmentBookings>>);
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");
});
});
+34
View File
@@ -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<string, unknown>;
after?: Record<string, unknown>;
summary?: string;
metadata?: Record<string, unknown>;
}
/**
* 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 });
};
}
@@ -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<string, ToolManifest> = 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);
}
@@ -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");
@@ -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[];
}