183 lines
6.1 KiB
TypeScript
183 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 },
|
|
});
|
|
|
|
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);
|
|
}),
|
|
};
|