fix(types): flatten tRPC Zod schema types to resolve TS2589 inference depth errors

Cast Zod schemas with .refine()/.superRefine() to z.ZodType<InferredType> at the
procedure level. This short-circuits TypeScript's deep type recursion through
tRPC's middleware chain, eliminating 4 of 5 @ts-expect-error TS2589 suppressions
in web components (VacationModal, ProjectModal, UsersClient, CountriesClient).

Applied same pattern to allocation, timeline, staffing, dashboard, project, and
resource query/mutation procedures to reduce client-side type depth.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-10 15:28:12 +02:00
parent 0d79f97d7a
commit 9bd3781c03
21 changed files with 460 additions and 304 deletions
@@ -1,6 +1,7 @@
import { rankResources } from "@capakraken/staffing";
import { listAssignmentBookings } from "@capakraken/application";
import { PermissionKey, toIsoDateOrNull, type WeekdayAvailability } from "@capakraken/shared";
import { PermissionKey, toIsoDateOrNull } from "@capakraken/shared";
import type { SkillEntry, WeekdayAvailability } from "@capakraken/shared";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { loadResourceDailyAvailabilityContexts } from "../lib/resource-capacity.js";
@@ -30,10 +31,8 @@ type StaffingSuggestionInput = {
minProficiency?: number | undefined;
};
type StaffingSuggestionsDbClient =
Parameters<typeof listAssignmentBookings>[0]
& Parameters<typeof loadResourceDailyAvailabilityContexts>[0]
& {
type StaffingSuggestionsDbClient = Parameters<typeof listAssignmentBookings>[0] &
Parameters<typeof loadResourceDailyAvailabilityContexts>[0] & {
resource: {
findMany: (args: Record<string, unknown>) => Promise<unknown[]>;
};
@@ -77,7 +76,7 @@ async function queryStaffingSuggestions(
mainSkillsOnly,
minProficiency,
} = input;
const resources = await db.resource.findMany({
const resources = (await db.resource.findMany({
where: {
isActive: true,
...(chapter ? { chapter } : {}),
@@ -100,7 +99,7 @@ async function queryStaffingSuggestions(
metroCity: { select: { name: true } },
areaRole: { select: { name: true } },
},
}) as StaffingResourceRecord[];
})) as StaffingResourceRecord[];
const bookings = await listAssignmentBookings(db, {
startDate,
endDate,
@@ -133,7 +132,9 @@ async function queryStaffingSuggestions(
const availability = resource.availability as unknown as WeekdayAvailability;
const context = contexts.get(resource.id);
const resourceBookings = bookingsByResourceId.get(resource.id) ?? [];
const activeBookings = resourceBookings.filter((booking) => ACTIVE_STATUSES.has(booking.status));
const activeBookings = resourceBookings.filter((booking) =>
ACTIVE_STATUSES.has(booking.status),
);
const capacity = buildResourceCapacitySummary({
availability,
periodStart: startDate,
@@ -192,13 +193,17 @@ async function queryStaffingSuggestions(
}
const allocatedHours = capacity.bookedHours;
const remainingHours = capacity.remainingHours;
const remainingHoursPerDay = capacity.remainingHoursPerDay;
const utilizationPercent =
capacity.availableHours > 0
? Math.min(100, (allocatedHours / capacity.availableHours) * 100)
: 0;
type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean };
type SkillRow = {
skill: string;
category?: string;
proficiency: number;
isMainSkill?: boolean;
};
let skills = resource.skills as unknown as SkillRow[];
if (mainSkillsOnly) {
skills = skills.filter((skill) => skill.isMainSkill);
@@ -217,7 +222,7 @@ async function queryStaffingSuggestions(
fte: resource.fte,
chapter: resource.chapter,
role: resource.areaRole?.name ?? null,
skills: skills as unknown as import("@capakraken/shared").SkillEntry[],
skills: skills as unknown as SkillEntry[],
lcrCents: resource.lcrCents,
chargeabilityTarget: resource.chargeabilityTarget,
currentUtilizationPercent: utilizationPercent,
@@ -267,97 +272,138 @@ async function queryStaffingSuggestions(
budgetLcrCentsPerHour,
} as unknown as Parameters<typeof rankResources>[0]);
const baseRankIndex = new Map(ranked.map((suggestion, index) => [suggestion.resourceId, index]));
return [...ranked].sort((left, right) => {
if (Math.abs(left.score - right.score) <= 2) {
const leftValue = enrichedResources.find((resource) => resource.id === left.resourceId)?.valueScore ?? 0;
const rightValue = enrichedResources.find((resource) => resource.id === right.resourceId)?.valueScore ?? 0;
return rightValue - leftValue;
}
return 0;
}).map((suggestion, index) => {
const resource = enrichedResources.find((entry) => entry.id === suggestion.resourceId);
const fallbackBreakdown = "breakdown" in suggestion
? (suggestion as { breakdown?: { skillScore: number; availabilityScore: number; costScore: number; utilizationScore: number } }).breakdown
: undefined;
const scoreBreakdown = suggestion.scoreBreakdown ?? {
skillScore: fallbackBreakdown?.skillScore ?? 0,
availabilityScore: fallbackBreakdown?.availabilityScore ?? 0,
costScore: fallbackBreakdown?.costScore ?? 0,
utilizationScore: fallbackBreakdown?.utilizationScore ?? 0,
total: suggestion.score,
};
const baseRank = (baseRankIndex.get(suggestion.resourceId) ?? index) + 1;
const tieBreakerApplied = baseRank !== index + 1;
return [...ranked]
.sort((left, right) => {
if (Math.abs(left.score - right.score) <= 2) {
const leftValue =
enrichedResources.find((resource) => resource.id === left.resourceId)?.valueScore ?? 0;
const rightValue =
enrichedResources.find((resource) => resource.id === right.resourceId)?.valueScore ?? 0;
return rightValue - leftValue;
}
return 0;
})
.map((suggestion, index) => {
const resource = enrichedResources.find((entry) => entry.id === suggestion.resourceId);
const fallbackBreakdown =
"breakdown" in suggestion
? (
suggestion as {
breakdown?: {
skillScore: number;
availabilityScore: number;
costScore: number;
utilizationScore: number;
};
}
).breakdown
: undefined;
const scoreBreakdown = suggestion.scoreBreakdown ?? {
skillScore: fallbackBreakdown?.skillScore ?? 0,
availabilityScore: fallbackBreakdown?.availabilityScore ?? 0,
costScore: fallbackBreakdown?.costScore ?? 0,
utilizationScore: fallbackBreakdown?.utilizationScore ?? 0,
total: suggestion.score,
};
const baseRank = (baseRankIndex.get(suggestion.resourceId) ?? index) + 1;
const tieBreakerApplied = baseRank !== index + 1;
return {
...suggestion,
resourceName: suggestion.resourceName ?? resource?.displayName ?? "",
eid: suggestion.eid ?? resource?.eid ?? "",
fte: resource?.fte ?? 0,
chapter: resource?.chapter ?? null,
role: resource?.role ?? null,
scoreBreakdown,
matchedSkills: suggestion.matchedSkills ?? requiredSkills.filter((skill) =>
resource?.skills.some((entry) => entry.skill.toLowerCase() === skill.trim().toLowerCase()),
),
missingSkills: suggestion.missingSkills ?? requiredSkills.filter((skill) =>
!resource?.skills.some((entry) => entry.skill.toLowerCase() === skill.trim().toLowerCase()),
),
availabilityConflicts: suggestion.availabilityConflicts ?? resource?.conflictDays ?? [],
estimatedDailyCostCents: suggestion.estimatedDailyCostCents ?? ((resource?.lcrCents ?? 0) * 8),
currentUtilization: suggestion.currentUtilization ?? round1(resource?.currentUtilizationPercent ?? 0),
valueScore: resource?.valueScore ?? 0,
location: resource?.transparency.location ?? {
countryCode: null,
countryName: null,
federalState: null,
metroCityName: null,
label: "",
},
capacity: resource?.transparency.capacity ?? {
requestedHoursPerDay: round1(hoursPerDay),
requestedHoursTotal: 0,
baseWorkingDays: 0,
effectiveWorkingDays: 0,
baseAvailableHours: 0,
effectiveAvailableHours: 0,
bookedHours: 0,
remainingHours: 0,
remainingHoursPerDay: 0,
holidayCount: 0,
holidayWorkdayCount: 0,
holidayHoursDeduction: 0,
absenceDayEquivalent: 0,
absenceHoursDeduction: 0,
},
conflicts: resource?.transparency.conflicts ?? {
count: 0,
conflictDays: [],
details: [],
},
ranking: {
rank: index + 1,
baseRank,
tieBreakerApplied,
tieBreakerReason: tieBreakerApplied
? "Within 2 score points, higher value score moves the candidate up."
: null,
model: "Composite ranking across skill fit, availability, cost, and utilization.",
components: [
{ key: "skillScore", label: "Skills", score: scoreBreakdown.skillScore },
{ key: "availabilityScore", label: "Availability", score: scoreBreakdown.availabilityScore },
{ key: "costScore", label: "Cost", score: scoreBreakdown.costScore },
{ key: "utilizationScore", label: "Utilization", score: scoreBreakdown.utilizationScore },
],
},
remainingHoursPerDay: resource?.transparency.capacity.remainingHoursPerDay ?? 0,
remainingHours: resource?.transparency.capacity.remainingHours ?? 0,
effectiveAvailableHours: resource?.transparency.capacity.effectiveAvailableHours ?? 0,
baseAvailableHours: resource?.transparency.capacity.baseAvailableHours ?? 0,
holidayHoursDeduction: resource?.transparency.capacity.holidayHoursDeduction ?? 0,
};
});
return {
...suggestion,
resourceName: suggestion.resourceName ?? resource?.displayName ?? "",
eid: suggestion.eid ?? resource?.eid ?? "",
fte: resource?.fte ?? 0,
chapter: resource?.chapter ?? null,
role: resource?.role ?? null,
scoreBreakdown,
matchedSkills:
suggestion.matchedSkills ??
requiredSkills.filter((skill) =>
resource?.skills.some(
(entry) => entry.skill.toLowerCase() === skill.trim().toLowerCase(),
),
),
missingSkills:
suggestion.missingSkills ??
requiredSkills.filter(
(skill) =>
!resource?.skills.some(
(entry) => entry.skill.toLowerCase() === skill.trim().toLowerCase(),
),
),
availabilityConflicts: suggestion.availabilityConflicts ?? resource?.conflictDays ?? [],
estimatedDailyCostCents:
suggestion.estimatedDailyCostCents ?? (resource?.lcrCents ?? 0) * 8,
currentUtilization:
suggestion.currentUtilization ?? round1(resource?.currentUtilizationPercent ?? 0),
valueScore: resource?.valueScore ?? 0,
location: resource?.transparency.location ?? {
countryCode: null,
countryName: null,
federalState: null,
metroCityName: null,
label: "",
},
capacity: resource?.transparency.capacity ?? {
requestedHoursPerDay: round1(hoursPerDay),
requestedHoursTotal: 0,
baseWorkingDays: 0,
effectiveWorkingDays: 0,
baseAvailableHours: 0,
effectiveAvailableHours: 0,
bookedHours: 0,
remainingHours: 0,
remainingHoursPerDay: 0,
holidayCount: 0,
holidayWorkdayCount: 0,
holidayHoursDeduction: 0,
absenceDayEquivalent: 0,
absenceHoursDeduction: 0,
},
conflicts: resource?.transparency.conflicts ?? {
count: 0,
conflictDays: [],
details: [],
},
ranking: {
rank: index + 1,
baseRank,
tieBreakerApplied,
tieBreakerReason: tieBreakerApplied
? "Within 2 score points, higher value score moves the candidate up."
: null,
model: "Composite ranking across skill fit, availability, cost, and utilization.",
components: [
{ key: "skillScore", label: "Skills", score: scoreBreakdown.skillScore },
{
key: "availabilityScore",
label: "Availability",
score: scoreBreakdown.availabilityScore,
},
{ key: "costScore", label: "Cost", score: scoreBreakdown.costScore },
{
key: "utilizationScore",
label: "Utilization",
score: scoreBreakdown.utilizationScore,
},
],
},
remainingHoursPerDay: resource?.transparency.capacity.remainingHoursPerDay ?? 0,
remainingHours: resource?.transparency.capacity.remainingHours ?? 0,
effectiveAvailableHours: resource?.transparency.capacity.effectiveAvailableHours ?? 0,
baseAvailableHours: resource?.transparency.capacity.baseAvailableHours ?? 0,
holidayHoursDeduction: resource?.transparency.capacity.holidayHoursDeduction ?? 0,
};
});
}
const GetProjectStaffingSuggestionsInputSchema = z.object({
projectId: z.string().min(1),
roleName: z.string().optional(),
startDate: z.coerce.date().optional(),
endDate: z.coerce.date().optional(),
limit: z.number().int().min(1).max(50).optional().default(5),
});
export const staffingSuggestionsReadProcedures = {
getSuggestions: planningReadProcedure
.input(
@@ -380,35 +426,39 @@ export const staffingSuggestionsReadProcedures = {
}),
getProjectStaffingSuggestions: planningReadProcedure
.input(
z.object({
projectId: z.string().min(1),
roleName: z.string().optional(),
startDate: z.coerce.date().optional(),
endDate: z.coerce.date().optional(),
limit: z.number().int().min(1).max(50).optional().default(5),
}),
GetProjectStaffingSuggestionsInputSchema as z.ZodType<
z.infer<typeof GetProjectStaffingSuggestionsInputSchema>,
z.ZodTypeDef,
z.input<typeof GetProjectStaffingSuggestionsInputSchema>
>,
)
.query(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.VIEW_COSTS);
const project = await findUniqueOrThrow(ctx.db.project.findUnique({
where: { id: input.projectId },
select: {
id: true,
shortCode: true,
name: true,
startDate: true,
endDate: true,
},
}), "Project");
const project = await findUniqueOrThrow(
ctx.db.project.findUnique({
where: { id: input.projectId },
select: {
id: true,
shortCode: true,
name: true,
startDate: true,
endDate: true,
},
}),
"Project",
);
const startDate = input.startDate ?? project.startDate ?? new Date();
const endDate = input.endDate ?? project.endDate ?? new Date();
const normalizedRoleFilter = input.roleName?.trim().toLowerCase();
const suggestions = await queryStaffingSuggestions(ctx.db as unknown as StaffingSuggestionsDbClient, {
requiredSkills: [],
startDate,
endDate,
hoursPerDay: 8,
});
const suggestions = await queryStaffingSuggestions(
ctx.db as unknown as StaffingSuggestionsDbClient,
{
requiredSkills: [],
startDate,
endDate,
hoursPerDay: 8,
},
);
return {
project: `${project.name} (${project.shortCode})`,
period: `${toIsoDateOrNull(startDate)} to ${toIsoDateOrNull(endDate)}`,