perf(api): eliminate 3 N+1 query patterns
- timeline-holiday-load-support: deduplicate getResolvedCalendarHolidays by location key so resources sharing the same country/state/city resolve holidays once instead of per-resource - rate-card-lookup: add lookupRatesBatch that loads rate card lines once and scores locally per demand line, replacing per-line DB round-trips in estimate-demand-lines autoFillDemandLineRates - config-readmodels: include _count in utilization-category list query instead of calling getById per category for project counts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,138 +4,149 @@ import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } fro
|
||||
|
||||
type ConfigReadmodelsDeps = {
|
||||
createManagementLevelCaller: (ctx: TRPCContext) => {
|
||||
listGroups: () => Promise<Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
targetPercentage: number | null;
|
||||
levels: Array<{ id: string; name: string }>;
|
||||
}>>;
|
||||
};
|
||||
createUtilizationCategoryCaller: (ctx: TRPCContext) => {
|
||||
list: () => Promise<Array<{
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
}>>;
|
||||
getById: (params: { id: string }) => Promise<{
|
||||
_count: { projects: number };
|
||||
}>;
|
||||
};
|
||||
createCalculationRuleCaller: (ctx: TRPCContext) => {
|
||||
list: () => Promise<Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
isActive: boolean;
|
||||
triggerType: string;
|
||||
orderType: string | null;
|
||||
costEffect: string;
|
||||
costReductionPercent: number | null;
|
||||
chargeabilityEffect: string;
|
||||
priority: number;
|
||||
project: {
|
||||
listGroups: () => Promise<
|
||||
Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
shortCode: string;
|
||||
} | null;
|
||||
}>>;
|
||||
targetPercentage: number | null;
|
||||
levels: Array<{ id: string; name: string }>;
|
||||
}>
|
||||
>;
|
||||
};
|
||||
createUtilizationCategoryCaller: (ctx: TRPCContext) => {
|
||||
list: () => Promise<
|
||||
Array<{
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
_count: { projects: number };
|
||||
}>
|
||||
>;
|
||||
};
|
||||
createCalculationRuleCaller: (ctx: TRPCContext) => {
|
||||
list: () => Promise<
|
||||
Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
isActive: boolean;
|
||||
triggerType: string;
|
||||
orderType: string | null;
|
||||
costEffect: string;
|
||||
costReductionPercent: number | null;
|
||||
chargeabilityEffect: string;
|
||||
priority: number;
|
||||
project: {
|
||||
id: string;
|
||||
name: string;
|
||||
shortCode: string;
|
||||
} | null;
|
||||
}>
|
||||
>;
|
||||
};
|
||||
createEffortRuleCaller: (ctx: TRPCContext) => {
|
||||
list: () => Promise<Array<{
|
||||
name: string;
|
||||
isDefault: boolean;
|
||||
rules: Array<{
|
||||
id: string;
|
||||
description: string | null;
|
||||
scopeType: string;
|
||||
discipline: string;
|
||||
chapter: string | null;
|
||||
unitMode: string;
|
||||
hoursPerUnit: number;
|
||||
sortOrder: number;
|
||||
}>;
|
||||
}>>;
|
||||
list: () => Promise<
|
||||
Array<{
|
||||
name: string;
|
||||
isDefault: boolean;
|
||||
rules: Array<{
|
||||
id: string;
|
||||
description: string | null;
|
||||
scopeType: string;
|
||||
discipline: string;
|
||||
chapter: string | null;
|
||||
unitMode: string;
|
||||
hoursPerUnit: number;
|
||||
sortOrder: number;
|
||||
}>;
|
||||
}>
|
||||
>;
|
||||
};
|
||||
createExperienceMultiplierCaller: (ctx: TRPCContext) => {
|
||||
list: () => Promise<Array<{
|
||||
name: string;
|
||||
isDefault: boolean;
|
||||
rules: Array<{
|
||||
id: string;
|
||||
description: string | null;
|
||||
chapter: string | null;
|
||||
location: string | null;
|
||||
level: string | null;
|
||||
costMultiplier: number;
|
||||
billMultiplier: number;
|
||||
shoringRatio: number | null;
|
||||
additionalEffortRatio: number | null;
|
||||
sortOrder: number;
|
||||
}>;
|
||||
}>>;
|
||||
list: () => Promise<
|
||||
Array<{
|
||||
name: string;
|
||||
isDefault: boolean;
|
||||
rules: Array<{
|
||||
id: string;
|
||||
description: string | null;
|
||||
chapter: string | null;
|
||||
location: string | null;
|
||||
level: string | null;
|
||||
costMultiplier: number;
|
||||
billMultiplier: number;
|
||||
shoringRatio: number | null;
|
||||
additionalEffortRatio: number | null;
|
||||
sortOrder: number;
|
||||
}>;
|
||||
}>
|
||||
>;
|
||||
};
|
||||
createScopedCallerContext: (ctx: ToolContext) => TRPCContext;
|
||||
};
|
||||
|
||||
export const configReadmodelToolDefinitions: ToolDef[] = withToolAccess([
|
||||
export const configReadmodelToolDefinitions: ToolDef[] = withToolAccess(
|
||||
[
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "list_management_levels",
|
||||
description: "List management level groups and their levels with target percentages.",
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "list_utilization_categories",
|
||||
description: "List utilization categories (cost classification for projects).",
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "list_calculation_rules",
|
||||
description: "List calculation rules for cost attribution and chargeability.",
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "list_effort_rules",
|
||||
description: "List effort estimation rules with their formulas and conditions.",
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "list_experience_multipliers",
|
||||
description: "List experience multipliers that adjust effort estimates based on seniority.",
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
},
|
||||
],
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "list_management_levels",
|
||||
description: "List management level groups and their levels with target percentages.",
|
||||
parameters: { type: "object", properties: {} },
|
||||
list_management_levels: {
|
||||
requiresPlanningRead: true,
|
||||
},
|
||||
list_utilization_categories: {
|
||||
requiresPlanningRead: true,
|
||||
},
|
||||
list_calculation_rules: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
},
|
||||
list_effort_rules: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
},
|
||||
list_experience_multipliers: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "list_utilization_categories",
|
||||
description: "List utilization categories (cost classification for projects).",
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "list_calculation_rules",
|
||||
description: "List calculation rules for cost attribution and chargeability.",
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "list_effort_rules",
|
||||
description: "List effort estimation rules with their formulas and conditions.",
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "list_experience_multipliers",
|
||||
description: "List experience multipliers that adjust effort estimates based on seniority.",
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
},
|
||||
], {
|
||||
list_management_levels: {
|
||||
requiresPlanningRead: true,
|
||||
},
|
||||
list_utilization_categories: {
|
||||
requiresPlanningRead: true,
|
||||
},
|
||||
list_calculation_rules: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
},
|
||||
list_effort_rules: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
},
|
||||
list_experience_multipliers: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
export function createConfigReadmodelExecutors(
|
||||
deps: ConfigReadmodelsDeps,
|
||||
@@ -155,19 +166,13 @@ export function createConfigReadmodelExecutors(
|
||||
async list_utilization_categories(_params: Record<string, never>, ctx: ToolContext) {
|
||||
const caller = deps.createUtilizationCategoryCaller(deps.createScopedCallerContext(ctx));
|
||||
const categories = await caller.list();
|
||||
const categoriesWithCounts = await Promise.all(
|
||||
categories.map(async (category) => ({
|
||||
category,
|
||||
projectCount: (await caller.getById({ id: category.id }))._count.projects,
|
||||
})),
|
||||
);
|
||||
|
||||
return categoriesWithCounts.map(({ category, projectCount }) => ({
|
||||
return categories.map((category) => ({
|
||||
id: category.id,
|
||||
code: category.code,
|
||||
name: category.name,
|
||||
description: category.description,
|
||||
projectCount,
|
||||
projectCount: category._count.projects,
|
||||
}));
|
||||
},
|
||||
|
||||
@@ -198,41 +203,45 @@ export function createConfigReadmodelExecutors(
|
||||
async list_effort_rules(_params: Record<string, never>, ctx: ToolContext) {
|
||||
const caller = deps.createEffortRuleCaller(deps.createScopedCallerContext(ctx));
|
||||
const ruleSets = await caller.list();
|
||||
return ruleSets.flatMap((ruleSet) => ruleSet.rules.map((rule) => ({
|
||||
id: rule.id,
|
||||
description: rule.description,
|
||||
scopeType: rule.scopeType,
|
||||
discipline: rule.discipline,
|
||||
chapter: rule.chapter,
|
||||
unitMode: rule.unitMode,
|
||||
hoursPerUnit: rule.hoursPerUnit,
|
||||
sortOrder: rule.sortOrder,
|
||||
ruleSet: {
|
||||
name: ruleSet.name,
|
||||
isDefault: ruleSet.isDefault,
|
||||
},
|
||||
})));
|
||||
return ruleSets.flatMap((ruleSet) =>
|
||||
ruleSet.rules.map((rule) => ({
|
||||
id: rule.id,
|
||||
description: rule.description,
|
||||
scopeType: rule.scopeType,
|
||||
discipline: rule.discipline,
|
||||
chapter: rule.chapter,
|
||||
unitMode: rule.unitMode,
|
||||
hoursPerUnit: rule.hoursPerUnit,
|
||||
sortOrder: rule.sortOrder,
|
||||
ruleSet: {
|
||||
name: ruleSet.name,
|
||||
isDefault: ruleSet.isDefault,
|
||||
},
|
||||
})),
|
||||
);
|
||||
},
|
||||
|
||||
async list_experience_multipliers(_params: Record<string, never>, ctx: ToolContext) {
|
||||
const caller = deps.createExperienceMultiplierCaller(deps.createScopedCallerContext(ctx));
|
||||
const multiplierSets = await caller.list();
|
||||
return multiplierSets.flatMap((multiplierSet) => multiplierSet.rules.map((rule) => ({
|
||||
id: rule.id,
|
||||
description: rule.description,
|
||||
chapter: rule.chapter,
|
||||
location: rule.location,
|
||||
level: rule.level,
|
||||
costMultiplier: rule.costMultiplier,
|
||||
billMultiplier: rule.billMultiplier,
|
||||
shoringRatio: rule.shoringRatio,
|
||||
additionalEffortRatio: rule.additionalEffortRatio,
|
||||
sortOrder: rule.sortOrder,
|
||||
multiplierSet: {
|
||||
name: multiplierSet.name,
|
||||
isDefault: multiplierSet.isDefault,
|
||||
},
|
||||
})));
|
||||
return multiplierSets.flatMap((multiplierSet) =>
|
||||
multiplierSet.rules.map((rule) => ({
|
||||
id: rule.id,
|
||||
description: rule.description,
|
||||
chapter: rule.chapter,
|
||||
location: rule.location,
|
||||
level: rule.level,
|
||||
costMultiplier: rule.costMultiplier,
|
||||
billMultiplier: rule.billMultiplier,
|
||||
shoringRatio: rule.shoringRatio,
|
||||
additionalEffortRatio: rule.additionalEffortRatio,
|
||||
sortOrder: rule.sortOrder,
|
||||
multiplierSet: {
|
||||
name: multiplierSet.name,
|
||||
isDefault: multiplierSet.isDefault,
|
||||
},
|
||||
})),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { normalizeEstimateDemandLine, summarizeEstimateDemandLines } from "@capakraken/engine";
|
||||
import { CreateEstimateSchema } from "@capakraken/shared";
|
||||
import { z } from "zod";
|
||||
import { lookupRate } from "../lib/rate-card-lookup.js";
|
||||
import { lookupRatesBatch } from "../lib/rate-card-lookup.js";
|
||||
|
||||
function buildComputedMetrics(
|
||||
demandLines: z.infer<typeof CreateEstimateSchema>["demandLines"],
|
||||
) {
|
||||
function buildComputedMetrics(demandLines: z.infer<typeof CreateEstimateSchema>["demandLines"]) {
|
||||
const summary = summarizeEstimateDemandLines(demandLines);
|
||||
|
||||
return [
|
||||
@@ -62,7 +60,9 @@ function normalizeDemandLines<
|
||||
const snapshotsByResourceId = new Map(
|
||||
input.resourceSnapshots
|
||||
.filter(
|
||||
(snapshot): snapshot is (typeof input.resourceSnapshots)[number] & {
|
||||
(
|
||||
snapshot,
|
||||
): snapshot is (typeof input.resourceSnapshots)[number] & {
|
||||
resourceId: string;
|
||||
} => typeof snapshot.resourceId === "string" && snapshot.resourceId.length > 0,
|
||||
)
|
||||
@@ -71,8 +71,7 @@ function normalizeDemandLines<
|
||||
|
||||
return input.demandLines.map((line) =>
|
||||
normalizeEstimateDemandLine(line, {
|
||||
resourceSnapshot:
|
||||
line.resourceId != null ? snapshotsByResourceId.get(line.resourceId) : null,
|
||||
resourceSnapshot: line.resourceId != null ? snapshotsByResourceId.get(line.resourceId) : null,
|
||||
defaultCurrency: baseCurrency,
|
||||
}),
|
||||
);
|
||||
@@ -117,44 +116,60 @@ export async function autoFillDemandLineRates(
|
||||
clientId = project?.clientId ?? null;
|
||||
}
|
||||
|
||||
const autoFilledIndices: number[] = [];
|
||||
const enriched = await Promise.all(
|
||||
demandLines.map(async (line, index) => {
|
||||
const isDefaultRate = line.costRateCents === 0 && line.billRateCents === 0;
|
||||
const hasExplicitSource = line.rateSource != null && line.rateSource.length > 0;
|
||||
if (!isDefaultRate || hasExplicitSource) {
|
||||
return line;
|
||||
}
|
||||
|
||||
const result = await lookupRate(db, {
|
||||
clientId,
|
||||
chapter: line.chapter ?? null,
|
||||
roleId: line.roleId ?? null,
|
||||
// Identify which lines need auto-fill and collect their lookup params
|
||||
const needsLookup: {
|
||||
index: number;
|
||||
params: { chapter: string | null; roleId: string | null };
|
||||
}[] = [];
|
||||
for (let i = 0; i < demandLines.length; i++) {
|
||||
const line = demandLines[i]!;
|
||||
const isDefaultRate = line.costRateCents === 0 && line.billRateCents === 0;
|
||||
const hasExplicitSource = line.rateSource != null && line.rateSource.length > 0;
|
||||
if (isDefaultRate && !hasExplicitSource) {
|
||||
needsLookup.push({
|
||||
index: i,
|
||||
params: { chapter: line.chapter ?? null, roleId: line.roleId ?? null },
|
||||
});
|
||||
if (!result) {
|
||||
return line;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
autoFilledIndices.push(index);
|
||||
const existingMetadata = (line.metadata ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
...line,
|
||||
costRateCents: result.costRateCents,
|
||||
billRateCents: result.billRateCents,
|
||||
currency: result.currency,
|
||||
rateSource: `rate-card:${result.rateCardId}`,
|
||||
metadata: {
|
||||
...existingMetadata,
|
||||
autoAppliedRateCard: {
|
||||
rateCardId: result.rateCardId,
|
||||
rateCardLineId: result.rateCardLineId,
|
||||
rateCardName: result.rateCardName,
|
||||
appliedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
if (needsLookup.length === 0) {
|
||||
return { demandLines, autoFilledIndices: [] };
|
||||
}
|
||||
|
||||
// Single DB query for all rate card lines, scored locally per demand line
|
||||
const results = await lookupRatesBatch(
|
||||
db,
|
||||
clientId,
|
||||
needsLookup.map((entry) => entry.params),
|
||||
);
|
||||
|
||||
const autoFilledIndices: number[] = [];
|
||||
const enriched = [...demandLines];
|
||||
for (let i = 0; i < needsLookup.length; i++) {
|
||||
const result = results[i];
|
||||
if (!result) continue;
|
||||
const { index } = needsLookup[i]!;
|
||||
autoFilledIndices.push(index);
|
||||
const line = demandLines[index]!;
|
||||
const existingMetadata = (line.metadata ?? {}) as Record<string, unknown>;
|
||||
enriched[index] = {
|
||||
...line,
|
||||
costRateCents: result.costRateCents,
|
||||
billRateCents: result.billRateCents,
|
||||
currency: result.currency,
|
||||
rateSource: `rate-card:${result.rateCardId}`,
|
||||
metadata: {
|
||||
...existingMetadata,
|
||||
autoAppliedRateCard: {
|
||||
rateCardId: result.rateCardId,
|
||||
rateCardLineId: result.rateCardLineId,
|
||||
rateCardName: result.rateCardName,
|
||||
appliedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { demandLines: enriched, autoFilledIndices };
|
||||
}
|
||||
|
||||
@@ -57,36 +57,57 @@ export async function loadTimelineHolidayOverlaysForReadModel(
|
||||
},
|
||||
});
|
||||
|
||||
// Group resources by location key to deduplicate holiday resolution.
|
||||
// Resources sharing the same (countryId, federalState, metroCityId) get
|
||||
// identical holidays, so we resolve once per location instead of once per resource.
|
||||
const locationGroups = new Map<
|
||||
string,
|
||||
{ locationResource: (typeof resources)[0]; resourceIds: string[] }
|
||||
>();
|
||||
for (const resource of resources) {
|
||||
const key = `${resource.countryId ?? ""}:${resource.federalState ?? ""}:${resource.metroCityId ?? ""}`;
|
||||
const existing = locationGroups.get(key);
|
||||
if (existing) {
|
||||
existing.resourceIds.push(resource.id);
|
||||
} else {
|
||||
locationGroups.set(key, { locationResource: resource, resourceIds: [resource.id] });
|
||||
}
|
||||
}
|
||||
|
||||
const resolverDb = asHolidayResolverDb(db);
|
||||
const overlays = await Promise.all(
|
||||
resources.map(async (resource) => {
|
||||
const holidays = await getResolvedCalendarHolidays(asHolidayResolverDb(db), {
|
||||
[...locationGroups.values()].map(async ({ locationResource, resourceIds }) => {
|
||||
const holidays = await getResolvedCalendarHolidays(resolverDb, {
|
||||
periodStart: input.startDate,
|
||||
periodEnd: input.endDate,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code ?? null,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name ?? null,
|
||||
countryId: locationResource.countryId,
|
||||
countryCode: locationResource.country?.code ?? null,
|
||||
federalState: locationResource.federalState,
|
||||
metroCityId: locationResource.metroCityId,
|
||||
metroCityName: locationResource.metroCity?.name ?? null,
|
||||
});
|
||||
|
||||
return holidays.map((holiday) => {
|
||||
const holidayDate = new Date(`${holiday.date}T00:00:00.000Z`);
|
||||
return {
|
||||
id: `calendar-holiday:${resource.id}:${holiday.date}`,
|
||||
resourceId: resource.id,
|
||||
type: VacationType.PUBLIC_HOLIDAY,
|
||||
status: "APPROVED" as const,
|
||||
startDate: holidayDate,
|
||||
endDate: holidayDate,
|
||||
note: holiday.name,
|
||||
scope: holiday.scope,
|
||||
calendarName: holiday.calendarName,
|
||||
sourceType: holiday.sourceType,
|
||||
countryCode: resource.country?.code ?? null,
|
||||
countryName: resource.country?.name ?? null,
|
||||
federalState: resource.federalState ?? null,
|
||||
metroCityName: resource.metroCity?.name ?? null,
|
||||
};
|
||||
return resourceIds.flatMap((resourceId) => {
|
||||
const resource = resources.find((r) => r.id === resourceId)!;
|
||||
return holidays.map((holiday) => {
|
||||
const holidayDate = new Date(`${holiday.date}T00:00:00.000Z`);
|
||||
return {
|
||||
id: `calendar-holiday:${resourceId}:${holiday.date}`,
|
||||
resourceId,
|
||||
type: VacationType.PUBLIC_HOLIDAY,
|
||||
status: "APPROVED" as const,
|
||||
startDate: holidayDate,
|
||||
endDate: holidayDate,
|
||||
note: holiday.name,
|
||||
scope: holiday.scope,
|
||||
calendarName: holiday.calendarName,
|
||||
sourceType: holiday.sourceType,
|
||||
countryCode: resource.country?.code ?? null,
|
||||
countryName: resource.country?.name ?? null,
|
||||
federalState: resource.federalState ?? null,
|
||||
metroCityName: resource.metroCity?.name ?? null,
|
||||
};
|
||||
});
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -14,9 +14,11 @@ import {
|
||||
unsetDefaultUtilizationCategory,
|
||||
} from "./utilization-category-support.js";
|
||||
|
||||
export const UtilizationCategoryListInputSchema = z.object({
|
||||
isActive: z.boolean().optional(),
|
||||
}).optional();
|
||||
export const UtilizationCategoryListInputSchema = z
|
||||
.object({
|
||||
isActive: z.boolean().optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const UtilizationCategoryByIdInputSchema = z.object({
|
||||
id: z.string(),
|
||||
@@ -38,6 +40,7 @@ export async function listUtilizationCategories(
|
||||
return ctx.db.utilizationCategory.findMany({
|
||||
where: buildUtilizationCategoryListWhere(input ?? {}),
|
||||
orderBy: { sortOrder: "asc" },
|
||||
include: { _count: { select: { projects: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user