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
@@ -71,7 +71,7 @@ export function CountriesClient() {
const utils = trpc.useUtils();
const { data: countries, isLoading } = trpc.country.list.useQuery();
// @ts-expect-error TS2589: tRPC infers union type too deeply for nullable JSONB scheduleRules schema
// @ts-expect-error TS2589: tRPC type instantiation depth — intermittent with country schema flattening
const createMut = trpc.country.create.useMutation({
onSuccess: () => {
void utils.country.list.invalidate();
@@ -102,7 +102,6 @@ export function SystemRolesClient() {
staleTime: 10_000,
});
// @ts-expect-error TS2589: tRPC infers union type too deeply for the role config update payload
const updateMutation = trpc.systemRoleConfig.update.useMutation({
onSuccess: async () => {
await utils.systemRoleConfig.list.invalidate();
@@ -155,7 +155,6 @@ export function UsersClient() {
onError: (err) => setActionError(err.message),
});
// @ts-expect-error TS2589: tRPC infers union type too deeply for nullable overrides schema
const setPermissionsMutation = trpc.user.setPermissions.useMutation({
onSuccess: async () => {
await utils.user.list.invalidate();
@@ -263,7 +263,8 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
if (actions) {
for (const action of actions) {
if (action.type === "navigate" && action.url) {
router.push(action.url as string & {});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
router.push(action.url as any);
} else if (action.type === "invalidate" && action.scope) {
// Invalidate relevant tRPC queries so the UI refreshes
for (const scope of action.scope) {
@@ -109,7 +109,6 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
});
const { data: clientList } = trpc.clientEntity.list.useQuery(undefined, { staleTime: 60_000 });
// @ts-expect-error TS2589: tRPC infers union type too deeply for CreateProjectSchema with .refine()
const createMutation = trpc.project.create.useMutation({
onSuccess: async () => {
await utils.project.listWithCosts.invalidate();
@@ -142,7 +142,6 @@ export function VacationModal({
const utils = trpc.useUtils();
// @ts-expect-error TS2589: tRPC infers union type too deeply for CreateVacationRequestSchema with .superRefine()
const createMutation = trpc.vacation.create.useMutation({
onSuccess: async () => {
await utils.vacation.list.invalidate();
@@ -1,7 +1,8 @@
import { validateAvailability } from "@capakraken/engine";
import {
type AllocationConflictCheckResult,
type WeekdayAvailability,
import type {
AllocationConflictCheckResult,
AllocationStatus,
WeekdayAvailability,
} from "@capakraken/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
@@ -24,7 +25,7 @@ export const allocationConflictProcedures = {
* Read-only — no mutations.
*/
checkConflicts: managerProcedure
.input(CheckConflictsInputSchema)
.input(CheckConflictsInputSchema as z.ZodType<z.infer<typeof CheckConflictsInputSchema>>)
.query(async ({ ctx, input }): Promise<AllocationConflictCheckResult> => {
const resource = await ctx.db.resource.findUnique({
where: { id: input.resourceId },
@@ -39,8 +40,7 @@ export const allocationConflictProcedures = {
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
}
const fallbackDailyHours =
(resource.country?.dailyWorkingHours ?? 8) * (resource.fte ?? 1);
const fallbackDailyHours = (resource.country?.dailyWorkingHours ?? 8) * (resource.fte ?? 1);
const availability = (resource.availability as WeekdayAvailability | null) ?? {
monday: fallbackDailyHours,
tuesday: fallbackDailyHours,
@@ -82,7 +82,12 @@ export const allocationConflictProcedures = {
input.endDate,
input.hoursPerDay,
availability,
existingAssignments as { startDate: Date; endDate: Date; hoursPerDay: number; status: import("@capakraken/shared").AllocationStatus }[],
existingAssignments as {
startDate: Date;
endDate: Date;
hoursPerDay: number;
status: AllocationStatus;
}[],
);
// Compute max overbook percentage for the worst day
@@ -90,9 +95,7 @@ export const allocationConflictProcedures = {
for (const conflict of availabilityResult.conflicts) {
const totalBooked = conflict.existingHours + conflict.requestedHours;
const overbookPct =
conflict.availableHours > 0
? ((totalBooked / conflict.availableHours) - 1) * 100
: 100;
conflict.availableHours > 0 ? (totalBooked / conflict.availableHours - 1) * 100 : 100;
if (overbookPct > maxOverbookPercent) maxOverbookPercent = overbookPct;
}
@@ -1,7 +1,4 @@
import {
createAssignment,
updateAssignment,
} from "@capakraken/application";
import { createAssignment, updateAssignment } from "@capakraken/application";
import {
AllocationStatus,
CreateAllocationSchema,
@@ -10,6 +7,12 @@ import {
UpdateAllocationSchema,
UpdateAssignmentSchema,
} from "@capakraken/shared";
import type {
CreateAllocationInput,
CreateAssignmentInput,
UpdateAllocationInput,
UpdateAssignmentInput,
} from "@capakraken/shared";
import { z } from "zod";
import { findUniqueOrThrow } from "../../db/helpers.js";
import {
@@ -28,40 +31,36 @@ import {
ensureAssignmentRecord,
updateAllocationWithAudit,
} from "./assignment-mutations.js";
import {
managerProcedure,
requirePermission,
} from "../../trpc.js";
import { managerProcedure, requirePermission } from "../../trpc.js";
export const allocationAssignmentProcedures = {
create: managerProcedure
.input(CreateAllocationSchema)
.input(CreateAllocationSchema as z.ZodType<CreateAllocationInput>)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const allocation = await ctx.db.$transaction(async (tx) =>
createAllocationReadModelEntry(
tx as Parameters<typeof createAssignment>[0],
input,
));
createAllocationReadModelEntry(tx as Parameters<typeof createAssignment>[0], input),
);
publishAllocationCreated(ctx.db, {
id: allocation.id,
projectId: allocation.projectId,
resourceId: allocation.resourceId,
}, { dispatchWebhook: true });
publishAllocationCreated(
ctx.db,
{
id: allocation.id,
projectId: allocation.projectId,
resourceId: allocation.resourceId,
},
{ dispatchWebhook: true },
);
return allocation;
}),
createAssignment: managerProcedure
.input(CreateAssignmentSchema)
.input(CreateAssignmentSchema as z.ZodType<CreateAssignmentInput>)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const assignment = await ctx.db.$transaction(async (tx) => {
return createAssignment(
tx as unknown as Parameters<typeof createAssignment>[0],
input,
);
return createAssignment(tx as unknown as Parameters<typeof createAssignment>[0], input);
});
publishAllocationCreated(ctx.db, {
@@ -74,14 +73,16 @@ export const allocationAssignmentProcedures = {
}),
ensureAssignment: managerProcedure
.input(z.object({
resourceId: z.string(),
projectId: z.string(),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
hoursPerDay: z.number().min(0.5).max(24),
role: z.string().optional(),
}))
.input(
z.object({
resourceId: z.string(),
projectId: z.string(),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
hoursPerDay: z.number().min(0.5).max(24),
role: z.string().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const result = await ensureAssignmentRecord(ctx.db, {
@@ -94,11 +95,15 @@ export const allocationAssignmentProcedures = {
});
if (result.action === "reactivated") {
publishAllocationUpdated(ctx.db, {
id: result.assignment.id,
projectId: result.assignment.projectId,
resourceId: result.assignment.resourceId,
}, { dispatchWebhook: true });
publishAllocationUpdated(
ctx.db,
{
id: result.assignment.id,
projectId: result.assignment.projectId,
resourceId: result.assignment.resourceId,
},
{ dispatchWebhook: true },
);
return result;
}
@@ -113,7 +118,12 @@ export const allocationAssignmentProcedures = {
}),
updateAssignment: managerProcedure
.input(z.object({ id: z.string(), data: UpdateAssignmentSchema }))
.input(
z.object({ id: z.string(), data: UpdateAssignmentSchema }) as z.ZodType<{
id: string;
data: UpdateAssignmentInput;
}>,
)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const existing = await findUniqueOrThrow(
@@ -132,18 +142,27 @@ export const allocationAssignmentProcedures = {
);
});
publishAllocationUpdated(ctx.db, {
id: updated.id,
projectId: updated.projectId,
resourceId: updated.resourceId,
resourceIds: [existing.resourceId, updated.resourceId],
}, { dispatchWebhook: true });
publishAllocationUpdated(
ctx.db,
{
id: updated.id,
projectId: updated.projectId,
resourceId: updated.resourceId,
resourceIds: [existing.resourceId, updated.resourceId],
},
{ dispatchWebhook: true },
);
return updated;
}),
update: managerProcedure
.input(z.object({ id: z.string(), data: UpdateAllocationSchema }))
.input(
z.object({ id: z.string(), data: UpdateAllocationSchema }) as z.ZodType<{
id: string;
data: UpdateAllocationInput;
}>,
)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const { existing, updated } = await updateAllocationWithAudit(ctx.db, input.id, input.data);
@@ -177,20 +196,18 @@ export const allocationAssignmentProcedures = {
return { success: true };
}),
delete: managerProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const existing = await deleteAllocationWithAudit(ctx.db, input.id);
delete: managerProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const existing = await deleteAllocationWithAudit(ctx.db, input.id);
publishAllocationDeleted(ctx.db, {
id: existing.entry.id,
projectId: existing.projectId,
resourceId: existing.entry.resourceId,
});
publishAllocationDeleted(ctx.db, {
id: existing.entry.id,
projectId: existing.projectId,
resourceId: existing.entry.resourceId,
});
return { success: true };
}),
return { success: true };
}),
batchDelete: managerProcedure
.input(z.object({ ids: z.array(z.string()).min(1).max(100) }))
@@ -198,11 +215,14 @@ export const allocationAssignmentProcedures = {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const existing = await batchDeleteAllocationsWithAudit(ctx.db, input.ids);
publishBatchAllocationDeletes(ctx.db, existing.map((allocation) => ({
id: allocation.entry.id,
projectId: allocation.projectId,
resourceId: allocation.entry.resourceId,
})));
publishBatchAllocationDeletes(
ctx.db,
existing.map((allocation) => ({
id: allocation.entry.id,
projectId: allocation.projectId,
resourceId: allocation.entry.resourceId,
})),
);
return { count: existing.length };
}),
@@ -218,11 +238,14 @@ export const allocationAssignmentProcedures = {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const updated = await batchUpdateAllocationStatusWithAudit(ctx.db, input);
publishBatchAllocationStatusUpdates(ctx.db, updated.map((allocation) => ({
id: allocation.id,
projectId: allocation.projectId,
resourceId: allocation.resourceId,
})));
publishBatchAllocationStatusUpdates(
ctx.db,
updated.map((allocation) => ({
id: allocation.id,
projectId: allocation.projectId,
resourceId: allocation.resourceId,
})),
);
return { count: updated.length };
}),
+32 -31
View File
@@ -1,3 +1,4 @@
import type { Prisma } from "@capakraken/db";
import {
deleteDemandRequirement,
fillOpenDemand,
@@ -10,6 +11,11 @@ import {
PermissionKey,
UpdateDemandRequirementSchema,
} from "@capakraken/shared";
import type {
CreateDemandRequirementInput,
FillDemandRequirementInput,
FillOpenDemandByAllocationInput,
} from "@capakraken/shared";
import { z } from "zod";
import { findUniqueOrThrow } from "../../db/helpers.js";
import {
@@ -25,41 +31,34 @@ import {
invalidateDashboardCacheInBackground,
} from "./effects.js";
import { DEMAND_INCLUDE } from "./shared.js";
import {
buildCreateDemandRequirementInput,
getDemandRequirementByIdOrThrow,
} from "./support.js";
import {
managerProcedure,
requirePermission,
} from "../../trpc.js";
import { buildCreateDemandRequirementInput, getDemandRequirementByIdOrThrow } from "./support.js";
import { managerProcedure, requirePermission } from "../../trpc.js";
export const allocationDemandProcedures = {
createDemandRequirement: managerProcedure
.input(CreateDemandRequirementSchema)
.input(CreateDemandRequirementSchema as z.ZodType<CreateDemandRequirementInput>)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
return createDemandRequirementWithEffects(ctx.db, input);
}),
createDemand: managerProcedure
.input(z.object({
projectId: z.string(),
role: z.string().optional(),
roleId: z.string().optional(),
headcount: z.number().int().positive().default(1),
hoursPerDay: z.number().min(0.5).max(24),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
budgetCents: z.number().int().min(0).optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
}))
.input(
z.object({
projectId: z.string(),
role: z.string().optional(),
roleId: z.string().optional(),
headcount: z.number().int().positive().default(1),
hoursPerDay: z.number().min(0.5).max(24),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
budgetCents: z.number().int().min(0).optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
return createDemandRequirementWithEffects(
ctx.db,
buildCreateDemandRequirementInput(input),
);
return createDemandRequirementWithEffects(ctx.db, buildCreateDemandRequirementInput(input));
}),
updateDemandRequirement: managerProcedure
@@ -110,7 +109,7 @@ export const allocationDemandProcedures = {
entityType: "DemandRequirement",
entityId: input.id,
action: "DELETE",
changes: { before: existing } as unknown as import("@capakraken/db").Prisma.InputJsonValue,
changes: { before: existing } as unknown as Prisma.InputJsonValue,
},
});
});
@@ -127,17 +126,19 @@ export const allocationDemandProcedures = {
}),
fillDemandRequirement: managerProcedure
.input(FillDemandRequirementSchema)
.input(FillDemandRequirementSchema as z.ZodType<FillDemandRequirementInput>)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
return fillDemandRequirementWithEffects(ctx.db, input);
}),
assignResourceToDemand: managerProcedure
.input(z.object({
demandRequirementId: z.string(),
resourceId: z.string(),
}))
.input(
z.object({
demandRequirementId: z.string(),
resourceId: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const result = await fillDemandRequirementWithEffects(ctx.db, input);
@@ -153,7 +154,7 @@ export const allocationDemandProcedures = {
}),
fillOpenDemandByAllocation: managerProcedure
.input(FillOpenDemandByAllocationSchema)
.input(FillOpenDemandByAllocationSchema as z.ZodType<FillOpenDemandByAllocationInput>)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
+9 -1
View File
@@ -23,6 +23,8 @@ import {
updateMetroCity,
} from "./country-procedure-support.js";
import { CreateCountrySchema, CreateMetroCitySchema } from "@capakraken/shared";
import type { CreateCountryInput } from "@capakraken/shared";
import type { z } from "zod";
export const countryRouter = createTRPCRouter({
list: protectedProcedure
@@ -46,7 +48,13 @@ export const countryRouter = createTRPCRouter({
.query(({ ctx, input }) => getMetroCityById(ctx, input)),
create: adminProcedure
.input(CreateCountrySchema)
.input(
CreateCountrySchema as z.ZodType<
CreateCountryInput,
z.ZodTypeDef,
z.input<typeof CreateCountrySchema>
>,
)
.mutation(({ ctx, input }) => createCountry(ctx, input)),
update: adminProcedure
+14 -3
View File
@@ -1,3 +1,4 @@
import type { z } from "zod";
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
import {
dashboardChargeabilityOverviewInputSchema,
@@ -34,7 +35,13 @@ export const dashboardRouter = createTRPCRouter({
.query(({ ctx, input }) => getDashboardTopValueResourcesRead(ctx, input)),
getDemand: controllerProcedure
.input(dashboardDemandInputSchema)
.input(
dashboardDemandInputSchema as z.ZodType<
z.infer<typeof dashboardDemandInputSchema>,
z.ZodTypeDef,
z.input<typeof dashboardDemandInputSchema>
>,
)
.query(({ ctx, input }) => getDashboardDemandRead(ctx, input)),
getDetail: controllerProcedure
@@ -47,7 +54,9 @@ export const dashboardRouter = createTRPCRouter({
getBudgetForecast: controllerProcedure.query(({ ctx }) => getDashboardBudgetForecastRead(ctx)),
getBudgetForecastDetail: controllerProcedure.query(({ ctx }) => getDashboardBudgetForecastDetail(ctx)),
getBudgetForecastDetail: controllerProcedure.query(({ ctx }) =>
getDashboardBudgetForecastDetail(ctx),
),
getSkillGaps: controllerProcedure.query(({ ctx }) => getDashboardSkillGapsRead(ctx)),
@@ -55,5 +64,7 @@ export const dashboardRouter = createTRPCRouter({
getProjectHealth: controllerProcedure.query(({ ctx }) => getDashboardProjectHealthRead(ctx)),
getProjectHealthDetail: controllerProcedure.query(({ ctx }) => getDashboardProjectHealthDetail(ctx)),
getProjectHealthDetail: controllerProcedure.query(({ ctx }) =>
getDashboardProjectHealthDetail(ctx),
),
});
+12 -7
View File
@@ -4,13 +4,19 @@ import { z } from "zod";
import { CursorInputSchema, paginateCursor } from "../db/pagination.js";
import { controllerProcedure } from "../trpc.js";
const ListWithCostsInputSchema = CursorInputSchema.extend({
status: z.nativeEnum(ProjectStatus).optional(),
search: z.string().optional(),
});
export const projectCostReadProcedures = {
listWithCosts: controllerProcedure
.input(
CursorInputSchema.extend({
status: z.nativeEnum(ProjectStatus).optional(),
search: z.string().optional(),
}),
ListWithCostsInputSchema as z.ZodType<
z.infer<typeof ListWithCostsInputSchema>,
z.ZodTypeDef,
z.input<typeof ListWithCostsInputSchema>
>,
)
.query(async ({ ctx, input }) => {
const { status, search, cursor } = input;
@@ -58,9 +64,8 @@ export const projectCostReadProcedures = {
totalCostCents += booking.dailyCostCents * days;
totalPersonDays += (booking.hoursPerDay * days) / 8;
}
const utilizationPercent = project.budgetCents > 0
? Math.round((totalCostCents / project.budgetCents) * 100)
: 0;
const utilizationPercent =
project.budgetCents > 0 ? Math.round((totalCostCents / project.budgetCents) * 100) : 0;
return {
...project,
totalCostCents: Math.round(totalCostCents),
+33 -14
View File
@@ -1,4 +1,11 @@
import { BlueprintTarget, CreateProjectSchema, PermissionKey, UpdateProjectSchema } from "@capakraken/shared";
import {
BlueprintTarget,
CreateProjectSchema,
PermissionKey,
UpdateProjectSchema,
} from "@capakraken/shared";
import type { CreateProjectInput } from "@capakraken/shared";
import type { Prisma } from "@capakraken/db";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
@@ -21,10 +28,12 @@ function buildProjectCreateData(
status: input.status,
responsiblePerson: input.responsiblePerson,
...(input.color !== undefined ? { color: input.color } : {}),
staffingReqs: input.staffingReqs as unknown as import("@capakraken/db").Prisma.InputJsonValue,
dynamicFields: input.dynamicFields as unknown as import("@capakraken/db").Prisma.InputJsonValue,
staffingReqs: input.staffingReqs as unknown as Prisma.InputJsonValue,
dynamicFields: input.dynamicFields as unknown as Prisma.InputJsonValue,
blueprintId: input.blueprintId,
...(input.utilizationCategoryId !== undefined ? { utilizationCategoryId: input.utilizationCategoryId || null } : {}),
...(input.utilizationCategoryId !== undefined
? { utilizationCategoryId: input.utilizationCategoryId || null }
: {}),
...(input.clientId !== undefined ? { clientId: input.clientId || null } : {}),
} as unknown as Parameters<TRPCContext["db"]["project"]["create"]>[0]["data"];
}
@@ -41,24 +50,32 @@ function buildProjectUpdateData(
...(input.startDate !== undefined ? { startDate: input.startDate } : {}),
...(input.endDate !== undefined ? { endDate: input.endDate } : {}),
...(input.status !== undefined ? { status: input.status } : {}),
...(input.responsiblePerson !== undefined ? { responsiblePerson: input.responsiblePerson } : {}),
...(input.responsiblePerson !== undefined
? { responsiblePerson: input.responsiblePerson }
: {}),
...(input.color !== undefined ? { color: input.color } : {}),
...(input.staffingReqs !== undefined ? { staffingReqs: input.staffingReqs as unknown as import("@capakraken/db").Prisma.InputJsonValue } : {}),
...(input.dynamicFields !== undefined ? { dynamicFields: input.dynamicFields as unknown as import("@capakraken/db").Prisma.InputJsonValue } : {}),
...(input.staffingReqs !== undefined
? { staffingReqs: input.staffingReqs as unknown as Prisma.InputJsonValue }
: {}),
...(input.dynamicFields !== undefined
? { dynamicFields: input.dynamicFields as unknown as Prisma.InputJsonValue }
: {}),
...(input.blueprintId !== undefined ? { blueprintId: input.blueprintId } : {}),
...(input.utilizationCategoryId !== undefined ? { utilizationCategoryId: input.utilizationCategoryId || null } : {}),
...(input.utilizationCategoryId !== undefined
? { utilizationCategoryId: input.utilizationCategoryId || null }
: {}),
...(input.clientId !== undefined ? { clientId: input.clientId || null } : {}),
...(input.shoringThreshold !== undefined ? { shoringThreshold: input.shoringThreshold } : {}),
...(input.onshoreCountryCode !== undefined ? { onshoreCountryCode: input.onshoreCountryCode } : {}),
...(input.onshoreCountryCode !== undefined
? { onshoreCountryCode: input.onshoreCountryCode }
: {}),
} as unknown as Parameters<TRPCContext["db"]["project"]["update"]>[0]["data"];
}
export function createProjectMutationProcedures(
backgroundEffects: ProjectBackgroundEffects,
) {
export function createProjectMutationProcedures(backgroundEffects: ProjectBackgroundEffects) {
return {
create: managerProcedure
.input(CreateProjectSchema)
.input(CreateProjectSchema as z.ZodType<CreateProjectInput>)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
@@ -119,7 +136,9 @@ export function createProjectMutationProcedures(
);
const nextBlueprintId = input.data.blueprintId ?? existing.blueprintId ?? undefined;
const nextDynamicFields = (input.data.dynamicFields ?? existing.dynamicFields ?? {}) as Record<string, unknown>;
const nextDynamicFields = (input.data.dynamicFields ??
existing.dynamicFields ??
{}) as Record<string, unknown>;
await assertBlueprintDynamicFields({
db: ctx.db,
+10 -3
View File
@@ -1,3 +1,4 @@
import type { z } from "zod";
import { projectCostReadProcedures } from "./project-cost-read.js";
import { projectCoverProcedures } from "./project-cover.js";
import { projectIdentifierReadProcedures } from "./project-identifier-read.js";
@@ -21,13 +22,20 @@ export const projectRouter = createTRPCRouter({
...projectCoverProcedures,
...projectIdentifierReadProcedures,
...createProjectLifecycleProcedures({
invalidateDashboardCacheInBackground: projectBackgroundEffects.invalidateDashboardCacheInBackground,
invalidateDashboardCacheInBackground:
projectBackgroundEffects.invalidateDashboardCacheInBackground,
dispatchProjectWebhookInBackground: projectBackgroundEffects.dispatchProjectWebhookInBackground,
}),
...createProjectMutationProcedures(projectBackgroundEffects),
list: controllerProcedure
.input(ProjectListInputSchema)
.input(
ProjectListInputSchema as z.ZodType<
z.infer<typeof ProjectListInputSchema>,
z.ZodTypeDef,
z.input<typeof ProjectListInputSchema>
>,
)
.query(({ ctx, input }) => listProjects(ctx, input)),
getById: controllerProcedure
@@ -37,5 +45,4 @@ export const projectRouter = createTRPCRouter({
getShoringRatio: controllerProcedure
.input(ProjectShoringRatioInputSchema)
.query(({ ctx, input }) => getProjectShoringRatioData(ctx, input)),
});
@@ -1,8 +1,6 @@
import type { z } from "zod";
import { protectedProcedure, resourceOverviewProcedure } from "../trpc.js";
import {
ResourceDirectoryQuerySchema,
ResourceListQuerySchema,
} from "./resource-read-shared.js";
import { ResourceDirectoryQuerySchema, ResourceListQuerySchema } from "./resource-read-shared.js";
import {
ResolveResponsiblePersonNameInputSchema,
ResourceChargeabilitySummaryInputSchema,
@@ -38,7 +36,13 @@ export const resourceSummaryReadProcedures = {
.query(({ ctx, input }) => listResourceDirectory(ctx, input)),
listStaff: resourceOverviewProcedure
.input(ResourceListQuerySchema)
.input(
ResourceListQuerySchema as z.ZodType<
z.infer<typeof ResourceListQuerySchema>,
z.ZodTypeDef,
z.input<typeof ResourceListQuerySchema>
>,
)
.query(({ ctx, input }) => listStaffResourceEntries(ctx, input)),
chapters: protectedProcedure.query(({ ctx }) => listResourceChapters(ctx)),
@@ -18,17 +18,23 @@ import {
toIsoDate,
} from "./staffing-shared.js";
const SearchCapacityInputSchema = z.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
minHoursPerDay: z.number().optional().default(4),
roleName: z.string().optional(),
chapter: z.string().optional(),
limit: z.number().int().min(1).max(100).optional().default(20),
});
export const staffingCapacityReadProcedures = {
searchCapacity: planningReadProcedure
.input(
z.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
minHoursPerDay: z.number().optional().default(4),
roleName: z.string().optional(),
chapter: z.string().optional(),
limit: z.number().int().min(1).max(100).optional().default(20),
}),
SearchCapacityInputSchema as z.ZodType<
z.infer<typeof SearchCapacityInputSchema>,
z.ZodTypeDef,
z.input<typeof SearchCapacityInputSchema>
>,
)
.query(async ({ ctx, input }) => {
const where: Record<string, unknown> = { isActive: true };
@@ -107,7 +113,8 @@ export const staffingCapacityReadProcedures = {
});
const bookedHours = (bookingsByResourceId.get(resource.id) ?? []).reduce(
(sum, booking) =>
sum + calculateEffectiveBookedHours({
sum +
calculateEffectiveBookedHours({
availability,
startDate: booking.startDate,
endDate: booking.endDate,
@@ -179,15 +186,17 @@ export const staffingCapacityReadProcedures = {
const availability = resource.availability as unknown as WeekdayAvailability;
const contexts = await loadResourceDailyAvailabilityContexts(
ctx.db,
[{
id: resource.id,
availability,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
}],
[
{
id: resource.id,
availability,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
},
],
input.startDate,
input.endDate,
);
@@ -231,9 +240,8 @@ export const staffingCapacityReadProcedures = {
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
const currentChargeability = totalAvailableHours > 0
? (totalChargeableHours / totalAvailableHours) * 100
: 0;
const currentChargeability =
totalAvailableHours > 0 ? (totalChargeableHours / totalAvailableHours) * 100 : 0;
return {
resourceId: resource.id,
@@ -291,15 +299,17 @@ export const staffingCapacityReadProcedures = {
const availability = resource.availability as unknown as WeekdayAvailability;
const contexts = await loadResourceDailyAvailabilityContexts(
ctx.db,
[{
id: resource.id,
availability,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
}],
[
{
id: resource.id,
availability,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
},
],
input.startDate,
input.endDate,
);
@@ -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)}`,
@@ -1,3 +1,4 @@
import type { z } from "zod";
import { adminProcedure, createTRPCRouter } from "../trpc.js";
import {
listSystemRoleConfigs,
@@ -11,6 +12,10 @@ export const systemRoleConfigRouter = createTRPCRouter({
/** Update a role's default permissions, label, description, and color */
update: adminProcedure
.input(systemRoleConfigUpdateInputSchema)
.input(
systemRoleConfigUpdateInputSchema as z.ZodType<
z.infer<typeof systemRoleConfigUpdateInputSchema>
>,
)
.mutation(({ ctx, input }) => updateSystemRoleConfig(ctx, input)),
});
@@ -1,4 +1,6 @@
import { PermissionKey, ShiftProjectSchema } from "@capakraken/shared";
import type { ShiftProjectInput } from "@capakraken/shared";
import type { z } from "zod";
import { managerProcedure, requirePermission } from "../trpc.js";
import { timelineAllocationMutationProcedures } from "./timeline-allocation-mutations.js";
import { applyTimelineProjectShiftMutation } from "./timeline-shift-router-support.js";
@@ -7,7 +9,7 @@ export const timelineMutationProcedures = {
...timelineAllocationMutationProcedures,
applyShift: managerProcedure
.input(ShiftProjectSchema)
.input(ShiftProjectSchema as z.ZodType<ShiftProjectInput>)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
+11 -2
View File
@@ -1,4 +1,11 @@
import { adminProcedure, createTRPCRouter, managerProcedure, protectedProcedure, publicProcedure } from "../trpc.js";
import type { z } from "zod";
import {
adminProcedure,
createTRPCRouter,
managerProcedure,
protectedProcedure,
publicProcedure,
} from "../trpc.js";
import {
autoLinkUsersByEmail,
countActiveUsers,
@@ -92,7 +99,9 @@ export const userRouter = createTRPCRouter({
.mutation(({ ctx, input }) => toggleFavoriteProject(ctx, input)),
setPermissions: adminProcedure
.input(SetUserPermissionsInputSchema)
.input(
SetUserPermissionsInputSchema as z.ZodType<z.infer<typeof SetUserPermissionsInputSchema>>,
)
.mutation(({ ctx, input }) => setUserPermissions(ctx, input)),
resetPermissions: adminProcedure
+3 -1
View File
@@ -1,7 +1,9 @@
import { createTRPCRouter, protectedProcedure } from "../trpc.js";
import type { z } from "zod";
import {
createVacationRequest,
CreateVacationRequestSchema,
type CreateVacationRequestInput,
} from "./vacation-create-support.js";
import { vacationManagementProcedures } from "./vacation-management-procedures.js";
import { vacationReadProcedures } from "./vacation-read.js";
@@ -11,6 +13,6 @@ export const vacationRouter = createTRPCRouter({
...vacationManagementProcedures,
create: protectedProcedure
.input(CreateVacationRequestSchema)
.input(CreateVacationRequestSchema as z.ZodType<CreateVacationRequestInput>)
.mutation(({ ctx, input }) => createVacationRequest(ctx, input)),
});