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:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user