Files
CapaKraken/packages/api/src/router/resource.ts
T

2735 lines
95 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createAiClient, isAiConfigured, loggedAiCall } from "../ai-client.js";
import {
isChargeabilityActualBooking,
isChargeabilityRelevantProject,
listAssignmentBookings,
recomputeResourceValueScores,
} from "@capakraken/application";
import {
calculateAllocation,
deriveResourceForecast,
getMonthRange,
DEFAULT_CALCULATION_RULES,
type AssignmentSlice,
} from "@capakraken/engine";
import { VacationStatus } from "@capakraken/db";
import { BlueprintTarget, CreateResourceSchema, FieldType, PermissionKey, ResourceRoleSchema, ResourceType, SkillEntrySchema, SystemRole, UpdateResourceSchema, inferStateFromPostalCode, resolvePermissions, type PermissionOverrides } from "@capakraken/shared";
import type { CalculationRule, SpainScheduleRule } from "@capakraken/shared";
import type { WeekdayAvailability } from "@capakraken/shared";
import { assertBlueprintDynamicFields } from "./blueprint-validation.js";
import { buildDynamicFieldWhereClauses } from "./custom-field-filters.js";
import {
anonymizeResource,
anonymizeResources,
anonymizeSearchMatches,
getAnonymizationDirectory,
resolveResourceIdsByDisplayedEids,
} from "../lib/anonymization.js";
import {
asHolidayResolverDb,
collectHolidayAvailability,
getResolvedCalendarHolidays,
} from "../lib/holiday-availability.js";
import {
calculateEffectiveAvailableHours,
calculateEffectiveBookedHours,
calculateEffectiveDayAvailability,
countEffectiveWorkingDays,
loadResourceDailyAvailabilityContexts,
} from "../lib/resource-capacity.js";
import { fmtEur } from "../lib/format-utils.js";
export const DEFAULT_SUMMARY_PROMPT = `You are writing a short professional profile for an internal resource planning tool.
Artist profile:
- Role: {role}
- Chapter: {chapter}
- Main skills: {mainSkills}
- Top skills: {topSkills}
Write a 23 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, resourceOverviewProcedure } from "../trpc.js";
import { ROLE_BRIEF_SELECT } from "../db/selects.js";
import type { TRPCContext } from "../trpc.js";
function parseResourceCursor(cursor: string | undefined): { displayName: string; id: string } | null {
if (!cursor) return null;
try {
const decoded = JSON.parse(cursor) as { displayName?: string; id?: string };
if (typeof decoded.displayName === "string" && typeof decoded.id === "string") {
return { displayName: decoded.displayName, id: decoded.id };
}
} catch {
return null;
}
return null;
}
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;
}
function getAvailabilityHoursForDate(
availability: WeekdayAvailability,
date: Date,
): number {
const dayKey = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"][date.getUTCDay()] as keyof WeekdayAvailability;
return availability[dayKey] ?? 0;
}
function sumAvailabilityHoursForDates(
availability: WeekdayAvailability,
dates: Date[],
): number {
return dates.reduce((sum, date) => sum + getAvailabilityHoursForDate(availability, date), 0);
}
function formatResolvedHolidaySummary(holiday: {
date: string;
name: string;
scope: string;
calendarName: string | null;
sourceType?: string | null;
}) {
return {
date: holiday.date,
name: holiday.name,
scope: holiday.scope,
calendarName: holiday.calendarName ?? "Built-in",
sourceType: holiday.sourceType ?? "system",
};
}
const RESOURCE_SUMMARY_SELECT = {
id: true,
eid: true,
displayName: true,
chapter: true,
isActive: true,
areaRole: { select: { name: true } },
country: { select: { code: true, name: true } },
metroCity: { select: { name: true } },
orgUnit: { select: { name: true } },
} as const;
const RESOURCE_SUMMARY_DETAIL_SELECT = {
...RESOURCE_SUMMARY_SELECT,
fte: true,
lcrCents: true,
chargeabilityTarget: true,
} as const;
const RESOURCE_IDENTIFIER_SELECT = {
id: true,
eid: true,
displayName: true,
chapter: true,
isActive: true,
} as const;
const RESOURCE_IDENTIFIER_DETAIL_SELECT = {
...RESOURCE_IDENTIFIER_SELECT,
id: true,
eid: true,
displayName: true,
email: true,
chapter: true,
fte: true,
lcrCents: true,
ucrCents: true,
chargeabilityTarget: true,
isActive: true,
availability: true,
skills: true,
postalCode: true,
federalState: true,
areaRole: { select: { name: true, color: true } },
country: { select: { code: true, name: true, dailyWorkingHours: true } },
metroCity: { select: { name: true } },
managementLevelGroup: { select: { name: true, targetPercentage: true } },
orgUnit: { select: { name: true, level: true } },
_count: { select: { assignments: true, vacations: true } },
} as const;
function mapResourceSummary(resource: {
id: string;
eid: string;
displayName: string;
chapter: string | null;
isActive: boolean;
areaRole: { name: string } | null;
country: { code: string; name: string } | null;
metroCity: { name: string } | null;
orgUnit: { name: string } | null;
}) {
return {
id: resource.id,
eid: resource.eid,
name: resource.displayName,
chapter: resource.chapter,
role: resource.areaRole?.name ?? null,
country: resource.country?.name ?? resource.country?.code ?? null,
countryCode: resource.country?.code ?? null,
metroCity: resource.metroCity?.name ?? null,
orgUnit: resource.orgUnit?.name ?? null,
active: resource.isActive,
};
}
function mapResourceSummaryDetail(resource: {
id: string;
eid: string;
displayName: string;
chapter: string | null;
fte: number | null;
lcrCents: number | null;
chargeabilityTarget: number | null;
isActive: boolean;
areaRole: { name: string } | null;
country: { code: string; name: string } | null;
metroCity: { name: string } | null;
orgUnit: { name: string } | null;
}) {
return {
id: resource.id,
eid: resource.eid,
name: resource.displayName,
chapter: resource.chapter,
role: resource.areaRole?.name ?? null,
country: resource.country?.name ?? resource.country?.code ?? null,
countryCode: resource.country?.code ?? null,
metroCity: resource.metroCity?.name ?? null,
orgUnit: resource.orgUnit?.name ?? null,
fte: resource.fte,
lcr: resource.lcrCents != null ? fmtEur(resource.lcrCents) : null,
chargeabilityTarget: resource.chargeabilityTarget != null ? `${resource.chargeabilityTarget}%` : null,
active: resource.isActive,
};
}
function mapResourceDetail(resource: {
id: string;
eid: string;
displayName: string;
email: string | null;
chapter: string | null;
fte: number | null;
lcrCents: number | null;
ucrCents: number | null;
chargeabilityTarget: number | null;
isActive: boolean;
skills: unknown;
postalCode: string | null;
federalState: string | null;
areaRole: { name: string; color: string | null } | null;
country: { code: string; name: string; dailyWorkingHours: number | null } | null;
metroCity: { name: string } | null;
managementLevelGroup: { name: string; targetPercentage: number | null } | null;
orgUnit: { name: string; level: number } | null;
_count: { assignments: number; vacations: number };
}) {
const skills = Array.isArray(resource.skills) ? resource.skills as { name?: string; level?: number }[] : [];
return {
id: resource.id,
eid: resource.eid,
name: resource.displayName,
email: resource.email,
chapter: resource.chapter,
role: resource.areaRole?.name ?? null,
country: resource.country?.name ?? resource.country?.code ?? null,
countryCode: resource.country?.code ?? null,
countryHours: resource.country?.dailyWorkingHours ?? 8,
metroCity: resource.metroCity?.name ?? null,
fte: resource.fte,
lcr: resource.lcrCents != null ? fmtEur(resource.lcrCents) : null,
ucr: resource.ucrCents != null ? fmtEur(resource.ucrCents) : null,
chargeabilityTarget: resource.chargeabilityTarget != null ? `${resource.chargeabilityTarget}%` : null,
managementLevel: resource.managementLevelGroup?.name ?? null,
orgUnit: resource.orgUnit?.name ?? null,
postalCode: resource.postalCode,
federalState: resource.federalState,
active: resource.isActive,
totalAssignments: resource._count.assignments,
totalVacations: resource._count.vacations,
skillCount: skills.length,
topSkills: skills.slice(0, 10).map((skill) => `${skill.name ?? "?"} (${skill.level ?? "?"})`),
};
}
function summarizeResolvedHolidaySummary(holidays: Array<ReturnType<typeof formatResolvedHolidaySummary>>) {
const byScope = new Map<string, number>();
const bySourceType = new Map<string, number>();
const byCalendar = new Map<string, number>();
for (const holiday of holidays) {
byScope.set(holiday.scope, (byScope.get(holiday.scope) ?? 0) + 1);
bySourceType.set(holiday.sourceType, (bySourceType.get(holiday.sourceType) ?? 0) + 1);
byCalendar.set(holiday.calendarName, (byCalendar.get(holiday.calendarName) ?? 0) + 1);
}
return {
byScope: [...byScope.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([scope, count]) => ({ scope, count })),
bySourceType: [...bySourceType.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([sourceType, count]) => ({ sourceType, count })),
byCalendar: [...byCalendar.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([calendarName, count]) => ({ calendarName, count })),
};
}
function round1(value: number): number {
return Math.round(value * 10) / 10;
}
function averagePerWorkingDay(totalHours: number, workingDays: number): number {
return workingDays > 0 ? round1(totalHours / workingDays) : 0;
}
type ResourceReadContext = Pick<TRPCContext, "db" | "dbUser" | "roleDefaults">;
function resolveResourcePermissions(ctx: Pick<TRPCContext, "dbUser" | "roleDefaults">): Set<PermissionKey> {
if (!ctx.dbUser) {
return new Set();
}
return resolvePermissions(
ctx.dbUser.systemRole as SystemRole,
ctx.dbUser.permissionOverrides as PermissionOverrides | null,
ctx.roleDefaults ?? undefined,
);
}
function canReadAllResources(ctx: Pick<TRPCContext, "dbUser" | "roleDefaults">): boolean {
const permissions = resolveResourcePermissions(ctx);
return permissions.has(PermissionKey.VIEW_ALL_RESOURCES) || permissions.has(PermissionKey.MANAGE_RESOURCES);
}
async function findOwnedResourceId(ctx: ResourceReadContext): Promise<string | null> {
if (!ctx.dbUser?.id) {
return null;
}
if (!ctx.db.resource || typeof ctx.db.resource.findFirst !== "function") {
return null;
}
const resource = await ctx.db.resource.findFirst({
where: { userId: ctx.dbUser.id },
select: { id: true },
});
return resource?.id ?? null;
}
async function assertCanReadResource(
ctx: ResourceReadContext,
resourceId: string,
message = "You can only view your own resource data",
): Promise<void> {
if (canReadAllResources(ctx)) {
return;
}
const ownedResourceId = await findOwnedResourceId(ctx);
if (!ownedResourceId || ownedResourceId !== resourceId) {
throw new TRPCError({
code: "FORBIDDEN",
message,
});
}
}
function isBroadResourceLookupAllowed(ctx: Pick<TRPCContext, "dbUser" | "roleDefaults">): boolean {
return canReadAllResources(ctx);
}
const ResourceDirectoryQuerySchema = z.object({
chapter: z.string().optional(),
chapters: z.array(z.string()).optional(),
isActive: z.boolean().optional().default(true),
search: z.string().optional(),
eids: z.array(z.string()).optional(),
countryIds: z.array(z.string()).optional(),
excludedCountryIds: z.array(z.string()).optional(),
includeWithoutCountry: z.boolean().optional().default(true),
resourceTypes: z.array(z.nativeEnum(ResourceType)).optional(),
excludedResourceTypes: z.array(z.nativeEnum(ResourceType)).optional(),
includeWithoutResourceType: z.boolean().optional().default(true),
rolledOff: z.boolean().optional(),
departed: z.boolean().optional(),
page: z.number().int().min(1).default(1),
limit: z.number().int().min(1).max(500).default(50),
cursor: z.string().optional(),
});
const ResourceListQuerySchema = ResourceDirectoryQuerySchema.extend({
includeRoles: z.boolean().optional().default(false),
customFieldFilters: z.array(z.object({
key: z.string(),
value: z.string(),
type: z.nativeEnum(FieldType),
})).optional(),
});
async function listStaffResources(
ctx: Pick<TRPCContext, "db">,
input: z.infer<typeof ResourceListQuerySchema>,
) {
const {
chapter,
chapters,
isActive,
search,
eids,
countryIds,
excludedCountryIds,
includeWithoutCountry,
resourceTypes,
excludedResourceTypes,
includeWithoutResourceType,
rolledOff,
departed,
page,
limit,
includeRoles,
cursor,
customFieldFilters,
} = input;
const parsedCursor = parseResourceCursor(cursor);
const cfConditions = buildDynamicFieldWhereClauses(customFieldFilters).map((dynamicFields) => ({ dynamicFields }));
type WhereClause = Record<string, unknown>;
const andClauses: WhereClause[] = [];
const chapterFilters = Array.from(
new Set([
...(chapter ? [chapter] : []),
...(chapters ?? []),
]),
);
const directory = await getAnonymizationDirectory(ctx.db);
if (!eids) {
andClauses.push({ isActive });
}
if (eids && !directory) {
andClauses.push({ eid: { in: eids } });
}
if (chapterFilters.length === 1) {
andClauses.push({ chapter: chapterFilters[0] });
} else if (chapterFilters.length > 1) {
andClauses.push({ chapter: { in: chapterFilters } });
}
if (search && !directory) {
andClauses.push({
OR: [
{ displayName: { contains: search, mode: "insensitive" as const } },
{ eid: { contains: search, mode: "insensitive" as const } },
{ email: { contains: search, mode: "insensitive" as const } },
],
});
}
if (countryIds && countryIds.length > 0) {
const countryClauses: WhereClause[] = [{ countryId: { in: countryIds } }];
if (includeWithoutCountry) {
countryClauses.push({ countryId: null });
}
andClauses.push(countryClauses.length === 1 ? countryClauses[0]! : { OR: countryClauses });
}
if (excludedCountryIds && excludedCountryIds.length > 0) {
andClauses.push({ NOT: { countryId: { in: excludedCountryIds } } });
}
if (!includeWithoutCountry) {
andClauses.push({ NOT: { countryId: null } });
}
if (resourceTypes && resourceTypes.length > 0) {
const resourceTypeClauses: WhereClause[] = [{ resourceType: { in: resourceTypes } }];
if (includeWithoutResourceType) {
resourceTypeClauses.push({ resourceType: null });
}
andClauses.push(
resourceTypeClauses.length === 1 ? resourceTypeClauses[0]! : { OR: resourceTypeClauses },
);
}
if (excludedResourceTypes && excludedResourceTypes.length > 0) {
andClauses.push({ NOT: { resourceType: { in: excludedResourceTypes } } });
}
if (!includeWithoutResourceType) {
andClauses.push({ NOT: { resourceType: null } });
}
if (rolledOff !== undefined) {
andClauses.push({ rolledOff });
}
if (departed !== undefined) {
andClauses.push({ departed });
}
andClauses.push(...cfConditions);
const where = andClauses.length > 0 ? { AND: andClauses } : {};
if (directory) {
const rawResources = await (includeRoles
? ctx.db.resource.findMany({
where,
include: {
resourceRoles: {
include: { role: { select: ROLE_BRIEF_SELECT } },
},
},
orderBy: [{ displayName: "asc" }, { id: "asc" }],
})
: ctx.db.resource.findMany({
where,
orderBy: [{ displayName: "asc" }, { id: "asc" }],
}));
const directoryResources = rawResources.map((resource) => ({
id: resource.id,
eid: resource.eid,
displayName: resource.displayName,
email: resource.email,
}));
const requestedIds = eids
? resolveResourceIdsByDisplayedEids(directoryResources, directory, eids)
: [];
const requestedIdSet = requestedIds.length > 0 ? new Set(requestedIds) : null;
const filteredResources = rawResources.filter((resource) => {
const alias = directory.byResourceId.get(resource.id);
if (requestedIdSet && !requestedIdSet.has(resource.id)) {
return false;
}
if (eids && eids.length > 0 && requestedIds.length === 0) {
return false;
}
if (search && !anonymizeSearchMatches(
{
id: resource.id,
eid: resource.eid,
displayName: resource.displayName,
email: resource.email,
},
alias,
search,
)) {
return false;
}
return true;
});
const anonymizedResources = anonymizeResources(filteredResources, directory).sort((left, right) => {
const displayNameCompare = left.displayName.localeCompare(right.displayName);
if (displayNameCompare !== 0) {
return displayNameCompare;
}
return left.id.localeCompare(right.id);
});
const total = anonymizedResources.length;
const afterCursor = parsedCursor
? anonymizedResources.filter(
(resource) =>
resource.displayName > parsedCursor.displayName ||
(resource.displayName === parsedCursor.displayName && resource.id > parsedCursor.id),
)
: anonymizedResources;
const skip = cursor ? 0 : (page - 1) * limit;
const paged = afterCursor.slice(skip, skip + limit + 1);
const hasMore = paged.length > limit;
const resources = hasMore ? paged.slice(0, limit) : paged;
const nextCursor = hasMore
? JSON.stringify({
displayName: resources[resources.length - 1]!.displayName,
id: resources[resources.length - 1]!.id,
})
: null;
return { resources, total, page, limit, nextCursor };
}
const skip = cursor ? 0 : (page - 1) * limit;
const orderBy = [{ displayName: "asc" as const }, { id: "asc" as const }];
const whereWithCursor = parsedCursor
? {
AND: [
...((where as { AND?: WhereClause[] }).AND ?? []),
{
OR: [
{ displayName: { gt: parsedCursor.displayName } },
{ displayName: parsedCursor.displayName, id: { gt: parsedCursor.id } },
],
},
],
}
: where;
const baseQuery = { where: whereWithCursor, skip, take: limit + 1, orderBy };
const [rawResources, total] = await Promise.all([
includeRoles
? ctx.db.resource.findMany({
...baseQuery,
include: {
resourceRoles: {
include: { role: { select: ROLE_BRIEF_SELECT } },
},
},
})
: ctx.db.resource.findMany(baseQuery),
ctx.db.resource.count({ where }),
]);
const hasMore = rawResources.length > limit;
const resources = hasMore ? rawResources.slice(0, limit) : rawResources;
const nextCursor = hasMore
? JSON.stringify({
displayName: resources[resources.length - 1]!.displayName,
id: resources[resources.length - 1]!.id,
})
: null;
return { resources, total, page, limit, nextCursor };
}
function buildResourceSummaryWhere(input: {
search?: string;
country?: string;
metroCity?: string;
orgUnit?: string;
roleName?: string;
isActive: boolean;
}) {
const where: Record<string, unknown> = {};
if (input.isActive !== false) {
where.isActive = true;
}
if (input.search) {
where.OR = [
{ displayName: { contains: input.search, mode: "insensitive" as const } },
{ eid: { contains: input.search, mode: "insensitive" as const } },
{ chapter: { contains: input.search, mode: "insensitive" as const } },
];
}
if (input.country) {
where.country = {
OR: [
{ code: { equals: input.country, mode: "insensitive" as const } },
{ name: { contains: input.country, mode: "insensitive" as const } },
],
};
}
if (input.metroCity) {
where.metroCity = { name: { contains: input.metroCity, mode: "insensitive" as const } };
}
if (input.orgUnit) {
where.orgUnit = { name: { contains: input.orgUnit, mode: "insensitive" as const } };
}
if (input.roleName) {
where.areaRole = { name: { contains: input.roleName, mode: "insensitive" as const } };
}
return where;
}
function assertCanSearchResourceSummaries(ctx: Pick<TRPCContext, "dbUser" | "roleDefaults">) {
if (!canReadAllResources(ctx)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You need resource overview access to search resource summaries",
});
}
}
async function readResourceSummariesSnapshot(
ctx: Pick<TRPCContext, "db" | "dbUser" | "roleDefaults">,
input: {
search?: string;
country?: string;
metroCity?: string;
orgUnit?: string;
roleName?: string;
isActive: boolean;
limit: number;
},
) {
assertCanSearchResourceSummaries(ctx);
return ctx.db.resource.findMany({
where: buildResourceSummaryWhere(input),
select: RESOURCE_SUMMARY_SELECT,
take: input.limit,
orderBy: { displayName: "asc" },
});
}
async function readResourceSummaryDetailsSnapshot(
ctx: Pick<TRPCContext, "db" | "dbUser" | "roleDefaults">,
input: {
search?: string;
country?: string;
metroCity?: string;
orgUnit?: string;
roleName?: string;
isActive: boolean;
limit: number;
},
) {
assertCanSearchResourceSummaries(ctx);
return ctx.db.resource.findMany({
where: buildResourceSummaryWhere(input),
select: RESOURCE_SUMMARY_DETAIL_SELECT,
take: input.limit,
orderBy: { displayName: "asc" },
});
}
async function resolveResourceIdentifierSnapshot(
ctx: ResourceReadContext,
identifier: string,
forbiddenMessage = "You can only view your own resource unless you have staff access",
) {
let resource = await ctx.db.resource.findUnique({
where: { id: identifier },
select: RESOURCE_IDENTIFIER_SELECT,
});
if (!resource) {
resource = await ctx.db.resource.findUnique({
where: { eid: identifier },
select: RESOURCE_IDENTIFIER_SELECT,
});
}
if (!resource) {
resource = await ctx.db.resource.findFirst({
where: { displayName: { equals: identifier, mode: "insensitive" } },
select: RESOURCE_IDENTIFIER_SELECT,
});
}
if (!resource && isBroadResourceLookupAllowed(ctx)) {
resource = await ctx.db.resource.findFirst({
where: { displayName: { contains: identifier, mode: "insensitive" } },
select: RESOURCE_IDENTIFIER_SELECT,
});
}
if (!resource && isBroadResourceLookupAllowed(ctx)) {
const words = identifier.split(/[\s,._\-/]+/).filter((word) => word.length >= 2);
if (words.length > 0) {
const candidates = await ctx.db.resource.findMany({
where: {
OR: words.map((word) => ({
displayName: { contains: word, mode: "insensitive" as const },
})),
},
select: RESOURCE_IDENTIFIER_SELECT,
take: 5,
});
if (candidates.length === 1) {
resource = candidates[0]!;
} else if (candidates.length > 1) {
return {
error: `Resource not found: "${identifier}". Did you mean one of these?`,
suggestions: candidates.map((candidate) => ({
id: candidate.id,
eid: candidate.eid,
name: candidate.displayName,
})),
} as const;
}
}
}
if (!resource) {
return { error: `Resource not found: ${identifier}` } as const;
}
await assertCanReadResource(
ctx,
resource.id,
forbiddenMessage,
);
return resource;
}
async function readResourceByIdentifierDetailSnapshot(
ctx: ResourceReadContext,
identifier: string,
) {
const resource = await resolveResourceIdentifierSnapshot(ctx, identifier);
if ("error" in resource) {
return resource;
}
const detail = await ctx.db.resource.findUnique({
where: { id: resource.id },
select: RESOURCE_IDENTIFIER_DETAIL_SELECT,
});
if (!detail) {
return { error: `Resource not found: ${identifier}` } as const;
}
return detail;
}
export const resourceRouter = createTRPCRouter({
resolveByIdentifier: protectedProcedure
.input(z.object({ identifier: z.string() }))
.query(async ({ ctx, input }) => {
const resource = await resolveResourceIdentifierSnapshot(
ctx,
input.identifier,
"You can only resolve your own resource unless you have staff access",
);
if ("error" in resource) {
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
}
return resource;
}),
resolveResponsiblePersonName: resourceOverviewProcedure
.input(z.object({ name: z.string() }))
.query(async ({ ctx, input }) => {
const exact = await ctx.db.resource.findFirst({
where: { displayName: { equals: input.name, mode: "insensitive" }, isActive: true },
select: { displayName: true },
});
if (exact) {
return {
status: "resolved" as const,
displayName: exact.displayName,
};
}
const candidates = await ctx.db.resource.findMany({
where: { displayName: { contains: input.name, mode: "insensitive" }, isActive: true },
select: { displayName: true, eid: true },
take: 5,
});
if (candidates.length === 1) {
return {
status: "resolved" as const,
displayName: candidates[0]!.displayName,
};
}
if (candidates.length > 1) {
return {
status: "ambiguous" as const,
message: `Multiple resources match "${input.name}": ${candidates.map((candidate) => `${candidate.displayName} (${candidate.eid})`).join(", ")}. Please specify the exact name.`,
candidates,
};
}
return {
status: "missing" as const,
message: `No active resource found matching "${input.name}". The responsible person must be an existing resource.`,
candidates: [],
};
}),
getChargeabilitySummary: protectedProcedure
.input(z.object({
resourceId: z.string(),
month: z.string().regex(/^\d{4}-\d{2}$/),
}))
.query(async ({ ctx, input }) => {
await assertCanReadResource(
ctx,
input.resourceId,
"You can only view chargeability details for your own resource unless you have staff access",
);
const [year, month] = input.month.split("-").map(Number) as [number, number];
const { start: monthStart, end: monthEnd } = getMonthRange(year, month);
const resource = await ctx.db.resource.findUniqueOrThrow({
where: { id: input.resourceId },
select: {
id: true,
displayName: true,
eid: true,
fte: true,
lcrCents: true,
chargeabilityTarget: true,
countryId: true,
federalState: true,
metroCityId: true,
availability: true,
country: { select: { id: true, code: true, name: true, dailyWorkingHours: true, scheduleRules: true } },
metroCity: { select: { id: true, name: true } },
managementLevelGroup: { select: { id: true, name: true, targetPercentage: true } },
},
});
const dailyHours = resource.country?.dailyWorkingHours ?? 8;
const scheduleRules = resource.country?.scheduleRules as SpainScheduleRule | null;
const targetRatio = resource.managementLevelGroup?.targetPercentage ?? (resource.chargeabilityTarget / 100);
const availability = resource.availability as WeekdayAvailability | null;
const weeklyAvailability: WeekdayAvailability = availability ?? {
monday: dailyHours,
tuesday: dailyHours,
wednesday: dailyHours,
thursday: dailyHours,
friday: dailyHours,
saturday: 0,
sunday: 0,
};
const assignments = await ctx.db.assignment.findMany({
where: {
resourceId: input.resourceId,
startDate: { lte: monthEnd },
endDate: { gte: monthStart },
status: { in: ["CONFIRMED", "ACTIVE", "PROPOSED"] },
},
select: {
id: true,
hoursPerDay: true,
startDate: true,
endDate: true,
dailyCostCents: true,
status: true,
project: {
select: {
id: true,
name: true,
shortCode: true,
orderType: true,
utilizationCategory: { select: { code: true } },
},
},
},
});
const vacations = await ctx.db.vacation.findMany({
where: {
resourceId: input.resourceId,
status: VacationStatus.APPROVED,
startDate: { lte: monthEnd },
endDate: { gte: monthStart },
},
select: { startDate: true, endDate: true, type: true, isHalfDay: true },
});
const resolvedHolidays = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), {
periodStart: monthStart,
periodEnd: monthEnd,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
});
const holidayAvailability = collectHolidayAvailability({
vacations,
periodStart: monthStart,
periodEnd: monthEnd,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityName: resource.metroCity?.name,
resolvedHolidayStrings: resolvedHolidays.map((holiday) => holiday.date),
});
const absenceDays = holidayAvailability.absenceDays;
const contexts = await loadResourceDailyAvailabilityContexts(
ctx.db,
[{
id: resource.id,
availability: weeklyAvailability,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
}],
monthStart,
monthEnd,
);
const availabilityContext = contexts.get(resource.id);
let calcRules: CalculationRule[] = DEFAULT_CALCULATION_RULES;
try {
const dbRules = await ctx.db.calculationRule.findMany({
where: { isActive: true },
orderBy: [{ priority: "desc" }],
});
if (dbRules.length > 0) {
calcRules = dbRules as unknown as CalculationRule[];
}
} catch {
// table may not exist yet
}
const baseWorkingDays = countEffectiveWorkingDays({
availability: weeklyAvailability,
periodStart: monthStart,
periodEnd: monthEnd,
context: undefined,
});
const effectiveWorkingDays = countEffectiveWorkingDays({
availability: weeklyAvailability,
periodStart: monthStart,
periodEnd: monthEnd,
context: availabilityContext,
});
const baseAvailableHours = calculateEffectiveAvailableHours({
availability: weeklyAvailability,
periodStart: monthStart,
periodEnd: monthEnd,
context: undefined,
});
const effectiveAvailableHours = calculateEffectiveAvailableHours({
availability: weeklyAvailability,
periodStart: monthStart,
periodEnd: monthEnd,
context: availabilityContext,
});
const publicHolidayDates = resolvedHolidays.map((holiday) => new Date(`${holiday.date}T00:00:00.000Z`));
const publicHolidayWorkdayCount = publicHolidayDates.reduce((count, date) => (
count + (getAvailabilityHoursForDate(weeklyAvailability, date) > 0 ? 1 : 0)
), 0);
const publicHolidayHoursDeduction = sumAvailabilityHoursForDates(
weeklyAvailability,
publicHolidayDates,
);
const absenceDayEquivalent = absenceDays.reduce((sum, absence) => {
if (absence.type === "PUBLIC_HOLIDAY") {
return sum;
}
return sum + (absence.isHalfDay ? 0.5 : 1);
}, 0);
const absenceHoursDeduction = absenceDays.reduce((sum, absence) => {
if (absence.type === "PUBLIC_HOLIDAY") {
return sum;
}
const baseHours = getAvailabilityHoursForDate(weeklyAvailability, absence.date);
return sum + baseHours * (absence.isHalfDay ? 0.5 : 1);
}, 0);
const slices: AssignmentSlice[] = [];
const assignmentBreakdown: Array<{
project: string;
code: string;
hours: number;
status: string;
}> = [];
let totalBookedHours = 0;
for (const assignment of assignments) {
const overlapStart = new Date(Math.max(monthStart.getTime(), assignment.startDate.getTime()));
const overlapEnd = new Date(Math.min(monthEnd.getTime(), assignment.endDate.getTime()));
const categoryCode = assignment.project.utilizationCategory?.code ?? "Chg";
const calcResult = calculateAllocation({
lcrCents: resource.lcrCents,
hoursPerDay: assignment.hoursPerDay,
startDate: overlapStart,
endDate: overlapEnd,
availability: weeklyAvailability,
absenceDays,
calculationRules: calcRules,
orderType: assignment.project.orderType,
projectId: assignment.project.id,
});
if (calcResult.workingDays <= 0 && calcResult.totalHours <= 0) {
continue;
}
totalBookedHours += calcResult.totalHours;
assignmentBreakdown.push({
project: assignment.project.name,
code: assignment.project.shortCode,
hours: round1(calcResult.totalHours),
status: assignment.status,
});
slices.push({
hoursPerDay: assignment.hoursPerDay,
workingDays: calcResult.workingDays,
categoryCode,
...(calcResult.totalChargeableHours !== undefined
? { totalChargeableHours: calcResult.totalChargeableHours }
: {}),
});
}
const forecast = deriveResourceForecast({
fte: resource.fte,
targetPercentage: targetRatio,
assignments: slices,
sah: effectiveAvailableHours,
});
const formattedHolidays = resolvedHolidays.map((holiday) => formatResolvedHolidaySummary({
...holiday,
calendarName: holiday.calendarName ?? "Built-in",
sourceType: holiday.sourceType ?? "system",
}));
const workingDays = round1(effectiveWorkingDays);
const baseWorkingDaysRounded = round1(baseWorkingDays);
const baseAvailableHoursRounded = round1(baseAvailableHours);
const availableHours = round1(effectiveAvailableHours);
const bookedHours = round1(totalBookedHours);
const targetPct = round1(targetRatio * 100);
const targetHours = availableHours > 0 ? round1((availableHours * targetPct) / 100) : 0;
const chargeabilityPct = round1(forecast.chg * 100);
const unassignedHours = round1(Math.max(0, availableHours - bookedHours));
return {
resource: resource.displayName,
eid: resource.eid,
month: input.month,
periodStart: monthStart.toISOString().slice(0, 10),
periodEnd: monthEnd.toISOString().slice(0, 10),
fte: round1(resource.fte),
target: `${targetPct}%`,
targetPct,
targetHours,
workingDays,
baseWorkingDays: baseWorkingDaysRounded,
locationContext: {
countryCode: resource.country?.code ?? null,
country: resource.country?.name ?? resource.country?.code ?? null,
federalState: resource.federalState ?? null,
metroCity: resource.metroCity?.name ?? null,
},
baseAvailableHours: baseAvailableHoursRounded,
availableHours,
bookedHours,
unassignedHours,
chargeability: `${chargeabilityPct}%`,
chargeabilityPct,
onTarget: chargeabilityPct >= targetPct,
holidaySummary: {
count: formattedHolidays.length,
workdayCount: round1(publicHolidayWorkdayCount),
hoursDeduction: round1(publicHolidayHoursDeduction),
holidays: formattedHolidays,
breakdown: summarizeResolvedHolidaySummary(formattedHolidays),
},
absenceSummary: {
dayEquivalent: round1(absenceDayEquivalent),
hoursDeduction: round1(absenceHoursDeduction),
},
capacityBreakdown: {
formula: "baseAvailableHours - holidayHoursDeduction - absenceHoursDeduction = availableHours",
baseAvailableHours: baseAvailableHoursRounded,
holidayHoursDeduction: round1(publicHolidayHoursDeduction),
absenceHoursDeduction: round1(absenceHoursDeduction),
availableHours,
},
averages: {
availableHoursPerWorkingDay: averagePerWorkingDay(availableHours, workingDays),
bookedHoursPerWorkingDay: averagePerWorkingDay(bookedHours, workingDays),
remainingHoursPerWorkingDay: averagePerWorkingDay(Math.max(0, availableHours - bookedHours), workingDays),
},
allocations: assignmentBreakdown,
scheduleContext: {
dailyWorkingHours: dailyHours,
hasScheduleRules: Boolean(scheduleRules),
},
};
}),
listSummaries: resourceOverviewProcedure
.input(z.object({
search: z.string().optional(),
country: z.string().optional(),
metroCity: z.string().optional(),
orgUnit: z.string().optional(),
roleName: z.string().optional(),
isActive: z.boolean().optional().default(true),
limit: z.number().int().min(1).max(100).default(50),
}))
.query(async ({ ctx, input }) => {
const resources = await readResourceSummariesSnapshot(ctx, {
isActive: input.isActive,
limit: input.limit,
...(input.search ? { search: input.search } : {}),
...(input.country ? { country: input.country } : {}),
...(input.metroCity ? { metroCity: input.metroCity } : {}),
...(input.orgUnit ? { orgUnit: input.orgUnit } : {}),
...(input.roleName ? { roleName: input.roleName } : {}),
});
return resources.map(mapResourceSummary);
}),
listSummariesDetail: resourceOverviewProcedure
.input(z.object({
search: z.string().optional(),
country: z.string().optional(),
metroCity: z.string().optional(),
orgUnit: z.string().optional(),
roleName: z.string().optional(),
isActive: z.boolean().optional().default(true),
limit: z.number().int().min(1).max(100).default(50),
}))
.query(async ({ ctx, input }) => {
const resources = await readResourceSummaryDetailsSnapshot(ctx, {
isActive: input.isActive,
limit: input.limit,
...(input.search ? { search: input.search } : {}),
...(input.country ? { country: input.country } : {}),
...(input.metroCity ? { metroCity: input.metroCity } : {}),
...(input.orgUnit ? { orgUnit: input.orgUnit } : {}),
...(input.roleName ? { roleName: input.roleName } : {}),
});
return resources.map(mapResourceSummaryDetail);
}),
directory: protectedProcedure
.input(ResourceDirectoryQuerySchema)
.query(async ({ ctx, input }) => {
const {
chapter,
chapters,
isActive,
search,
eids,
countryIds,
excludedCountryIds,
includeWithoutCountry,
resourceTypes,
excludedResourceTypes,
includeWithoutResourceType,
rolledOff,
departed,
page,
limit,
cursor,
} = input;
const parsedCursor = parseResourceCursor(cursor);
type WhereClause = Record<string, unknown>;
const andClauses: WhereClause[] = [];
const chapterFilters = Array.from(
new Set([
...(chapter ? [chapter] : []),
...(chapters ?? []),
]),
);
const directory = await getAnonymizationDirectory(ctx.db);
if (!eids) {
andClauses.push({ isActive });
}
if (eids && !directory) {
andClauses.push({ eid: { in: eids } });
}
if (chapterFilters.length === 1) {
andClauses.push({ chapter: chapterFilters[0] });
} else if (chapterFilters.length > 1) {
andClauses.push({ chapter: { in: chapterFilters } });
}
if (search && !directory) {
andClauses.push({
OR: [
{ displayName: { contains: search, mode: "insensitive" as const } },
{ eid: { contains: search, mode: "insensitive" as const } },
],
});
}
if (countryIds && countryIds.length > 0) {
const countryClauses: WhereClause[] = [{ countryId: { in: countryIds } }];
if (includeWithoutCountry) {
countryClauses.push({ countryId: null });
}
andClauses.push(countryClauses.length === 1 ? countryClauses[0]! : { OR: countryClauses });
}
if (excludedCountryIds && excludedCountryIds.length > 0) {
andClauses.push({ NOT: { countryId: { in: excludedCountryIds } } });
}
if (!includeWithoutCountry) {
andClauses.push({ NOT: { countryId: null } });
}
if (resourceTypes && resourceTypes.length > 0) {
const resourceTypeClauses: WhereClause[] = [{ resourceType: { in: resourceTypes } }];
if (includeWithoutResourceType) {
resourceTypeClauses.push({ resourceType: null });
}
andClauses.push(
resourceTypeClauses.length === 1 ? resourceTypeClauses[0]! : { OR: resourceTypeClauses },
);
}
if (excludedResourceTypes && excludedResourceTypes.length > 0) {
andClauses.push({ NOT: { resourceType: { in: excludedResourceTypes } } });
}
if (!includeWithoutResourceType) {
andClauses.push({ NOT: { resourceType: null } });
}
if (rolledOff !== undefined) {
andClauses.push({ rolledOff });
}
if (departed !== undefined) {
andClauses.push({ departed });
}
const where = andClauses.length > 0 ? { AND: andClauses } : {};
const orderBy = [{ displayName: "asc" as const }, { id: "asc" as const }];
if (directory) {
const rawResources = await ctx.db.resource.findMany({
where,
select: {
id: true,
eid: true,
displayName: true,
chapter: true,
isActive: true,
email: true,
},
orderBy,
});
const requestedIds = eids
? resolveResourceIdsByDisplayedEids(rawResources, directory, eids)
: [];
const requestedIdSet = requestedIds.length > 0 ? new Set(requestedIds) : null;
const filteredResources = rawResources.filter((resource) => {
const alias = directory.byResourceId.get(resource.id);
if (requestedIdSet && !requestedIdSet.has(resource.id)) {
return false;
}
if (eids && eids.length > 0 && requestedIds.length === 0) {
return false;
}
if (search && !anonymizeSearchMatches(
{
id: resource.id,
eid: resource.eid,
displayName: resource.displayName,
email: resource.email,
},
alias,
search,
)) {
return false;
}
return true;
});
const anonymizedResources = anonymizeResources(filteredResources, directory).sort((left, right) => {
const displayNameCompare = left.displayName.localeCompare(right.displayName);
if (displayNameCompare !== 0) {
return displayNameCompare;
}
return left.id.localeCompare(right.id);
});
const total = anonymizedResources.length;
const afterCursor = parsedCursor
? anonymizedResources.filter(
(resource) =>
resource.displayName > parsedCursor.displayName ||
(resource.displayName === parsedCursor.displayName && resource.id > parsedCursor.id),
)
: anonymizedResources;
const skip = cursor ? 0 : (page - 1) * limit;
const paged = afterCursor.slice(skip, skip + limit + 1);
const hasMore = paged.length > limit;
const resources = (hasMore ? paged.slice(0, limit) : paged).map((resource) => ({
id: resource.id,
eid: resource.eid,
displayName: resource.displayName,
chapter: resource.chapter,
isActive: resource.isActive,
}));
const nextCursor = hasMore
? JSON.stringify({
displayName: resources[resources.length - 1]!.displayName,
id: resources[resources.length - 1]!.id,
})
: null;
return { resources, total, page, limit, nextCursor };
}
const skip = cursor ? 0 : (page - 1) * limit;
const whereWithCursor = parsedCursor
? {
AND: [
...((where as { AND?: WhereClause[] }).AND ?? []),
{
OR: [
{ displayName: { gt: parsedCursor.displayName } },
{ displayName: parsedCursor.displayName, id: { gt: parsedCursor.id } },
],
},
],
}
: where;
const [rawResources, total] = await Promise.all([
ctx.db.resource.findMany({
where: whereWithCursor,
skip,
take: limit + 1,
orderBy,
select: {
id: true,
eid: true,
displayName: true,
chapter: true,
isActive: true,
},
}),
ctx.db.resource.count({ where }),
]);
const hasMore = rawResources.length > limit;
const resources = hasMore ? rawResources.slice(0, limit) : rawResources;
const nextCursor = hasMore
? JSON.stringify({
displayName: resources[resources.length - 1]!.displayName,
id: resources[resources.length - 1]!.id,
})
: null;
return { resources, total, page, limit, nextCursor };
}),
listStaff: resourceOverviewProcedure
.input(ResourceListQuerySchema)
.query(async ({ ctx, input }) => listStaffResources(ctx, input)),
/** Lightweight resource card for hover tooltips on the timeline. */
getHoverCard: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const resource = await findUniqueOrThrow(
ctx.db.resource.findUnique({
where: { id: input.id },
select: {
id: true,
displayName: true,
eid: true,
email: true,
chapter: true,
lcrCents: true,
ucrCents: true,
currency: true,
chargeabilityTarget: true,
skills: true,
availability: true,
isActive: true,
areaRole: { select: ROLE_BRIEF_SELECT },
country: { select: { name: true, code: true } },
managementLevel: { select: { name: true } },
resourceType: true,
},
}),
"Resource",
);
await assertCanReadResource(
ctx,
resource.id,
"You can only view hover details for your own resource unless you have staff access",
);
const directory = await getAnonymizationDirectory(ctx.db);
const anon = anonymizeResource(resource, directory);
return {
id: anon.id,
displayName: anon.displayName ?? "",
eid: anon.eid ?? "",
chapter: resource.chapter,
lcrCents: resource.lcrCents,
ucrCents: resource.ucrCents,
currency: resource.currency,
chargeabilityTarget: resource.chargeabilityTarget,
skills: resource.skills as Record<string, unknown>[],
isActive: resource.isActive,
resourceType: resource.resourceType,
areaRole: resource.areaRole,
country: resource.country,
managementLevel: resource.managementLevel,
};
}),
getByIdentifier: protectedProcedure
.input(z.object({ identifier: z.string() }))
.query(async ({ ctx, input }) => resolveResourceIdentifierSnapshot(ctx, input.identifier)),
getByIdentifierDetail: protectedProcedure
.input(z.object({ identifier: z.string() }))
.query(async ({ ctx, input }) => {
const resource = await readResourceByIdentifierDetailSnapshot(ctx, input.identifier);
if ("error" in resource) {
return resource;
}
return mapResourceDetail(resource);
}),
getById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const resource = await findUniqueOrThrow(
ctx.db.resource.findUnique({
where: { id: input.id },
include: {
blueprint: true,
resourceRoles: {
include: { role: { select: ROLE_BRIEF_SELECT } },
},
areaRole: { select: { id: true, name: true } },
},
}),
"Resource",
);
await assertCanReadResource(
ctx,
resource.id,
"You can only view your own resource unless you have staff access",
);
const directory = await getAnonymizationDirectory(ctx.db);
return {
...anonymizeResource(resource, directory),
isOwnedByCurrentUser: Boolean(resource.userId && ctx.dbUser?.id && resource.userId === ctx.dbUser.id),
};
}),
getByEid: protectedProcedure
.input(z.object({ eid: z.string() }))
.query(async ({ ctx, input }) => {
const directory = await getAnonymizationDirectory(ctx.db);
let resource = await ctx.db.resource.findUnique({ where: { eid: input.eid } });
if (!resource && directory) {
const resourceId = directory.byAliasEid.get(input.eid.trim().toLowerCase());
if (resourceId) {
resource = await ctx.db.resource.findUnique({ where: { id: resourceId } });
}
}
if (!resource) {
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
}
await assertCanReadResource(
ctx,
resource.id,
"You can only view your own resource unless you have staff access",
);
return anonymizeResource(resource, directory);
}),
create: managerProcedure
.input(CreateResourceSchema.extend({ roles: z.array(ResourceRoleSchema).optional() }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
const existing = await ctx.db.resource.findFirst({
where: { OR: [{ eid: input.eid }, { email: input.email }] },
});
if (existing) {
throw new TRPCError({
code: "CONFLICT",
message: `Resource with EID "${input.eid}" or email "${input.email}" already exists`,
});
}
await assertBlueprintDynamicFields({
db: ctx.db,
blueprintId: input.blueprintId,
dynamicFields: input.dynamicFields,
target: BlueprintTarget.RESOURCE,
});
// Enforce max 1 primary role
const primaryCount = (input.roles ?? []).filter((r) => r.isPrimary).length;
if (primaryCount > 1) {
throw new TRPCError({ code: "BAD_REQUEST", message: "A resource can have at most one primary role" });
}
const resource = await ctx.db.resource.create({
data: {
eid: input.eid,
displayName: input.displayName,
email: input.email,
chapter: input.chapter,
lcrCents: input.lcrCents,
ucrCents: input.ucrCents,
currency: input.currency,
chargeabilityTarget: input.chargeabilityTarget,
availability: input.availability,
skills: input.skills as unknown as import("@capakraken/db").Prisma.InputJsonValue,
dynamicFields: input.dynamicFields as unknown as import("@capakraken/db").Prisma.InputJsonValue,
blueprintId: input.blueprintId,
portfolioUrl: input.portfolioUrl || undefined,
roleId: input.roleId || undefined,
...(input.postalCode !== undefined ? { postalCode: input.postalCode } : {}),
...(input.postalCode && !input.federalState
? { federalState: inferStateFromPostalCode(input.postalCode) }
: input.federalState !== undefined
? { federalState: input.federalState }
: {}),
...(input.countryId !== undefined ? { countryId: input.countryId || null } : {}),
...(input.metroCityId !== undefined ? { metroCityId: input.metroCityId || null } : {}),
...(input.orgUnitId !== undefined ? { orgUnitId: input.orgUnitId || null } : {}),
...(input.managementLevelGroupId !== undefined ? { managementLevelGroupId: input.managementLevelGroupId || null } : {}),
...(input.managementLevelId !== undefined ? { managementLevelId: input.managementLevelId || null } : {}),
...(input.resourceType !== undefined ? { resourceType: input.resourceType } : {}),
...(input.chgResponsibility !== undefined ? { chgResponsibility: input.chgResponsibility } : {}),
...(input.rolledOff !== undefined ? { rolledOff: input.rolledOff } : {}),
...(input.departed !== undefined ? { departed: input.departed } : {}),
...(input.enterpriseId !== undefined ? { enterpriseId: input.enterpriseId || null } : {}),
...(input.clientUnitId !== undefined ? { clientUnitId: input.clientUnitId || null } : {}),
...(input.fte !== undefined ? { fte: input.fte } : {}),
resourceRoles: input.roles?.length
? {
create: input.roles.map((r) => ({
roleId: r.roleId,
isPrimary: r.isPrimary,
})),
}
: undefined,
} as unknown as Parameters<typeof ctx.db.resource.create>[0]["data"],
include: {
resourceRoles: { include: { role: { select: ROLE_BRIEF_SELECT } } },
},
});
await ctx.db.auditLog.create({
data: {
entityType: "Resource",
entityId: resource.id,
action: "CREATE",
userId: ctx.dbUser?.id,
changes: { after: resource },
} as unknown as Parameters<typeof ctx.db.auditLog.create>[0]["data"],
});
return resource;
}),
update: managerProcedure
.input(z.object({ id: z.string(), data: UpdateResourceSchema.extend({ roles: z.array(ResourceRoleSchema).optional() }) }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
const existing = await findUniqueOrThrow(
ctx.db.resource.findUnique({ where: { id: input.id } }),
"Resource",
);
const nextBlueprintId = input.data.blueprintId ?? existing.blueprintId ?? undefined;
const nextDynamicFields = (input.data.dynamicFields ?? existing.dynamicFields ?? {}) as Record<string, unknown>;
await assertBlueprintDynamicFields({
db: ctx.db,
blueprintId: nextBlueprintId,
dynamicFields: nextDynamicFields,
target: BlueprintTarget.RESOURCE,
});
// Enforce max 1 primary role
if (input.data.roles !== undefined) {
const primaryCount = input.data.roles.filter((r) => r.isPrimary).length;
if (primaryCount > 1) {
throw new TRPCError({ code: "BAD_REQUEST", message: "A resource can have at most one primary role" });
}
}
const updated = await ctx.db.resource.update({
where: { id: input.id },
data: {
...(input.data.displayName !== undefined ? { displayName: input.data.displayName } : {}),
...(input.data.email !== undefined ? { email: input.data.email } : {}),
...(input.data.chapter !== undefined ? { chapter: input.data.chapter } : {}),
...(input.data.lcrCents !== undefined ? { lcrCents: input.data.lcrCents } : {}),
...(input.data.ucrCents !== undefined ? { ucrCents: input.data.ucrCents } : {}),
...(input.data.currency !== undefined ? { currency: input.data.currency } : {}),
...(input.data.chargeabilityTarget !== undefined ? { chargeabilityTarget: input.data.chargeabilityTarget } : {}),
...(input.data.availability !== undefined ? { availability: input.data.availability as unknown as import("@capakraken/db").Prisma.InputJsonValue } : {}),
...(input.data.skills !== undefined ? { skills: input.data.skills as unknown as import("@capakraken/db").Prisma.InputJsonValue } : {}),
...(input.data.dynamicFields !== undefined ? { dynamicFields: input.data.dynamicFields as unknown as import("@capakraken/db").Prisma.InputJsonValue } : {}),
...(input.data.blueprintId !== undefined ? { blueprintId: input.data.blueprintId } : {}),
...(input.data.isActive !== undefined ? { isActive: input.data.isActive } : {}),
...(input.data.portfolioUrl !== undefined ? { portfolioUrl: input.data.portfolioUrl || null } : {}),
...(input.data.roleId !== undefined ? { roleId: input.data.roleId || null } : {}),
...(input.data.postalCode !== undefined ? { postalCode: input.data.postalCode } : {}),
...(input.data.postalCode && !input.data.federalState
? { federalState: inferStateFromPostalCode(input.data.postalCode) }
: input.data.federalState !== undefined
? { federalState: input.data.federalState }
: {}),
...(input.data.countryId !== undefined ? { countryId: input.data.countryId || null } : {}),
...(input.data.metroCityId !== undefined ? { metroCityId: input.data.metroCityId || null } : {}),
...(input.data.orgUnitId !== undefined ? { orgUnitId: input.data.orgUnitId || null } : {}),
...(input.data.managementLevelGroupId !== undefined ? { managementLevelGroupId: input.data.managementLevelGroupId || null } : {}),
...(input.data.managementLevelId !== undefined ? { managementLevelId: input.data.managementLevelId || null } : {}),
...(input.data.resourceType !== undefined ? { resourceType: input.data.resourceType } : {}),
...(input.data.chgResponsibility !== undefined ? { chgResponsibility: input.data.chgResponsibility } : {}),
...(input.data.rolledOff !== undefined ? { rolledOff: input.data.rolledOff } : {}),
...(input.data.departed !== undefined ? { departed: input.data.departed } : {}),
...(input.data.enterpriseId !== undefined ? { enterpriseId: input.data.enterpriseId || null } : {}),
...(input.data.clientUnitId !== undefined ? { clientUnitId: input.data.clientUnitId || null } : {}),
...(input.data.fte !== undefined ? { fte: input.data.fte } : {}),
} as unknown as Parameters<typeof ctx.db.resource.update>[0]["data"],
include: {
resourceRoles: { include: { role: { select: ROLE_BRIEF_SELECT } } },
},
});
// Replace roles if provided
if (input.data.roles !== undefined) {
await ctx.db.resourceRole.deleteMany({ where: { resourceId: input.id } });
if (input.data.roles.length > 0) {
await ctx.db.resourceRole.createMany({
data: input.data.roles.map((r) => ({
resourceId: input.id,
roleId: r.roleId,
isPrimary: r.isPrimary,
})),
});
}
}
await ctx.db.auditLog.create({
data: {
entityType: "Resource",
entityId: input.id,
action: "UPDATE",
changes: { before: existing, after: updated },
},
});
return updated;
}),
deactivate: managerProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
const resource = await ctx.db.resource.update({
where: { id: input.id },
data: { isActive: false },
});
await ctx.db.auditLog.create({
data: {
entityType: "Resource",
entityId: input.id,
action: "UPDATE",
changes: { after: { isActive: false } },
},
});
return resource;
}),
batchDeactivate: managerProcedure
.input(z.object({ ids: z.array(z.string()).min(1).max(100) }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
const updated = await ctx.db.$transaction(
input.ids.map((id) =>
ctx.db.resource.update({ where: { id }, data: { isActive: false } }),
),
);
await ctx.db.auditLog.create({
data: {
entityType: "Resource",
entityId: input.ids.join(","),
action: "UPDATE",
changes: { after: { isActive: false, ids: input.ids } },
},
});
return { count: updated.length };
}),
chapters: protectedProcedure.query(async ({ ctx }) => {
const resources = await ctx.db.resource.findMany({
where: { isActive: true, chapter: { not: null } },
select: { chapter: true },
distinct: ["chapter"],
orderBy: { chapter: "asc" },
});
return resources.map((r) => r.chapter as string);
}),
// ─── Skill Matrix Import ────────────────────────────────────────────────────
importSkillMatrix: protectedProcedure
.input(
z.object({
skills: z.array(SkillEntrySchema),
employeeInfo: z
.object({
roleId: z.string().optional(),
yearsOfExperience: z.number().optional(),
portfolioUrl: z.string().url().optional().or(z.literal("")),
})
.optional(),
}),
)
.mutation(async ({ ctx, input }) => {
// Find the resource linked to this user
const user = await findUniqueOrThrow(
ctx.db.user.findUnique({
where: { email: ctx.session.user?.email ?? "" },
include: { resource: true },
}),
"User",
);
if (!user.resource) {
throw new TRPCError({ code: "NOT_FOUND", message: "No resource linked to your account" });
}
const resourceId = user.resource.id;
await ctx.db.resource.update({
where: { id: resourceId },
data: {
skills: input.skills as unknown as import("@capakraken/db").Prisma.InputJsonValue,
skillMatrixUpdatedAt: new Date(),
...(input.employeeInfo?.portfolioUrl !== undefined
? { portfolioUrl: input.employeeInfo.portfolioUrl || null }
: {}),
...(input.employeeInfo?.roleId !== undefined ? { roleId: input.employeeInfo.roleId } : {}),
},
});
return { count: input.skills.length };
}),
importSkillMatrixForResource: managerProcedure
.input(
z.object({
resourceId: z.string(),
skills: z.array(SkillEntrySchema),
employeeInfo: z
.object({
roleId: z.string().optional(),
yearsOfExperience: z.number().optional(),
portfolioUrl: z.string().url().optional().or(z.literal("")),
})
.optional(),
}),
)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
await findUniqueOrThrow(
ctx.db.resource.findUnique({ where: { id: input.resourceId } }),
"Resource",
);
await ctx.db.resource.update({
where: { id: input.resourceId },
data: {
skills: input.skills as unknown as import("@capakraken/db").Prisma.InputJsonValue,
skillMatrixUpdatedAt: new Date(),
...(input.employeeInfo?.portfolioUrl !== undefined
? { portfolioUrl: input.employeeInfo.portfolioUrl || null }
: {}),
...(input.employeeInfo?.roleId !== undefined ? { roleId: input.employeeInfo.roleId } : {}),
},
});
return { count: input.skills.length };
}),
batchImportSkillMatrices: adminProcedure
.input(
z.object({
entries: z.array(
z.object({
eid: z.string(),
skills: z.array(SkillEntrySchema),
employeeInfo: z
.object({
roleId: z.string().optional(),
yearsOfExperience: z.number().optional(),
portfolioUrl: z.string().url().optional().or(z.literal("")),
})
.optional(),
}),
),
}),
)
.mutation(async ({ ctx, input }) => {
// Single findMany to avoid N+1 (was: findUnique per entry)
const eids = input.entries.map((e) => e.eid);
const existing = await ctx.db.resource.findMany({
where: { eid: { in: eids } },
select: { id: true, eid: true },
});
const eidToId = new Map(existing.map((r) => [r.eid, r.id]));
const notFound = input.entries.length - existing.length;
const now = new Date();
const updates = input.entries
.filter((entry) => eidToId.has(entry.eid))
.map((entry) =>
ctx.db.resource.update({
where: { id: eidToId.get(entry.eid)! },
data: {
skills: entry.skills as unknown as import("@capakraken/db").Prisma.InputJsonValue,
skillMatrixUpdatedAt: now,
...(entry.employeeInfo?.portfolioUrl !== undefined
? { portfolioUrl: entry.employeeInfo.portfolioUrl || null }
: {}),
...(entry.employeeInfo?.roleId !== undefined ? { roleId: entry.employeeInfo.roleId } : {}),
},
}),
);
await ctx.db.$transaction(updates);
return { updated: updates.length, notFound };
}),
// ─── AI Summary ─────────────────────────────────────────────────────────────
generateAiSummary: managerProcedure
.input(z.object({ resourceId: z.string() }))
.mutation(async ({ ctx, input }) => {
const [resource, settings] = await Promise.all([
findUniqueOrThrow(
ctx.db.resource.findUnique({
where: { id: input.resourceId },
include: { areaRole: { select: { name: true } } },
}),
"Resource",
),
ctx.db.systemSettings.findUnique({ where: { id: "singleton" } }),
]);
if (!isAiConfigured(settings)) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "AI is not configured. Please set credentials in Admin → Settings.",
});
}
type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean };
const skills = (resource.skills as unknown as SkillRow[]) ?? [];
const mainSkills = skills.filter((s) => s.isMainSkill).map((s) => s.skill);
const top10 = [...skills]
.sort((a, b) => b.proficiency - a.proficiency)
.slice(0, 10)
.map((s) => `${s.skill} (${s.proficiency}/5)`);
const vars = {
role: resource.areaRole?.name ?? "Not specified",
chapter: resource.chapter ?? "Not specified",
mainSkills: mainSkills.length > 0 ? mainSkills.join(", ") : "Not specified",
topSkills: top10.join(", "),
};
const templateStr = settings!.aiSummaryPrompt ?? DEFAULT_SUMMARY_PROMPT;
const prompt = templateStr
.replace("{role}", vars.role)
.replace("{chapter}", vars.chapter)
.replace("{mainSkills}", vars.mainSkills)
.replace("{topSkills}", vars.topSkills);
const client = createAiClient(settings!);
const model = settings!.azureOpenAiDeployment!;
const maxTokens = settings!.aiMaxCompletionTokens ?? 300;
const temperature = settings!.aiTemperature ?? 1;
const provider = settings!.aiProvider ?? "openai";
async function callChatCompletions(withTemperature: boolean) {
return loggedAiCall(provider, model, prompt.length, () =>
client.chat.completions.create({
messages: [{ role: "user", content: prompt }],
max_completion_tokens: maxTokens,
model,
...(withTemperature && temperature !== 1 ? { temperature } : {}),
}),
);
}
let summary = "";
try {
let completion;
try {
completion = await callChatCompletions(true);
console.log("[generateAiSummary] chat.completions response:", JSON.stringify({
choices: completion.choices?.map(c => ({ content: c.message?.content, finish_reason: c.finish_reason })),
}));
} catch (tempErr) {
const status = (tempErr as { status?: number }).status;
const msg = (tempErr as Error).message ?? "";
console.log("[generateAiSummary] chat.completions error:", status, msg.slice(0, 200));
if (status === 400 && msg.includes("temperature")) {
completion = await callChatCompletions(false);
} else if (status === 404) {
console.log("[generateAiSummary] falling back to responses API");
const resp = await client.responses.create({ model, input: prompt, max_output_tokens: maxTokens });
console.log("[generateAiSummary] responses output_text:", resp.output_text?.slice(0, 100));
summary = resp.output_text?.trim() ?? "";
completion = null;
} else {
throw tempErr;
}
}
if (completion) summary = completion.choices[0]?.message?.content?.trim() ?? "";
} catch (e) {
throw e;
}
await ctx.db.resource.update({
where: { id: input.resourceId },
data: { aiSummary: summary, aiSummaryUpdatedAt: new Date() },
});
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 email = ctx.session.user?.email;
if (!email) return null;
const user = await ctx.db.user.findUnique({
where: { email },
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).
*/
batchUpdateCustomFields: managerProcedure
.input(z.object({
ids: z.array(z.string()).min(1).max(100),
fields: z.record(z.string(), z.unknown()),
}))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
await ctx.db.$transaction(
input.ids.map((id) =>
ctx.db.$executeRaw`
UPDATE "Resource"
SET "dynamicFields" = "dynamicFields" || ${JSON.stringify(input.fields)}::jsonb
WHERE id = ${id}
`,
),
);
await ctx.db.auditLog.create({
data: {
entityType: "Resource",
entityId: input.ids.join(","),
action: "UPDATE",
changes: { after: { dynamicFields: input.fields, ids: input.ids } } as unknown as import("@capakraken/db").Prisma.InputJsonValue,
},
});
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,
};
}),
});