refactor(api): extract resource insight procedures
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
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 },
|
||||
});
|
||||
|
||||
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);
|
||||
}),
|
||||
};
|
||||
Reference in New Issue
Block a user