Files
Nexus/packages/api/src/router/resource.ts
T
Hartmut e7b74f13bd refactor: consolidate duplicated code across web and API packages
- Extract shared render helpers (vacation blocks, range overlay, overbooking blink) into renderHelpers.tsx
- Centralize status badge styles and vacation color maps into status-styles.ts
- Extract dragMath.ts utility from useTimelineDrag for reuse
- Split useInvalidatePlanningViews into useInvalidateTimeline (4 queries) + useInvalidatePlanningViews (8 queries)
- Adopt findUniqueOrThrow() and Prisma select constants across API routers
- Add shared fmtEur() helper for API-side money formatting
- Wrap TimelineResourcePanel and TimelineProjectPanel with React.memo
- Fix pre-existing TS2589 deep type errors in TeamCalendar and VacationModal
- 38 files changed, reducing ~400 lines of duplicated code

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-19 00:10:08 +01:00

1195 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createAiClient, isAiConfigured } from "../ai-client.js";
import {
isChargeabilityActualBooking,
isChargeabilityRelevantProject,
listAssignmentBookings,
recomputeResourceValueScores,
} from "@planarchy/application";
import { BlueprintTarget, CreateResourceSchema, FieldType, PermissionKey, ResourceRoleSchema, ResourceType, SkillEntrySchema, UpdateResourceSchema, inferStateFromPostalCode } from "@planarchy/shared";
import type { WeekdayAvailability } from "@planarchy/shared";
import { computeChargeability } from "@planarchy/engine";
import { assertBlueprintDynamicFields } from "./blueprint-validation.js";
import { buildDynamicFieldWhereClauses } from "./custom-field-filters.js";
import {
anonymizeResource,
anonymizeResources,
anonymizeSearchMatches,
getAnonymizationDirectory,
resolveResourceIdsByDisplayedEids,
} from "../lib/anonymization.js";
export const DEFAULT_SUMMARY_PROMPT = `You are writing a short professional profile for an internal resource planning tool.
Artist profile:
- Role: {role}
- Chapter: {chapter}
- Main skills: {mainSkills}
- Top skills: {topSkills}
Write a 23 sentence professional bio. Be specific, use skill names. No fluff.`;
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { adminProcedure, controllerProcedure, createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
import { ROLE_BRIEF_SELECT } from "../db/selects.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;
}
export const resourceRouter = createTRPCRouter({
list: protectedProcedure
.input(
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),
includeRoles: z.boolean().optional().default(false),
// Cursor-based pagination (additive — page/limit still supported)
cursor: z.string().optional(),
// Custom field JSONB filters
customFieldFilters: z.array(z.object({
key: z.string(),
value: z.string(),
type: z.nativeEnum(FieldType),
})).optional(),
}),
)
.query(async ({ ctx, input }) => {
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 };
}),
/** 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",
);
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,
};
}),
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",
);
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" });
}
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("@planarchy/db").Prisma.InputJsonValue,
dynamicFields: input.dynamicFields as unknown as import("@planarchy/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("@planarchy/db").Prisma.InputJsonValue } : {}),
...(input.data.skills !== undefined ? { skills: input.data.skills as unknown as import("@planarchy/db").Prisma.InputJsonValue } : {}),
...(input.data.dynamicFields !== undefined ? { dynamicFields: input.data.dynamicFields as unknown as import("@planarchy/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("@planarchy/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("@planarchy/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("@planarchy/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;
async function callChatCompletions(withTemperature: boolean) {
return 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 settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
const visibleRoles = (settings?.scoreVisibleRoles as unknown as string[]) ?? ["ADMIN", "MANAGER"];
const userRole = (ctx.session.user as { role?: string } | undefined)?.role ?? "USER";
if (!visibleRoles.includes(userRole)) return [];
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,
valueScore: true,
valueScoreBreakdown: true,
valueScoreUpdatedAt: true,
userId: true,
},
});
const bookings = await listAssignmentBookings(ctx.db, {
startDate: start,
endDate: end,
resourceIds: resources.map((resource) => resource.id),
});
const directory = await getAnonymizationDirectory(ctx.db);
return resources.map((r) => {
const avail = r.availability as Record<string, number>;
const dailyAvailHours = Object.values(avail).reduce((s, h) => s + (h ?? 0), 0) / 5;
const periodDays =
(end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24) + 1;
const availableHours = dailyAvailHours * periodDays * (5 / 7);
let bookedHours = 0;
let isOverbooked = false;
const resourceBookings = bookings.filter(
(booking) =>
booking.resourceId === r.id &&
(input.includeProposed || booking.status !== "PROPOSED"),
);
for (const a of resourceBookings) {
const days =
(new Date(a.endDate).getTime() - new Date(a.startDate).getTime()) /
(1000 * 60 * 60 * 24) +
1;
bookedHours += a.hoursPerDay * days;
if (a.hoursPerDay > dailyAvailHours) isOverbooked = true;
}
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,
},
});
const bookings = await listAssignmentBookings(ctx.db, {
startDate: start,
endDate: end,
resourceIds: resources.map((resource) => resource.id),
});
const directory = await getAnonymizationDirectory(ctx.db);
return resources.map((r) => {
const avail = r.availability as unknown as WeekdayAvailability;
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 actual = computeChargeability(avail, actualAllocs, start, end);
const expected = computeChargeability(avail, expectedAllocs, start, end);
return anonymizeResource({
id: r.id,
eid: r.eid,
displayName: r.displayName,
chapter: r.chapter,
chargeabilityTarget: r.chargeabilityTarget,
actualChargeability: actual.chargeability,
expectedChargeability: expected.chargeability,
availableHours: actual.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("@planarchy/db").Prisma.InputJsonValue,
},
});
return { updated: input.ids.length };
}),
});