Files
CapaKraken/packages/api/src/router/resource-marketplace-read.ts
T
Hartmut 1204c186ef perf(api): eliminate N+1 queries, add query guards and missing indexes
- Notification fan-out: replace sequential for loops with Promise.all (allocation-effects, notification-broadcast, create-notification)
- Public holiday batch: group resources by location combo, resolve holidays once per group, replace per-holiday delete/findFirst/create with 3 batched queries (~18K → ~5 queries)
- Add take guards to unbounded findMany calls (resource-analytics: 5000, resource-marketplace: 2000, resource-capacity: 1000, chargeability-report: 2000)
- auto-staffing: add select with only needed fields + take: 5000
- schema.prisma: add 5 missing indexes (ManagementLevel.groupId, Blueprint.isActive/target, Comment.parentId, Vacation.requestedById, Resource.managementLevelGroupId)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 08:35:13 +02:00

278 lines
9.5 KiB
TypeScript

import type { WeekdayAvailability } from "@capakraken/shared";
import { z } from "zod";
import {
anonymizeResources,
getAnonymizationDirectory,
} from "../lib/anonymization.js";
import {
calculateEffectiveAvailableHours,
calculateEffectiveBookedHours,
calculateEffectiveDayAvailability,
loadResourceDailyAvailabilityContexts,
} from "../lib/resource-capacity.js";
import { controllerProcedure } from "../trpc.js";
import { buildDailyBookedHoursMap, toIsoDate } from "./resource-capacity-shared.js";
type SkillRow = {
skill: string;
category?: string;
proficiency: number;
isMainSkill?: boolean;
};
export const resourceMarketplaceReadProcedures = {
getSkillMarketplace: controllerProcedure
.input(
z.object({
searchSkill: z.string().optional(),
minProficiency: z.number().int().min(1).max(5).optional().default(1),
availableOnly: z.boolean().optional().default(false),
}),
)
.query(async ({ ctx, input }) => {
const now = new Date();
const today = new Date(now);
today.setUTCHours(0, 0, 0, 0);
const thirtyDaysFromNow = new Date(today);
thirtyDaysFromNow.setUTCDate(thirtyDaysFromNow.getUTCDate() + 29);
const resources = await ctx.db.resource.findMany({
where: { isActive: true },
select: {
id: true,
displayName: true,
eid: true,
chapter: true,
skills: true,
availability: true,
countryId: true,
federalState: true,
metroCityId: true,
country: { select: { code: true } },
metroCity: { select: { name: true } },
},
take: 2000,
});
const assignments = await ctx.db.assignment.findMany({
where: {
resourceId: { in: resources.map((resource) => resource.id) },
status: { in: ["PROPOSED", "CONFIRMED", "ACTIVE", "COMPLETED"] },
endDate: { gte: today },
startDate: { lte: thirtyDaysFromNow },
},
select: {
resourceId: true,
startDate: true,
endDate: true,
hoursPerDay: true,
},
});
const contexts = await loadResourceDailyAvailabilityContexts(
ctx.db,
resources.map((resource) => ({
id: resource.id,
availability: resource.availability as unknown as WeekdayAvailability,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
})),
today,
thirtyDaysFromNow,
);
const assignmentsByResourceId = new Map<string, typeof assignments>();
for (const assignment of assignments) {
const items = assignmentsByResourceId.get(assignment.resourceId) ?? [];
items.push(assignment);
assignmentsByResourceId.set(assignment.resourceId, items);
}
const utilizationMap = new Map<string, { utilizationPercent: number; earliestAvailableDate: Date | null }>();
for (const resource of resources) {
const availability = resource.availability as unknown as WeekdayAvailability;
const context = contexts.get(resource.id);
const resourceAssignments = assignmentsByResourceId.get(resource.id) ?? [];
const todayAvailableHours = calculateEffectiveAvailableHours({
availability,
periodStart: today,
periodEnd: today,
context,
});
const todayBookedHours = resourceAssignments.reduce(
(sum, assignment) => sum + calculateEffectiveBookedHours({
availability,
startDate: assignment.startDate,
endDate: assignment.endDate,
hoursPerDay: assignment.hoursPerDay,
periodStart: today,
periodEnd: today,
context,
}),
0,
);
const utilizationPercent = todayAvailableHours > 0
? Math.round((todayBookedHours / todayAvailableHours) * 100)
: 0;
const dailyBookedHours = buildDailyBookedHoursMap(
resourceAssignments,
availability,
context,
today,
thirtyDaysFromNow,
);
let earliestAvailableDate: Date | null = null;
const checkDate = new Date(today);
for (let index = 0; index < 30; index++) {
const dayAvailableHours = calculateEffectiveDayAvailability({
availability,
date: checkDate,
context,
});
if (dayAvailableHours > 0) {
const dayBookedHours = dailyBookedHours.get(toIsoDate(checkDate)) ?? 0;
if (dayBookedHours < dayAvailableHours * 0.8) {
earliestAvailableDate = new Date(checkDate);
break;
}
}
checkDate.setUTCDate(checkDate.getUTCDate() + 1);
}
utilizationMap.set(resource.id, { utilizationPercent, earliestAvailableDate });
}
let searchResults: Array<{
id: string;
displayName: string;
chapter: string | null;
skillProficiency: number;
skillName: string;
utilizationPercent: number;
availableFrom: string | null;
}> = [];
if (input.searchSkill && input.searchSkill.trim().length > 0) {
const needle = input.searchSkill.toLowerCase();
for (const resource of resources) {
const skills = (resource.skills as unknown as SkillRow[]) ?? [];
const match = skills.find(
(skill) => skill.skill.toLowerCase().includes(needle) && skill.proficiency >= input.minProficiency,
);
if (!match) continue;
const utilization = utilizationMap.get(resource.id);
if (input.availableOnly && !utilization?.earliestAvailableDate) continue;
searchResults.push({
id: resource.id,
displayName: resource.displayName,
chapter: resource.chapter,
skillProficiency: match.proficiency,
skillName: match.skill,
utilizationPercent: utilization?.utilizationPercent ?? 0,
availableFrom: utilization?.earliestAvailableDate?.toISOString() ?? null,
});
}
searchResults.sort((left, right) => (
right.skillProficiency - left.skillProficiency || left.utilizationPercent - right.utilizationPercent
));
}
const unfilled = await ctx.db.demandRequirement.findMany({
where: {
endDate: { gte: now },
assignments: { none: {} },
},
select: {
id: true,
role: true,
roleId: true,
headcount: true,
project: {
select: { staffingReqs: true },
},
},
});
const demandSkillCounts = new Map<string, number>();
for (const demand of unfilled) {
const staffingReqs = (demand.project.staffingReqs as unknown as Array<{
role?: string;
roleId?: string;
requiredSkills?: string[];
}>) ?? [];
const matchedReq = staffingReqs.find(
(staffingReq) =>
(demand.roleId && staffingReq.roleId === demand.roleId) ||
(demand.role && staffingReq.role === demand.role),
);
if (matchedReq?.requiredSkills) {
for (const skill of matchedReq.requiredSkills) {
demandSkillCounts.set(skill, (demandSkillCounts.get(skill) ?? 0) + demand.headcount);
}
}
}
const supplySkillCounts = new Map<string, number>();
for (const resource of resources) {
const skills = (resource.skills as unknown as SkillRow[]) ?? [];
for (const skill of skills) {
if (skill.proficiency >= 3) {
supplySkillCounts.set(skill.skill, (supplySkillCounts.get(skill.skill) ?? 0) + 1);
}
}
}
const gapData = Array.from(new Set([...demandSkillCounts.keys(), ...supplySkillCounts.keys()]))
.map((skill) => {
const supply = supplySkillCounts.get(skill) ?? 0;
const demand = demandSkillCounts.get(skill) ?? 0;
return { skill, supply, demand, gap: demand - supply };
})
.sort((left, right) => right.gap - left.gap);
const distribution = Array.from(
(() => {
const skillMap = new Map<string, { skill: string; count: number; totalProficiency: number }>();
for (const resource of resources) {
const skills = (resource.skills as unknown as SkillRow[]) ?? [];
for (const skill of skills) {
const entry = skillMap.get(skill.skill);
if (entry) {
entry.count++;
entry.totalProficiency += skill.proficiency;
} else {
skillMap.set(skill.skill, {
skill: skill.skill,
count: 1,
totalProficiency: skill.proficiency,
});
}
}
}
return skillMap.values();
})(),
)
.map((entry) => ({
skill: entry.skill,
count: entry.count,
avgProficiency: Math.round((entry.totalProficiency / entry.count) * 10) / 10,
}))
.sort((left, right) => right.count - left.count)
.slice(0, 20);
const directory = await getAnonymizationDirectory(ctx.db);
return {
searchResults: anonymizeResources(searchResults, directory),
gapData,
distribution,
totalResources: resources.length,
};
}),
};