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 2–3 sentence professional bio. Be specific, use skill names. No fluff.`; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { adminProcedure, controllerProcedure, createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js"; import { 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; 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[], 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[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[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; 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[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 } >(); for (const resource of resources) { const skills = (resource.skills as unknown as SkillRow[]) ?? []; for (const s of skills) { const key = s.skill; if (!skillMap.has(key)) { skillMap.set(key, { skill: s.skill, category: s.category ?? "Uncategorized", count: 0, totalProficiency: 0, chapters: new Set(), }); } const entry = skillMap.get(key)!; entry.count++; entry.totalProficiency += s.proficiency; if (resource.chapter) entry.chapters.add(resource.chapter); } } const aggregated = Array.from(skillMap.values()) .map((e) => ({ skill: e.skill, category: e.category, count: e.count, avgProficiency: Math.round((e.totalProficiency / e.count) * 10) / 10, chapters: Array.from(e.chapters), })) .sort((a, b) => b.count - a.count); const categories = [...new Set(aggregated.map((e) => e.category))].sort(); const allChapters = [...new Set(resources.map((r) => r.chapter).filter(Boolean))].sort() as string[]; return { totalResources: resources.length, totalSkillEntries: aggregated.length, aggregated, categories, allChapters, }; }), searchBySkills: controllerProcedure .input( z.object({ rules: z.array( z.object({ skill: z.string().min(1), minProficiency: z.number().int().min(1).max(5).default(1), }), ), chapter: z.string().optional(), operator: z.enum(["AND", "OR"]).default("AND"), }), ) .query(async ({ ctx, input }) => { const { rules, chapter, operator } = input; const resources = await ctx.db.resource.findMany({ where: { isActive: true, ...(chapter ? { chapter } : {}) }, select: { id: true, eid: true, displayName: true, chapter: true, skills: true }, }); type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean }; const results = resources .map((r) => { const skills = (r.skills as unknown as SkillRow[]) ?? []; const matchFn = (rule: { skill: string; minProficiency: number }) => { const s = skills.find((sk) => sk.skill.toLowerCase().includes(rule.skill.toLowerCase())); return s && s.proficiency >= rule.minProficiency ? s : null; }; const matched = rules.map(matchFn); const passes = operator === "AND" ? matched.every(Boolean) : matched.some(Boolean); if (!passes) return null; return { id: r.id, eid: r.eid, displayName: r.displayName, chapter: r.chapter, matchedSkills: rules .map((rule, i) => { const s = matched[i]; return s ? { skill: s.skill, proficiency: s.proficiency, category: s.category ?? "" } : null; }) .filter((s): s is { skill: string; proficiency: number; category: string } => s !== null), }; }) .filter((r): r is NonNullable => r !== null) .sort((a, b) => a.displayName.localeCompare(b.displayName)); const directory = await getAnonymizationDirectory(ctx.db); return anonymizeResources(results, directory); }), // ─── Self-service ──────────────────────────────────────────────────────────── /** Get the resource linked to the current user (for self-service pages). */ getMyResource: protectedProcedure.query(async ({ ctx }) => { const 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; 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 }; }), // ─── 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 thirtyDaysFromNow = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); 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, }, }); // ── 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: now }, startDate: { lte: thirtyDaysFromNow }, }, select: { resourceId: true, startDate: true, endDate: true, hoursPerDay: true, }, }); // Build utilization map (simple: booked hours per day / available hours per day) const utilizationMap = new Map(); for (const r of resources) { const avail = r.availability as Record; const dailyAvailHours = Object.values(avail).reduce((s, h) => s + (h ?? 0), 0) / 5; const resourceAssignments = assignments.filter((a) => a.resourceId === r.id); // Current daily booked hours (assignments overlapping today) let todayBooked = 0; for (const a of resourceAssignments) { if (a.startDate <= now && a.endDate >= now) { todayBooked += a.hoursPerDay; } } const utilizationPercent = dailyAvailHours > 0 ? Math.round((todayBooked / dailyAvailHours) * 100) : 0; // Find earliest date when resource has capacity (within 30 days) let earliestAvailableDate: Date | null = null; const checkDate = new Date(now); for (let i = 0; i < 30; i++) { const day = checkDate.getDay(); if (day !== 0 && day !== 6) { let dayBooked = 0; for (const a of resourceAssignments) { if (a.startDate <= checkDate && a.endDate >= checkDate) { dayBooked += a.hoursPerDay; } } if (dayBooked < dailyAvailHours * 0.8) { earliestAvailableDate = new Date(checkDate); break; } } checkDate.setDate(checkDate.getDate() + 1); } utilizationMap.set(r.id, { utilizationPercent, earliestAvailableDate }); } // ── Section 1: Skill Search ── let searchResults: Array<{ id: string; displayName: string; chapter: string | null; skillProficiency: number; skillName: string; utilizationPercent: number; availableFrom: string | null; }> = []; if (input.searchSkill && input.searchSkill.trim().length > 0) { const needle = input.searchSkill.toLowerCase(); for (const r of resources) { const skills = (r.skills as unknown as SkillRow[]) ?? []; const match = skills.find( (s) => s.skill.toLowerCase().includes(needle) && s.proficiency >= input.minProficiency, ); if (!match) continue; const util = utilizationMap.get(r.id); if (input.availableOnly && !util?.earliestAvailableDate) continue; searchResults.push({ id: r.id, displayName: r.displayName, chapter: r.chapter, skillProficiency: match.proficiency, skillName: match.skill, utilizationPercent: util?.utilizationPercent ?? 0, availableFrom: util?.earliestAvailableDate?.toISOString() ?? null, }); } searchResults.sort((a, b) => b.skillProficiency - a.skillProficiency || a.utilizationPercent - b.utilizationPercent); } // ── Section 2: Skill Gap Heat Map ── // Demand: from unfilled DemandRequirements + project staffingReqs skills const unfilled = await ctx.db.demandRequirement.findMany({ where: { endDate: { gte: now }, assignments: { none: {} }, }, select: { id: true, role: true, roleId: true, headcount: true, project: { select: { staffingReqs: true }, }, }, }); // Collect demanded skills from project staffingReqs const demandSkillCounts = new Map(); for (const demand of unfilled) { const staffingReqs = (demand.project.staffingReqs as unknown as Array<{ role?: string; roleId?: string; requiredSkills?: string[]; }>) ?? []; // Match demand to staffing req by role or roleId const matchedReq = staffingReqs.find( (sr) => (demand.roleId && sr.roleId === demand.roleId) || (demand.role && sr.role === demand.role), ); if (matchedReq?.requiredSkills) { for (const skill of matchedReq.requiredSkills) { demandSkillCounts.set(skill, (demandSkillCounts.get(skill) ?? 0) + demand.headcount); } } } // Supply: count resources with skill at proficiency >= 3 const supplySkillCounts = new Map(); const allSkillCounts = new Map(); for (const r of resources) { const skills = (r.skills as unknown as SkillRow[]) ?? []; for (const s of skills) { allSkillCounts.set(s.skill, (allSkillCounts.get(s.skill) ?? 0) + 1); if (s.proficiency >= 3) { supplySkillCounts.set(s.skill, (supplySkillCounts.get(s.skill) ?? 0) + 1); } } } // Merge all skill names from both demand and supply const allGapSkills = new Set([...demandSkillCounts.keys(), ...supplySkillCounts.keys()]); const gapData = Array.from(allGapSkills) .map((skill) => { const supply = supplySkillCounts.get(skill) ?? 0; const demand = demandSkillCounts.get(skill) ?? 0; return { skill, supply, demand, gap: demand - supply }; }) .sort((a, b) => b.gap - a.gap); // ── Section 3: Distribution (top 20 by resource count) ── const aggregated = Array.from( (() => { const map = new Map(); for (const r of resources) { const skills = (r.skills as unknown as SkillRow[]) ?? []; for (const s of skills) { const entry = map.get(s.skill); if (entry) { entry.count++; entry.totalProficiency += s.proficiency; } else { map.set(s.skill, { skill: s.skill, count: 1, totalProficiency: s.proficiency }); } } } return map; })().values(), ) .map((e) => ({ skill: e.skill, count: e.count, avgProficiency: Math.round((e.totalProficiency / e.count) * 10) / 10, })) .sort((a, b) => b.count - a.count) .slice(0, 20); const directory = await getAnonymizationDirectory(ctx.db); return { searchResults: anonymizeResources(searchResults, directory), gapData, distribution: aggregated, totalResources: resources.length, }; }), });