diff --git a/packages/api/src/router/resource-analytics.ts b/packages/api/src/router/resource-analytics.ts new file mode 100644 index 0000000..8e206fe --- /dev/null +++ b/packages/api/src/router/resource-analytics.ts @@ -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 } + >(); + + 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); + }), +}; diff --git a/packages/api/src/router/resource-capacity-read.ts b/packages/api/src/router/resource-capacity-read.ts new file mode 100644 index 0000000..26a2ae6 --- /dev/null +++ b/packages/api/src/router/resource-capacity-read.ts @@ -0,0 +1,264 @@ +import { + isChargeabilityActualBooking, + isChargeabilityRelevantProject, + listAssignmentBookings, +} from "@capakraken/application"; +import type { WeekdayAvailability } from "@capakraken/shared"; +import { z } from "zod"; +import { + anonymizeResource, + getAnonymizationDirectory, +} from "../lib/anonymization.js"; +import { + calculateEffectiveAvailableHours, + calculateEffectiveBookedHours, + calculateEffectiveDayAvailability, + loadResourceDailyAvailabilityContexts, +} from "../lib/resource-capacity.js"; +import { controllerProcedure } from "../trpc.js"; +import { buildDailyBookedHoursMap } from "./resource-capacity-shared.js"; + +export const resourceCapacityReadProcedures = { + listWithUtilization: controllerProcedure + .input( + z.object({ + startDate: z.string().datetime().optional(), + endDate: z.string().datetime().optional(), + chapter: z.string().optional(), + includeProposed: z.boolean().default(false), + limit: z.number().int().min(1).max(500).default(100), + }), + ) + .query(async ({ ctx, input }) => { + const now = new Date(); + const start = input.startDate ? new Date(input.startDate) : new Date(now.getFullYear(), now.getMonth(), 1); + const end = input.endDate ? new Date(input.endDate) : new Date(now.getFullYear(), now.getMonth() + 3, 0); + + const resources = await ctx.db.resource.findMany({ + where: { + isActive: true, + ...(input.chapter ? { chapter: input.chapter } : {}), + }, + take: input.limit, + orderBy: { displayName: "asc" }, + select: { + id: true, + eid: true, + displayName: true, + email: true, + chapter: true, + lcrCents: true, + ucrCents: true, + currency: true, + chargeabilityTarget: true, + availability: true, + skills: true, + dynamicFields: true, + blueprintId: true, + isActive: true, + createdAt: true, + updatedAt: true, + roleId: true, + portfolioUrl: true, + postalCode: true, + federalState: true, + countryId: true, + metroCityId: true, + valueScore: true, + valueScoreBreakdown: true, + valueScoreUpdatedAt: true, + userId: true, + country: { select: { code: true } }, + metroCity: { select: { name: true } }, + }, + }); + const bookings = await listAssignmentBookings(ctx.db, { + startDate: start, + endDate: end, + resourceIds: resources.map((resource) => resource.id), + }); + const bookingsByResourceId = new Map(); + for (const booking of bookings) { + if (!booking.resourceId) { + continue; + } + const items = bookingsByResourceId.get(booking.resourceId) ?? []; + items.push(booking); + bookingsByResourceId.set(booking.resourceId, items); + } + 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, + })), + start, + end, + ); + const directory = await getAnonymizationDirectory(ctx.db); + + return resources.map((resource) => { + const availability = resource.availability as unknown as WeekdayAvailability; + const context = contexts.get(resource.id); + const resourceBookings = (bookingsByResourceId.get(resource.id) ?? []).filter( + (booking) => + booking.resourceId === resource.id && + (input.includeProposed || booking.status !== "PROPOSED"), + ); + const availableHours = calculateEffectiveAvailableHours({ + availability, + periodStart: start, + periodEnd: end, + context, + }); + const bookedHours = resourceBookings.reduce( + (sum, booking) => sum + calculateEffectiveBookedHours({ + availability, + startDate: booking.startDate, + endDate: booking.endDate, + hoursPerDay: booking.hoursPerDay, + periodStart: start, + periodEnd: end, + context, + }), + 0, + ); + const dailyBookedHours = buildDailyBookedHoursMap(resourceBookings, availability, context, start, end); + const isOverbooked = Array.from(dailyBookedHours.entries()).some(([isoDate, hours]) => { + const date = new Date(`${isoDate}T00:00:00.000Z`); + const dayCapacity = calculateEffectiveDayAvailability({ + availability, + date, + context, + }); + return dayCapacity > 0 && hours > dayCapacity; + }); + + const utilizationPercent = availableHours > 0 ? Math.round((bookedHours / availableHours) * 100) : 0; + + return anonymizeResource({ + ...resource, + bookingCount: resourceBookings.length, + bookedHours: Math.round(bookedHours), + availableHours: Math.round(availableHours), + utilizationPercent, + isOverbooked, + }, directory); + }); + }), + + getChargeabilityStats: controllerProcedure + .input(z.object({ includeProposed: z.boolean().default(false), resourceId: z.string().optional() })) + .query(async ({ ctx, input }) => { + const now = new Date(); + const start = new Date(now.getFullYear(), now.getMonth(), 1); + const end = new Date(now.getFullYear(), now.getMonth() + 1, 0); + + const resources = await ctx.db.resource.findMany({ + where: { + isActive: true, + ...(input.resourceId ? { id: input.resourceId } : {}), + }, + select: { + id: true, + eid: true, + displayName: true, + chapter: true, + chargeabilityTarget: true, + availability: true, + countryId: true, + federalState: true, + metroCityId: true, + country: { select: { code: true } }, + metroCity: { select: { name: true } }, + }, + }); + const bookings = await listAssignmentBookings(ctx.db, { + startDate: start, + endDate: end, + resourceIds: resources.map((resource) => resource.id), + }); + 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, + })), + start, + end, + ); + const directory = await getAnonymizationDirectory(ctx.db); + + return resources.map((resource) => { + const availability = resource.availability as unknown as WeekdayAvailability; + const context = contexts.get(resource.id); + const resourceBookings = bookings.filter((booking) => booking.resourceId === resource.id); + + const actualAllocs = resourceBookings.filter((booking) => + isChargeabilityActualBooking(booking, input.includeProposed), + ); + const expectedAllocs = resourceBookings.filter((booking) => + isChargeabilityRelevantProject(booking.project, true), + ); + + const availableHours = calculateEffectiveAvailableHours({ + availability, + periodStart: start, + periodEnd: end, + context, + }); + const actualBookedHours = actualAllocs.reduce( + (sum, booking) => sum + calculateEffectiveBookedHours({ + availability, + startDate: booking.startDate, + endDate: booking.endDate, + hoursPerDay: booking.hoursPerDay, + periodStart: start, + periodEnd: end, + context, + }), + 0, + ); + const expectedBookedHours = expectedAllocs.reduce( + (sum, booking) => sum + calculateEffectiveBookedHours({ + availability, + startDate: booking.startDate, + endDate: booking.endDate, + hoursPerDay: booking.hoursPerDay, + periodStart: start, + periodEnd: end, + context, + }), + 0, + ); + + const actualChargeability = availableHours > 0 + ? Math.round((actualBookedHours / availableHours) * 100) + : 0; + const expectedChargeability = availableHours > 0 + ? Math.round((expectedBookedHours / availableHours) * 100) + : 0; + + return anonymizeResource({ + id: resource.id, + eid: resource.eid, + displayName: resource.displayName, + chapter: resource.chapter, + chargeabilityTarget: resource.chargeabilityTarget, + actualChargeability, + expectedChargeability, + availableHours: Math.round(availableHours), + }, directory); + }); + }), +}; diff --git a/packages/api/src/router/resource-capacity-shared.ts b/packages/api/src/router/resource-capacity-shared.ts new file mode 100644 index 0000000..1efb5d5 --- /dev/null +++ b/packages/api/src/router/resource-capacity-shared.ts @@ -0,0 +1,46 @@ +import type { WeekdayAvailability } from "@capakraken/shared"; +import { calculateEffectiveBookedHours } from "../lib/resource-capacity.js"; + +type BookingForCapacity = { + startDate: Date; + endDate: Date; + hoursPerDay: number; +}; + +export function toIsoDate(value: Date): string { + return value.toISOString().slice(0, 10); +} + +export function buildDailyBookedHoursMap( + bookings: BookingForCapacity[], + availability: WeekdayAvailability, + context: Parameters[0]["context"], + periodStart: Date, + periodEnd: Date, +): Map { + const dailyBookedHours = new Map(); + const cursor = new Date(periodStart); + cursor.setUTCHours(0, 0, 0, 0); + const end = new Date(periodEnd); + end.setUTCHours(0, 0, 0, 0); + + while (cursor <= end) { + const isoDate = toIsoDate(cursor); + const bookedHours = bookings.reduce( + (sum, booking) => sum + calculateEffectiveBookedHours({ + availability, + startDate: booking.startDate, + endDate: booking.endDate, + hoursPerDay: booking.hoursPerDay, + periodStart: cursor, + periodEnd: cursor, + context, + }), + 0, + ); + dailyBookedHours.set(isoDate, bookedHours); + cursor.setUTCDate(cursor.getUTCDate() + 1); + } + + return dailyBookedHours; +} diff --git a/packages/api/src/router/resource-insights.ts b/packages/api/src/router/resource-insights.ts new file mode 100644 index 0000000..4ab2cf9 --- /dev/null +++ b/packages/api/src/router/resource-insights.ts @@ -0,0 +1,9 @@ +import { resourceAnalyticsProcedures } from "./resource-analytics.js"; +import { resourceCapacityReadProcedures } from "./resource-capacity-read.js"; +import { resourceMarketplaceReadProcedures } from "./resource-marketplace-read.js"; + +export const resourceInsightProcedures = { + ...resourceAnalyticsProcedures, + ...resourceCapacityReadProcedures, + ...resourceMarketplaceReadProcedures, +}; diff --git a/packages/api/src/router/resource-marketplace-read.ts b/packages/api/src/router/resource-marketplace-read.ts new file mode 100644 index 0000000..0cf8b24 --- /dev/null +++ b/packages/api/src/router/resource-marketplace-read.ts @@ -0,0 +1,276 @@ +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 } }, + }, + }); + + 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(); + for (const assignment of assignments) { + const items = assignmentsByResourceId.get(assignment.resourceId) ?? []; + items.push(assignment); + assignmentsByResourceId.set(assignment.resourceId, items); + } + + const utilizationMap = new Map(); + 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(); + 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(); + 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(); + 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, + }; + }), +}; diff --git a/packages/api/src/router/resource.ts b/packages/api/src/router/resource.ts index da0c23b..91baa7c 100644 --- a/packages/api/src/router/resource.ts +++ b/packages/api/src/router/resource.ts @@ -1,27 +1,9 @@ import { createAiClient, isAiConfigured, loggedAiCall } from "../ai-client.js"; -import { - isChargeabilityActualBooking, - isChargeabilityRelevantProject, - listAssignmentBookings, - recomputeResourceValueScores, -} from "@capakraken/application"; import { calculateAllocation, } from "@capakraken/engine"; import { BlueprintTarget, CreateResourceSchema, PermissionKey, ResourceRoleSchema, SkillEntrySchema, UpdateResourceSchema, inferStateFromPostalCode } from "@capakraken/shared"; -import type { WeekdayAvailability } from "@capakraken/shared"; import { assertBlueprintDynamicFields } from "./blueprint-validation.js"; -import { - anonymizeResource, - anonymizeResources, - getAnonymizationDirectory, -} from "../lib/anonymization.js"; -import { - calculateEffectiveAvailableHours, - calculateEffectiveBookedHours, - calculateEffectiveDayAvailability, - loadResourceDailyAvailabilityContexts, -} from "../lib/resource-capacity.js"; import { logger } from "../lib/logger.js"; export const DEFAULT_SUMMARY_PROMPT = `You are writing a short professional profile for an internal resource planning tool. @@ -36,57 +18,14 @@ Write a 2–3 sentence professional bio. Be specific, use skill names. No fluff. import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; -import { adminProcedure, controllerProcedure, createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js"; +import { adminProcedure, createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js"; import { ROLE_BRIEF_SELECT } from "../db/selects.js"; -import { resolveResourcePermissions } from "../lib/resource-access.js"; +import { resourceInsightProcedures } from "./resource-insights.js"; import { resourceReadProcedures } from "./resource-read.js"; -type BookingForCapacity = { - startDate: Date; - endDate: Date; - hoursPerDay: number; -}; - -function toIsoDate(value: Date): string { - return value.toISOString().slice(0, 10); -} - -function buildDailyBookedHoursMap( - bookings: BookingForCapacity[], - availability: WeekdayAvailability, - context: Parameters[0]["context"], - periodStart: Date, - periodEnd: Date, -): Map { - const dailyBookedHours = new Map(); - const cursor = new Date(periodStart); - cursor.setUTCHours(0, 0, 0, 0); - const end = new Date(periodEnd); - end.setUTCHours(0, 0, 0, 0); - - while (cursor <= end) { - const isoDate = toIsoDate(cursor); - const bookedHours = bookings.reduce( - (sum, booking) => sum + calculateEffectiveBookedHours({ - availability, - startDate: booking.startDate, - endDate: booking.endDate, - hoursPerDay: booking.hoursPerDay, - periodStart: cursor, - periodEnd: cursor, - context, - }), - 0, - ); - dailyBookedHours.set(isoDate, bookedHours); - cursor.setUTCDate(cursor.getUTCDate() + 1); - } - - return dailyBookedHours; -} - export const resourceRouter = createTRPCRouter({ ...resourceReadProcedures, + ...resourceInsightProcedures, create: managerProcedure .input(CreateResourceSchema.extend({ roles: z.array(ResourceRoleSchema).optional() })) @@ -560,415 +499,6 @@ export const resourceRouter = createTRPCRouter({ return { summary }; }), - // ─── Skills Analytics ─────────────────────────────────────────────────────── - - getSkillsAnalytics: controllerProcedure.query(async ({ ctx }) => { - const resources = await ctx.db.resource.findMany({ - where: { isActive: true }, - select: { id: true, displayName: true, chapter: true, skills: true }, - }); - - type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean }; - - // Aggregate: { skillName, category, count, totalProficiency, chapters } - 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 s of skills) { - const key = s.skill; - if (!skillMap.has(key)) { - skillMap.set(key, { - skill: s.skill, - category: s.category ?? "Uncategorized", - count: 0, - totalProficiency: 0, - chapters: new Set(), - }); - } - const entry = skillMap.get(key)!; - entry.count++; - entry.totalProficiency += s.proficiency; - if (resource.chapter) entry.chapters.add(resource.chapter); - } - } - - const aggregated = Array.from(skillMap.values()) - .map((e) => ({ - skill: e.skill, - category: e.category, - count: e.count, - avgProficiency: Math.round((e.totalProficiency / e.count) * 10) / 10, - chapters: Array.from(e.chapters), - })) - .sort((a, b) => b.count - a.count); - - const categories = [...new Set(aggregated.map((e) => e.category))].sort(); - const allChapters = [...new Set(resources.map((r) => r.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 }, - }); - - type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean }; - - const results = resources - .map((r) => { - const skills = (r.skills as unknown as SkillRow[]) ?? []; - - const matchFn = (rule: { skill: string; minProficiency: number }) => { - const s = skills.find((sk) => sk.skill.toLowerCase().includes(rule.skill.toLowerCase())); - return s && s.proficiency >= rule.minProficiency ? s : null; - }; - - const matched = rules.map(matchFn); - const passes = - operator === "AND" ? matched.every(Boolean) : matched.some(Boolean); - - if (!passes) return null; - - return { - id: r.id, - eid: r.eid, - displayName: r.displayName, - chapter: r.chapter, - matchedSkills: rules - .map((rule, i) => { - const s = matched[i]; - return s ? { skill: s.skill, proficiency: s.proficiency, category: s.category ?? "" } : null; - }) - .filter((s): s is { skill: string; proficiency: number; category: string } => s !== null), - }; - }) - .filter((r): r is NonNullable => r !== null) - .sort((a, b) => a.displayName.localeCompare(b.displayName)); - - const directory = await getAnonymizationDirectory(ctx.db); - return anonymizeResources(results, directory); - }), - - // ─── Self-service ──────────────────────────────────────────────────────────── - - /** Get the resource linked to the current user (for self-service pages). */ - 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; - }), - - // ─── Value Score ───────────────────────────────────────────────────────────── - - 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); - }), - - listWithUtilization: controllerProcedure - .input( - z.object({ - startDate: z.string().datetime().optional(), - endDate: z.string().datetime().optional(), - chapter: z.string().optional(), - includeProposed: z.boolean().default(false), - limit: z.number().int().min(1).max(500).default(100), - }), - ) - .query(async ({ ctx, input }) => { - const now = new Date(); - const start = input.startDate ? new Date(input.startDate) : new Date(now.getFullYear(), now.getMonth(), 1); - const end = input.endDate ? new Date(input.endDate) : new Date(now.getFullYear(), now.getMonth() + 3, 0); - - const resources = await ctx.db.resource.findMany({ - where: { - isActive: true, - ...(input.chapter ? { chapter: input.chapter } : {}), - }, - take: input.limit, - orderBy: { displayName: "asc" }, - select: { - id: true, - eid: true, - displayName: true, - email: true, - chapter: true, - lcrCents: true, - ucrCents: true, - currency: true, - chargeabilityTarget: true, - availability: true, - skills: true, - dynamicFields: true, - blueprintId: true, - isActive: true, - createdAt: true, - updatedAt: true, - roleId: true, - portfolioUrl: true, - postalCode: true, - federalState: true, - countryId: true, - metroCityId: true, - valueScore: true, - valueScoreBreakdown: true, - valueScoreUpdatedAt: true, - userId: true, - country: { select: { code: true } }, - metroCity: { select: { name: true } }, - }, - }); - const bookings = await listAssignmentBookings(ctx.db, { - startDate: start, - endDate: end, - resourceIds: resources.map((resource) => resource.id), - }); - const bookingsByResourceId = new Map(); - for (const booking of bookings) { - if (!booking.resourceId) { - continue; - } - const items = bookingsByResourceId.get(booking.resourceId) ?? []; - items.push(booking); - bookingsByResourceId.set(booking.resourceId, items); - } - 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, - })), - start, - end, - ); - const directory = await getAnonymizationDirectory(ctx.db); - - return resources.map((r) => { - const availability = r.availability as unknown as WeekdayAvailability; - const context = contexts.get(r.id); - const resourceBookings = (bookingsByResourceId.get(r.id) ?? []).filter( - (booking) => - booking.resourceId === r.id && - (input.includeProposed || booking.status !== "PROPOSED"), - ); - const availableHours = calculateEffectiveAvailableHours({ - availability, - periodStart: start, - periodEnd: end, - context, - }); - const bookedHours = resourceBookings.reduce( - (sum, booking) => sum + calculateEffectiveBookedHours({ - availability, - startDate: booking.startDate, - endDate: booking.endDate, - hoursPerDay: booking.hoursPerDay, - periodStart: start, - periodEnd: end, - context, - }), - 0, - ); - const dailyBookedHours = buildDailyBookedHoursMap(resourceBookings, availability, context, start, end); - const isOverbooked = Array.from(dailyBookedHours.entries()).some(([isoDate, hours]) => { - const date = new Date(`${isoDate}T00:00:00.000Z`); - const dayCapacity = calculateEffectiveDayAvailability({ - availability, - date, - context, - }); - return dayCapacity > 0 && hours > dayCapacity; - }); - - const utilizationPercent = - availableHours > 0 ? Math.round((bookedHours / availableHours) * 100) : 0; - - return anonymizeResource({ - ...r, - bookingCount: resourceBookings.length, - bookedHours: Math.round(bookedHours), - availableHours: Math.round(availableHours), - utilizationPercent, - isOverbooked, - }, directory); - }); - }), - - getChargeabilityStats: controllerProcedure - .input(z.object({ includeProposed: z.boolean().default(false), resourceId: z.string().optional() })) - .query(async ({ ctx, input }) => { - const now = new Date(); - const start = new Date(now.getFullYear(), now.getMonth(), 1); - const end = new Date(now.getFullYear(), now.getMonth() + 1, 0); - - const resources = await ctx.db.resource.findMany({ - where: { - isActive: true, - ...(input.resourceId ? { id: input.resourceId } : {}), - }, - select: { - id: true, - eid: true, - displayName: true, - chapter: true, - chargeabilityTarget: true, - availability: true, - countryId: true, - federalState: true, - metroCityId: true, - country: { select: { code: true } }, - metroCity: { select: { name: true } }, - }, - }); - const bookings = await listAssignmentBookings(ctx.db, { - startDate: start, - endDate: end, - resourceIds: resources.map((resource) => resource.id), - }); - 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, - })), - start, - end, - ); - const directory = await getAnonymizationDirectory(ctx.db); - - return resources.map((r) => { - const avail = r.availability as unknown as WeekdayAvailability; - const context = contexts.get(r.id); - const resourceBookings = bookings.filter((booking) => booking.resourceId === r.id); - - const actualAllocs = resourceBookings.filter((booking) => - isChargeabilityActualBooking(booking, input.includeProposed), - ); - - const expectedAllocs = resourceBookings.filter((booking) => - isChargeabilityRelevantProject(booking.project, true), - ); - - const availableHours = calculateEffectiveAvailableHours({ - availability: avail, - periodStart: start, - periodEnd: end, - context, - }); - const actualBookedHours = actualAllocs.reduce( - (sum, booking) => sum + calculateEffectiveBookedHours({ - availability: avail, - startDate: booking.startDate, - endDate: booking.endDate, - hoursPerDay: booking.hoursPerDay, - periodStart: start, - periodEnd: end, - context, - }), - 0, - ); - const expectedBookedHours = expectedAllocs.reduce( - (sum, booking) => sum + calculateEffectiveBookedHours({ - availability: avail, - startDate: booking.startDate, - endDate: booking.endDate, - hoursPerDay: booking.hoursPerDay, - periodStart: start, - periodEnd: end, - context, - }), - 0, - ); - const actualChargeability = availableHours > 0 - ? Math.round((actualBookedHours / availableHours) * 100) - : 0; - const expectedChargeability = availableHours > 0 - ? Math.round((expectedBookedHours / availableHours) * 100) - : 0; - - return anonymizeResource({ - id: r.id, - eid: r.eid, - displayName: r.displayName, - chapter: r.chapter, - chargeabilityTarget: r.chargeabilityTarget, - actualChargeability, - expectedChargeability, - availableHours: Math.round(availableHours), - }, directory); - }); - }), - /** * Bulk-update dynamicFields on a set of resources (merges — does not overwrite other keys). */ @@ -1002,272 +532,4 @@ export const resourceRouter = createTRPCRouter({ return { updated: input.ids.length }; }), - // ─── Skill Marketplace ──────────────────────────────────────────────────── - - getSkillMarketplace: controllerProcedure - .input( - z.object({ - // Section 1: Skill search - 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); - - type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean }; - - // ── Fetch all active resources with skills ── - const resources = await ctx.db.resource.findMany({ - where: { isActive: true }, - select: { - id: true, - displayName: true, - eid: true, - chapter: true, - skills: true, - availability: true, - chargeabilityTarget: true, - countryId: true, - federalState: true, - metroCityId: true, - country: { select: { code: true } }, - metroCity: { select: { name: true } }, - }, - }); - - // ── Fetch current assignments for utilization calc ── - const allResourceIds = resources.map((r) => r.id); - const assignments = await ctx.db.assignment.findMany({ - where: { - resourceId: { in: allResourceIds }, - 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(); - for (const assignment of assignments) { - const items = assignmentsByResourceId.get(assignment.resourceId) ?? []; - items.push(assignment); - assignmentsByResourceId.set(assignment.resourceId, items); - } - - // Build utilization map with holiday-aware daily capacity over the next 30 days. - const utilizationMap = new Map(); - for (const r of resources) { - const availability = r.availability as unknown as WeekdayAvailability; - const context = contexts.get(r.id); - const resourceAssignments = assignmentsByResourceId.get(r.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 i = 0; i < 30; i++) { - 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(r.id, { utilizationPercent, earliestAvailableDate }); - } - - // ── Section 1: Skill Search ── - 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 r of resources) { - const skills = (r.skills as unknown as SkillRow[]) ?? []; - const match = skills.find( - (s) => s.skill.toLowerCase().includes(needle) && s.proficiency >= input.minProficiency, - ); - if (!match) continue; - - const util = utilizationMap.get(r.id); - if (input.availableOnly && !util?.earliestAvailableDate) continue; - - searchResults.push({ - id: r.id, - displayName: r.displayName, - chapter: r.chapter, - skillProficiency: match.proficiency, - skillName: match.skill, - utilizationPercent: util?.utilizationPercent ?? 0, - availableFrom: util?.earliestAvailableDate?.toISOString() ?? null, - }); - } - searchResults.sort((a, b) => b.skillProficiency - a.skillProficiency || a.utilizationPercent - b.utilizationPercent); - } - - // ── Section 2: Skill Gap Heat Map ── - // Demand: from unfilled DemandRequirements + project staffingReqs skills - 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 }, - }, - }, - }); - - // Collect demanded skills from project staffingReqs - const demandSkillCounts = new Map(); - for (const demand of unfilled) { - const staffingReqs = (demand.project.staffingReqs as unknown as Array<{ - role?: string; - roleId?: string; - requiredSkills?: string[]; - }>) ?? []; - - // Match demand to staffing req by role or roleId - const matchedReq = staffingReqs.find( - (sr) => - (demand.roleId && sr.roleId === demand.roleId) || - (demand.role && sr.role === demand.role), - ); - - if (matchedReq?.requiredSkills) { - for (const skill of matchedReq.requiredSkills) { - demandSkillCounts.set(skill, (demandSkillCounts.get(skill) ?? 0) + demand.headcount); - } - } - } - - // Supply: count resources with skill at proficiency >= 3 - const supplySkillCounts = new Map(); - const allSkillCounts = new Map(); - for (const r of resources) { - const skills = (r.skills as unknown as SkillRow[]) ?? []; - for (const s of skills) { - allSkillCounts.set(s.skill, (allSkillCounts.get(s.skill) ?? 0) + 1); - if (s.proficiency >= 3) { - supplySkillCounts.set(s.skill, (supplySkillCounts.get(s.skill) ?? 0) + 1); - } - } - } - - // Merge all skill names from both demand and supply - const allGapSkills = new Set([...demandSkillCounts.keys(), ...supplySkillCounts.keys()]); - const gapData = Array.from(allGapSkills) - .map((skill) => { - const supply = supplySkillCounts.get(skill) ?? 0; - const demand = demandSkillCounts.get(skill) ?? 0; - return { skill, supply, demand, gap: demand - supply }; - }) - .sort((a, b) => b.gap - a.gap); - - // ── Section 3: Distribution (top 20 by resource count) ── - const aggregated = Array.from( - (() => { - const map = new Map(); - for (const r of resources) { - const skills = (r.skills as unknown as SkillRow[]) ?? []; - for (const s of skills) { - const entry = map.get(s.skill); - if (entry) { - entry.count++; - entry.totalProficiency += s.proficiency; - } else { - map.set(s.skill, { skill: s.skill, count: 1, totalProficiency: s.proficiency }); - } - } - } - return map; - })().values(), - ) - .map((e) => ({ - skill: e.skill, - count: e.count, - avgProficiency: Math.round((e.totalProficiency / e.count) * 10) / 10, - })) - .sort((a, b) => b.count - a.count) - .slice(0, 20); - - const directory = await getAnonymizationDirectory(ctx.db); - - return { - searchResults: anonymizeResources(searchResults, directory), - gapData, - distribution: aggregated, - totalResources: resources.length, - }; - }), });