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 } >(); 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 => 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); }), };