refactor(api): extract assistant vacation entitlement slice

This commit is contained in:
2026-03-30 23:09:32 +02:00
parent 45c25b17c1
commit bcfb18393e
3 changed files with 515 additions and 367 deletions
@@ -0,0 +1,496 @@
import { VacationType } from "@capakraken/db";
import { SystemRole } from "@capakraken/shared";
import type { TRPCContext } from "../../trpc.js";
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
type AssistantToolErrorResult = { error: string };
type ResolvedResource = {
id: string;
displayName: string;
};
type VacationRecord = {
id: string;
status: string;
effectiveDays?: number | null;
warnings?: unknown;
resource?: {
displayName?: string | null;
} | null;
};
type PendingVacationApproval = {
id: string;
resource: {
displayName: string;
eid: string | null;
chapter: string | null;
};
type: string;
startDate: Date;
endDate: Date;
isHalfDay: boolean;
};
type VacationEntitlementDeps = {
createVacationCaller: (ctx: TRPCContext) => {
create: (params: {
resourceId: string;
type: VacationType;
startDate: Date;
endDate: Date;
isHalfDay?: boolean;
halfDayPart?: "MORNING" | "AFTERNOON";
note?: string;
}) => Promise<VacationRecord>;
getById: (params: { id: string }) => Promise<VacationRecord>;
approve: (params: { id: string }) => Promise<VacationRecord>;
reject: (params: { id: string; rejectionReason?: string }) => Promise<VacationRecord>;
cancel: (params: { id: string }) => Promise<VacationRecord>;
getPendingApprovals: () => Promise<PendingVacationApproval[]>;
getTeamOverlapDetail: (params: {
resourceId: string;
startDate: Date;
endDate: Date;
}) => Promise<unknown>;
};
createEntitlementCaller: (ctx: TRPCContext) => {
getYearSummaryDetail: (params: {
year: number;
resourceName?: string;
}) => Promise<unknown>;
set: (params: {
resourceId: string;
year: number;
entitledDays: number;
}) => Promise<unknown>;
};
createScopedCallerContext: (ctx: ToolContext) => TRPCContext;
resolveResourceIdentifier: (
ctx: ToolContext,
identifier: string,
) => Promise<ResolvedResource | AssistantToolErrorResult>;
parseIsoDate: (value: string, fieldName: string) => Date;
fmtDate: (value: Date | null | undefined) => string | null;
parseAssistantVacationRequestType: (input: string) => VacationType;
toAssistantVacationCreationError: (
error: unknown,
) => AssistantToolErrorResult | null;
toAssistantVacationMutationError: (
error: unknown,
action: "approve" | "reject" | "cancel",
) => AssistantToolErrorResult | null;
toAssistantEntitlementMutationError: (
error: unknown,
) => AssistantToolErrorResult | null;
};
export const vacationEntitlementToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
name: "create_vacation",
description: "Create a vacation/leave request through the real vacation workflow. Any authenticated user can request leave for their own resource; manager/admin can create requests for others. Always confirm with the user.",
parameters: {
type: "object",
properties: {
resourceId: { type: "string", description: "Resource ID, EID, or display name" },
type: {
type: "string",
enum: ["ANNUAL", "SICK", "OTHER"],
description: "Vacation type. PUBLIC_HOLIDAY requests are managed through holiday calendars, not manual vacation requests.",
},
startDate: { type: "string", description: "Start date YYYY-MM-DD" },
endDate: { type: "string", description: "End date YYYY-MM-DD" },
isHalfDay: { type: "boolean", description: "Half day? Default: false" },
halfDayPart: { type: "string", description: "MORNING or AFTERNOON (if half day)" },
note: { type: "string", description: "Optional note" },
},
required: ["resourceId", "type", "startDate", "endDate"],
},
},
},
{
type: "function",
function: {
name: "approve_vacation",
description: "Approve a vacation request through the real vacation workflow. Manager or admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
vacationId: { type: "string", description: "Vacation ID" },
},
required: ["vacationId"],
},
},
},
{
type: "function",
function: {
name: "reject_vacation",
description: "Reject a pending vacation request through the real vacation workflow. Manager or admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
vacationId: { type: "string", description: "Vacation ID" },
reason: { type: "string", description: "Rejection reason" },
},
required: ["vacationId"],
},
},
},
{
type: "function",
function: {
name: "cancel_vacation",
description: "Cancel a vacation request through the real vacation workflow. Users can cancel their own requests; manager/admin can cancel any request. Always confirm first.",
parameters: {
type: "object",
properties: {
vacationId: { type: "string", description: "Vacation ID" },
},
required: ["vacationId"],
},
},
},
{
type: "function",
function: {
name: "get_pending_vacation_approvals",
description: "List vacation requests awaiting approval.",
parameters: {
type: "object",
properties: {
limit: { type: "integer", description: "Max results. Default: 20" },
},
},
},
},
{
type: "function",
function: {
name: "get_team_vacation_overlap",
description: "Check if team members have overlapping vacations in a date range.",
parameters: {
type: "object",
properties: {
resourceId: { type: "string", description: "Resource ID to check overlap for" },
startDate: { type: "string", description: "Start date YYYY-MM-DD" },
endDate: { type: "string", description: "End date YYYY-MM-DD" },
},
required: ["resourceId", "startDate", "endDate"],
},
},
},
{
type: "function",
function: {
name: "get_entitlement_summary",
description: "Get vacation entitlement year summary for all resources or a specific resource.",
parameters: {
type: "object",
properties: {
year: { type: "integer", description: "Year. Default: current year" },
resourceName: { type: "string", description: "Filter by resource name (optional)" },
},
},
},
},
{
type: "function",
function: {
name: "set_entitlement",
description: "Set the annual vacation entitlement for a resource/year through the real entitlement workflow. Manager or admin role required. Carryover is computed automatically. Always confirm first.",
parameters: {
type: "object",
properties: {
resourceId: { type: "string", description: "Resource ID, EID, or display name" },
year: { type: "integer", description: "Year" },
entitledDays: { type: "number", description: "Number of entitled vacation days" },
},
required: ["resourceId", "year", "entitledDays"],
},
},
},
], {
approve_vacation: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
},
reject_vacation: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
},
get_pending_vacation_approvals: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
},
get_entitlement_summary: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
},
set_entitlement: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
},
});
export function createVacationEntitlementExecutors(
deps: VacationEntitlementDeps,
): Record<string, ToolExecutor> {
return {
async create_vacation(
params: {
resourceId: string;
type: string;
startDate: string;
endDate: string;
isHalfDay?: boolean;
halfDayPart?: string;
note?: string;
},
ctx: ToolContext,
) {
const resource = await deps.resolveResourceIdentifier(ctx, params.resourceId);
if ("error" in resource) {
return resource;
}
const caller = deps.createVacationCaller(deps.createScopedCallerContext(ctx));
const type = deps.parseAssistantVacationRequestType(params.type);
let vacation;
try {
vacation = await caller.create({
resourceId: resource.id,
type,
startDate: deps.parseIsoDate(params.startDate, "startDate"),
endDate: deps.parseIsoDate(params.endDate, "endDate"),
...(params.isHalfDay !== undefined ? { isHalfDay: params.isHalfDay } : {}),
...(params.halfDayPart !== undefined ? { halfDayPart: params.halfDayPart as "MORNING" | "AFTERNOON" } : {}),
...(params.note !== undefined ? { note: params.note } : {}),
});
} catch (error) {
const mapped = deps.toAssistantVacationCreationError(error);
if (mapped) {
return mapped;
}
throw error;
}
const effectiveDays = typeof vacation.effectiveDays === "number"
? vacation.effectiveDays
: null;
return {
__action: "invalidate",
scope: ["vacation"],
success: true,
message: `Created ${type} for ${resource.displayName}: ${params.startDate} to ${params.endDate} (status: ${vacation.status}${effectiveDays !== null ? `, deducted ${effectiveDays} day(s)` : ""})`,
vacationId: vacation.id,
vacation,
};
},
async approve_vacation(
params: { vacationId: string },
ctx: ToolContext,
) {
const caller = deps.createVacationCaller(deps.createScopedCallerContext(ctx));
let existing;
try {
existing = await caller.getById({ id: params.vacationId });
} catch (error) {
const mapped = deps.toAssistantVacationMutationError(error, "approve");
if (mapped) {
return mapped;
}
throw error;
}
let approved;
try {
approved = await caller.approve({ id: params.vacationId });
} catch (error) {
const mapped = deps.toAssistantVacationMutationError(error, "approve");
if (mapped) {
return mapped;
}
throw error;
}
return {
__action: "invalidate",
scope: ["vacation"],
success: true,
warnings: approved.warnings,
vacation: approved,
message: `Approved vacation for ${existing.resource?.displayName ?? params.vacationId}`,
};
},
async reject_vacation(
params: { vacationId: string; reason?: string },
ctx: ToolContext,
) {
const caller = deps.createVacationCaller(deps.createScopedCallerContext(ctx));
let existing;
try {
existing = await caller.getById({ id: params.vacationId });
} catch (error) {
const mapped = deps.toAssistantVacationMutationError(error, "reject");
if (mapped) {
return mapped;
}
throw error;
}
let rejected;
try {
rejected = await caller.reject({
id: params.vacationId,
...(params.reason !== undefined ? { rejectionReason: params.reason } : {}),
});
} catch (error) {
const mapped = deps.toAssistantVacationMutationError(error, "reject");
if (mapped) {
return mapped;
}
throw error;
}
return {
__action: "invalidate",
scope: ["vacation"],
success: true,
vacation: rejected,
message: `Rejected vacation for ${existing.resource?.displayName ?? params.vacationId}${params.reason ? `: ${params.reason}` : ""}`,
};
},
async cancel_vacation(
params: { vacationId: string },
ctx: ToolContext,
) {
const caller = deps.createVacationCaller(deps.createScopedCallerContext(ctx));
let existing;
try {
existing = await caller.getById({ id: params.vacationId });
} catch (error) {
const mapped = deps.toAssistantVacationMutationError(error, "cancel");
if (mapped) {
return mapped;
}
throw error;
}
let cancelled;
try {
cancelled = await caller.cancel({ id: params.vacationId });
} catch (error) {
const mapped = deps.toAssistantVacationMutationError(error, "cancel");
if (mapped) {
return mapped;
}
throw error;
}
return {
__action: "invalidate",
scope: ["vacation"],
success: true,
vacation: cancelled,
message: `Cancelled vacation for ${existing.resource?.displayName ?? params.vacationId}`,
};
},
async get_pending_vacation_approvals(
params: { limit?: number },
ctx: ToolContext,
) {
const limit = Math.min(params.limit ?? 20, 50);
const caller = deps.createVacationCaller(deps.createScopedCallerContext(ctx));
const vacations = await caller.getPendingApprovals();
return vacations.map((vacation) => ({
id: vacation.id,
resource: vacation.resource.displayName,
eid: vacation.resource.eid,
chapter: vacation.resource.chapter,
type: vacation.type,
start: deps.fmtDate(vacation.startDate),
end: deps.fmtDate(vacation.endDate),
isHalfDay: vacation.isHalfDay,
})).slice(0, limit);
},
async get_team_vacation_overlap(
params: { resourceId: string; startDate: string; endDate: string },
ctx: ToolContext,
) {
const resource = await deps.resolveResourceIdentifier(ctx, params.resourceId);
if ("error" in resource) {
return resource;
}
const caller = deps.createVacationCaller(deps.createScopedCallerContext(ctx));
return caller.getTeamOverlapDetail({
resourceId: resource.id,
startDate: deps.parseIsoDate(params.startDate, "startDate"),
endDate: deps.parseIsoDate(params.endDate, "endDate"),
});
},
async get_entitlement_summary(
params: { year?: number; resourceName?: string },
ctx: ToolContext,
) {
const year = params.year ?? new Date().getFullYear();
const caller = deps.createEntitlementCaller(deps.createScopedCallerContext(ctx));
return caller.getYearSummaryDetail({
year,
...(params.resourceName ? { resourceName: params.resourceName } : {}),
});
},
async set_entitlement(
params: {
resourceId: string;
year: number;
entitledDays: number;
carryoverDays?: number;
},
ctx: ToolContext,
) {
if (params.carryoverDays !== undefined) {
return {
error: "Manual carryoverDays is not supported here. Carryover is computed automatically from prior-year balances.",
};
}
const resource = await deps.resolveResourceIdentifier(ctx, params.resourceId);
if ("error" in resource) {
return resource;
}
const caller = deps.createEntitlementCaller(deps.createScopedCallerContext(ctx));
let entitlement;
try {
entitlement = await caller.set({
resourceId: resource.id,
year: params.year,
entitledDays: params.entitledDays,
});
} catch (error) {
const mapped = deps.toAssistantEntitlementMutationError(error);
if (mapped) {
return mapped;
}
throw error;
}
return {
__action: "invalidate",
scope: ["vacation"],
success: true,
entitlement,
message: `Set entitlement for ${resource.displayName} (${params.year}): ${params.entitledDays} days`,
};
},
};
}