refactor(api): extract resource insight procedures

This commit is contained in:
2026-03-31 08:05:45 +02:00
parent 565bec829e
commit d5bf3fe82a
6 changed files with 780 additions and 741 deletions
@@ -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);
}),
};
@@ -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<string, typeof bookings>();
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);
});
}),
};
@@ -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<typeof calculateEffectiveBookedHours>[0]["context"],
periodStart: Date,
periodEnd: Date,
): Map<string, number> {
const dailyBookedHours = new Map<string, number>();
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;
}
@@ -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,
};
@@ -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<string, typeof assignments>();
for (const assignment of assignments) {
const items = assignmentsByResourceId.get(assignment.resourceId) ?? [];
items.push(assignment);
assignmentsByResourceId.set(assignment.resourceId, items);
}
const utilizationMap = new Map<string, { utilizationPercent: number; earliestAvailableDate: Date | null }>();
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<string, number>();
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<string, number>();
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<string, { skill: string; count: number; totalProficiency: number }>();
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,
};
}),
};
+3 -741
View File
@@ -1,27 +1,9 @@
import { createAiClient, isAiConfigured, loggedAiCall } from "../ai-client.js"; import { createAiClient, isAiConfigured, loggedAiCall } from "../ai-client.js";
import {
isChargeabilityActualBooking,
isChargeabilityRelevantProject,
listAssignmentBookings,
recomputeResourceValueScores,
} from "@capakraken/application";
import { import {
calculateAllocation, calculateAllocation,
} from "@capakraken/engine"; } from "@capakraken/engine";
import { BlueprintTarget, CreateResourceSchema, PermissionKey, ResourceRoleSchema, SkillEntrySchema, UpdateResourceSchema, inferStateFromPostalCode } from "@capakraken/shared"; import { BlueprintTarget, CreateResourceSchema, PermissionKey, ResourceRoleSchema, SkillEntrySchema, UpdateResourceSchema, inferStateFromPostalCode } from "@capakraken/shared";
import type { WeekdayAvailability } from "@capakraken/shared";
import { assertBlueprintDynamicFields } from "./blueprint-validation.js"; 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"; import { logger } from "../lib/logger.js";
export const DEFAULT_SUMMARY_PROMPT = `You are writing a short professional profile for an internal resource planning tool. export const DEFAULT_SUMMARY_PROMPT = `You are writing a short professional profile for an internal resource planning tool.
@@ -36,57 +18,14 @@ Write a 23 sentence professional bio. Be specific, use skill names. No fluff.
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js"; 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 { 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"; 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<typeof calculateEffectiveBookedHours>[0]["context"],
periodStart: Date,
periodEnd: Date,
): Map<string, number> {
const dailyBookedHours = new Map<string, number>();
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({ export const resourceRouter = createTRPCRouter({
...resourceReadProcedures, ...resourceReadProcedures,
...resourceInsightProcedures,
create: managerProcedure create: managerProcedure
.input(CreateResourceSchema.extend({ roles: z.array(ResourceRoleSchema).optional() })) .input(CreateResourceSchema.extend({ roles: z.array(ResourceRoleSchema).optional() }))
@@ -560,415 +499,6 @@ export const resourceRouter = createTRPCRouter({
return { summary }; 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<string> }
>();
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<typeof r> => 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<string, typeof bookings>();
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). * 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 }; 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<string, typeof assignments>();
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<string, { utilizationPercent: number; earliestAvailableDate: Date | null }>();
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<string, number>();
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<string, number>();
const allSkillCounts = new Map<string, number>();
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<string, { skill: string; count: number; totalProficiency: number }>();
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,
};
}),
}); });