From cf1b2601873a090f2f9ad5e206f25735ae1b799d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 08:34:38 +0200 Subject: [PATCH] refactor(api): split resource mutation concerns --- .../api/src/router/resource-ai-summary.ts | 141 +++++ packages/api/src/router/resource-mutations.ts | 270 +++++++++ .../api/src/router/resource-skill-import.ts | 121 ++++ packages/api/src/router/resource.ts | 537 +----------------- 4 files changed, 541 insertions(+), 528 deletions(-) create mode 100644 packages/api/src/router/resource-ai-summary.ts create mode 100644 packages/api/src/router/resource-mutations.ts create mode 100644 packages/api/src/router/resource-skill-import.ts diff --git a/packages/api/src/router/resource-ai-summary.ts b/packages/api/src/router/resource-ai-summary.ts new file mode 100644 index 0000000..a030a6e --- /dev/null +++ b/packages/api/src/router/resource-ai-summary.ts @@ -0,0 +1,141 @@ +import { createAiClient, isAiConfigured, loggedAiCall } from "../ai-client.js"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { findUniqueOrThrow } from "../db/helpers.js"; +import { logger } from "../lib/logger.js"; +import { managerProcedure } from "../trpc.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.`; + +type SkillRow = { + skill: string; + category?: string; + proficiency: number; + isMainSkill?: boolean; +}; + +export const resourceAiSummaryProcedures = { + 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.", + }); + } + + const skills = (resource.skills as unknown as SkillRow[]) ?? []; + const mainSkills = skills.filter((skill) => skill.isMainSkill).map((skill) => skill.skill); + const top10 = [...skills] + .sort((left, right) => right.proficiency - left.proficiency) + .slice(0, 10) + .map((skill) => `${skill.skill} (${skill.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); + logger.debug( + { + provider, + model, + choiceCount: completion.choices?.length ?? 0, + }, + "AI summary chat completion succeeded", + ); + } catch (tempErr) { + const status = (tempErr as { status?: number }).status; + const msg = (tempErr as Error).message ?? ""; + if (status === 400 && msg.includes("temperature")) { + logger.info( + { provider, model, status }, + "Retrying AI summary generation without temperature override", + ); + completion = await callChatCompletions(false); + } else if (status === 404) { + logger.info( + { provider, model, status }, + "Falling back to AI responses API for summary generation", + ); + const resp = await client.responses.create({ model, input: prompt, max_output_tokens: maxTokens }); + logger.debug( + { + provider, + model, + summaryLength: resp.output_text?.trim().length ?? 0, + }, + "AI summary responses API call succeeded", + ); + summary = resp.output_text?.trim() ?? ""; + completion = null; + } else { + throw tempErr; + } + } + if (completion) { + summary = completion.choices[0]?.message?.content?.trim() ?? ""; + } + } catch (error) { + throw error; + } + + await ctx.db.resource.update({ + where: { id: input.resourceId }, + data: { aiSummary: summary, aiSummaryUpdatedAt: new Date() }, + }); + + return { summary }; + }), +}; diff --git a/packages/api/src/router/resource-mutations.ts b/packages/api/src/router/resource-mutations.ts new file mode 100644 index 0000000..d54b09b --- /dev/null +++ b/packages/api/src/router/resource-mutations.ts @@ -0,0 +1,270 @@ +import { calculateAllocation } from "@capakraken/engine"; +import { + BlueprintTarget, + CreateResourceSchema, + PermissionKey, + ResourceRoleSchema, + UpdateResourceSchema, + inferStateFromPostalCode, +} from "@capakraken/shared"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { findUniqueOrThrow } from "../db/helpers.js"; +import { ROLE_BRIEF_SELECT } from "../db/selects.js"; +import { adminProcedure, managerProcedure, requirePermission } from "../trpc.js"; +import { assertBlueprintDynamicFields } from "./blueprint-validation.js"; + +export const resourceMutationProcedures = { + 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, + }); + + const primaryCount = (input.roles ?? []).filter((role) => role.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((role) => ({ + roleId: role.roleId, + isPrimary: role.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, + }); + + if (input.data.roles !== undefined) { + const primaryCount = input.data.roles.filter((role) => role.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[0]["data"], + include: { + resourceRoles: { include: { role: { select: ROLE_BRIEF_SELECT } } }, + }, + }); + + 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((role) => ({ + resourceId: input.id, + roleId: role.roleId, + isPrimary: role.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 }; + }), + + 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 }; + }), +}; diff --git a/packages/api/src/router/resource-skill-import.ts b/packages/api/src/router/resource-skill-import.ts new file mode 100644 index 0000000..42a9372 --- /dev/null +++ b/packages/api/src/router/resource-skill-import.ts @@ -0,0 +1,121 @@ +import { PermissionKey, SkillEntrySchema } from "@capakraken/shared"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { findUniqueOrThrow } from "../db/helpers.js"; +import { adminProcedure, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js"; + +const employeeInfoSchema = z + .object({ + roleId: z.string().optional(), + yearsOfExperience: z.number().optional(), + portfolioUrl: z.string().url().optional().or(z.literal("")), + }) + .optional(); + +export const resourceSkillImportProcedures = { + importSkillMatrix: protectedProcedure + .input( + z.object({ + skills: z.array(SkillEntrySchema), + employeeInfo: employeeInfoSchema, + }), + ) + .mutation(async ({ ctx, input }) => { + const user = await findUniqueOrThrow( + ctx.db.user.findUnique({ + where: { id: ctx.dbUser!.id }, + include: { resource: true }, + }), + "User", + ); + if (!user.resource) { + throw new TRPCError({ code: "NOT_FOUND", message: "No resource linked to your account" }); + } + + await ctx.db.resource.update({ + where: { id: user.resource.id }, + 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: employeeInfoSchema, + }), + ) + .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: employeeInfoSchema, + }), + ), + }), + ) + .mutation(async ({ ctx, input }) => { + const eids = input.entries.map((entry) => entry.eid); + const existing = await ctx.db.resource.findMany({ + where: { eid: { in: eids } }, + select: { id: true, eid: true }, + }); + const eidToId = new Map(existing.map((resource) => [resource.eid, resource.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 }; + }), +}; diff --git a/packages/api/src/router/resource.ts b/packages/api/src/router/resource.ts index 91baa7c..64b8025 100644 --- a/packages/api/src/router/resource.ts +++ b/packages/api/src/router/resource.ts @@ -1,535 +1,16 @@ -import { createAiClient, isAiConfigured, loggedAiCall } from "../ai-client.js"; -import { - calculateAllocation, -} from "@capakraken/engine"; -import { BlueprintTarget, CreateResourceSchema, PermissionKey, ResourceRoleSchema, SkillEntrySchema, UpdateResourceSchema, inferStateFromPostalCode } from "@capakraken/shared"; -import { assertBlueprintDynamicFields } from "./blueprint-validation.js"; -import { logger } from "../lib/logger.js"; - -export const DEFAULT_SUMMARY_PROMPT = `You are writing a short professional profile for an internal resource planning tool. - -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, createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js"; -import { ROLE_BRIEF_SELECT } from "../db/selects.js"; +import { createTRPCRouter } from "../trpc.js"; +import { resourceAiSummaryProcedures } from "./resource-ai-summary.js"; import { resourceInsightProcedures } from "./resource-insights.js"; +import { resourceMutationProcedures } from "./resource-mutations.js"; import { resourceReadProcedures } from "./resource-read.js"; +import { resourceSkillImportProcedures } from "./resource-skill-import.js"; + +export { DEFAULT_SUMMARY_PROMPT } from "./resource-ai-summary.js"; export const resourceRouter = createTRPCRouter({ ...resourceReadProcedures, ...resourceInsightProcedures, - - 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[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("@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[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 }; - }), - - // ─── 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: { id: ctx.dbUser!.id }, - 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); - logger.debug( - { - provider, - model, - choiceCount: completion.choices?.length ?? 0, - }, - "AI summary chat completion succeeded", - ); - } catch (tempErr) { - const status = (tempErr as { status?: number }).status; - const msg = (tempErr as Error).message ?? ""; - if (status === 400 && msg.includes("temperature")) { - logger.info( - { provider, model, status }, - "Retrying AI summary generation without temperature override", - ); - completion = await callChatCompletions(false); - } else if (status === 404) { - logger.info( - { provider, model, status }, - "Falling back to AI responses API for summary generation", - ); - const resp = await client.responses.create({ model, input: prompt, max_output_tokens: maxTokens }); - logger.debug( - { - provider, - model, - summaryLength: resp.output_text?.trim().length ?? 0, - }, - "AI summary responses API call succeeded", - ); - 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 }; - }), - - /** - * 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 }; - }), - + ...resourceMutationProcedures, + ...resourceSkillImportProcedures, + ...resourceAiSummaryProcedures, });