2735 lines
95 KiB
TypeScript
2735 lines
95 KiB
TypeScript
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 2–3 sentence professional bio. Be specific, use skill names. No fluff.`;
|
||
import { TRPCError } from "@trpc/server";
|
||
import { z } from "zod";
|
||
import { findUniqueOrThrow } from "../db/helpers.js";
|
||
import { adminProcedure, controllerProcedure, createTRPCRouter, managerProcedure, protectedProcedure, requirePermission, 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,
|
||
};
|
||
}),
|
||
});
|