Files
CapaKraken/packages/api/src/router/resource-analytics.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

184 lines
6.1 KiB
TypeScript

import { recomputeResourceValueScores } from "@capakraken/application";
import { PermissionKey } from "@capakraken/shared";
import { z } from "zod";
import {
anonymizeResource,
anonymizeResources,
getAnonymizationDirectory,
} from "../lib/anonymization.js";
import { resolveResourcePermissions } from "../lib/resource-access.js";
import {
adminProcedure,
controllerProcedure,
protectedProcedure,
requirePermission,
} from "../trpc.js";
type SkillRow = {
skill: string;
category?: string;
proficiency: number;
isMainSkill?: boolean;
};
export const resourceAnalyticsProcedures = {
getSkillsAnalytics: controllerProcedure.query(async ({ ctx }) => {
const resources = await ctx.db.resource.findMany({
where: { isActive: true },
select: { id: true, displayName: true, chapter: true, skills: true },
take: 5000,
});
const skillMap = new Map<
string,
{ skill: string; category: string; count: number; totalProficiency: number; chapters: Set<string> }
>();
for (const resource of resources) {
const skills = (resource.skills as unknown as SkillRow[]) ?? [];
for (const skill of skills) {
const key = skill.skill;
if (!skillMap.has(key)) {
skillMap.set(key, {
skill: skill.skill,
category: skill.category ?? "Uncategorized",
count: 0,
totalProficiency: 0,
chapters: new Set(),
});
}
const entry = skillMap.get(key)!;
entry.count++;
entry.totalProficiency += skill.proficiency;
if (resource.chapter) entry.chapters.add(resource.chapter);
}
}
const aggregated = Array.from(skillMap.values())
.map((entry) => ({
skill: entry.skill,
category: entry.category,
count: entry.count,
avgProficiency: Math.round((entry.totalProficiency / entry.count) * 10) / 10,
chapters: Array.from(entry.chapters),
}))
.sort((left, right) => right.count - left.count);
const categories = [...new Set(aggregated.map((entry) => entry.category))].sort();
const allChapters = [...new Set(resources.map((resource) => resource.chapter).filter(Boolean))].sort() as string[];
return {
totalResources: resources.length,
totalSkillEntries: aggregated.length,
aggregated,
categories,
allChapters,
};
}),
searchBySkills: controllerProcedure
.input(
z.object({
rules: z.array(
z.object({
skill: z.string().min(1),
minProficiency: z.number().int().min(1).max(5).default(1),
}),
),
chapter: z.string().optional(),
operator: z.enum(["AND", "OR"]).default("AND"),
}),
)
.query(async ({ ctx, input }) => {
const { rules, chapter, operator } = input;
const resources = await ctx.db.resource.findMany({
where: { isActive: true, ...(chapter ? { chapter } : {}) },
select: { id: true, eid: true, displayName: true, chapter: true, skills: true },
});
const results = resources
.map((resource) => {
const skills = (resource.skills as unknown as SkillRow[]) ?? [];
const matchFn = (rule: { skill: string; minProficiency: number }) => {
const matchedSkill = skills.find((skill) => skill.skill.toLowerCase().includes(rule.skill.toLowerCase()));
return matchedSkill && matchedSkill.proficiency >= rule.minProficiency ? matchedSkill : null;
};
const matched = rules.map(matchFn);
const passes = operator === "AND" ? matched.every(Boolean) : matched.some(Boolean);
if (!passes) return null;
return {
id: resource.id,
eid: resource.eid,
displayName: resource.displayName,
chapter: resource.chapter,
matchedSkills: rules
.map((rule, index) => {
const matchedSkill = matched[index];
return matchedSkill
? {
skill: matchedSkill.skill,
proficiency: matchedSkill.proficiency,
category: matchedSkill.category ?? "",
}
: null;
})
.filter((skill): skill is { skill: string; proficiency: number; category: string } => skill !== null),
};
})
.filter((resource): resource is NonNullable<typeof resource> => resource !== null)
.sort((left, right) => left.displayName.localeCompare(right.displayName));
const directory = await getAnonymizationDirectory(ctx.db);
return anonymizeResources(results, directory);
}),
getMyResource: protectedProcedure.query(async ({ ctx }) => {
const user = await ctx.db.user.findUnique({
where: { id: ctx.dbUser!.id },
select: { resource: { select: { id: true, displayName: true, eid: true, chapter: true } } },
});
const directory = await getAnonymizationDirectory(ctx.db);
return user?.resource ? anonymizeResource(user.resource, directory) : null;
}),
getValueScores: protectedProcedure
.input(
z.object({
isActive: z.boolean().optional().default(true),
limit: z.number().int().min(1).max(500).default(100),
}),
)
.query(async ({ ctx, input }) => {
const permissions = resolveResourcePermissions(ctx);
requirePermission({ permissions }, PermissionKey.VIEW_SCORES);
const resources = await ctx.db.resource.findMany({
where: { isActive: input.isActive },
select: {
id: true,
eid: true,
displayName: true,
chapter: true,
lcrCents: true,
valueScore: true,
valueScoreBreakdown: true,
valueScoreUpdatedAt: true,
},
orderBy: [{ valueScore: "desc" }, { displayName: "asc" }],
take: input.limit,
});
const directory = await getAnonymizationDirectory(ctx.db);
return anonymizeResources(resources, directory);
}),
recomputeValueScores: adminProcedure.mutation(async ({ ctx }) => {
return recomputeResourceValueScores(ctx.db);
}),
};