refactor(api): split resource mutation concerns

This commit is contained in:
2026-03-31 08:34:38 +02:00
parent d5bf3fe82a
commit cf1b260187
4 changed files with 541 additions and 528 deletions
+9 -528
View File
@@ -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 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, 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<typeof ctx.db.resource.create>[0]["data"],
include: {
resourceRoles: { include: { role: { select: ROLE_BRIEF_SELECT } } },
},
});
await ctx.db.auditLog.create({
data: {
entityType: "Resource",
entityId: resource.id,
action: "CREATE",
userId: ctx.dbUser?.id,
changes: { after: resource },
} as unknown as Parameters<typeof ctx.db.auditLog.create>[0]["data"],
});
return resource;
}),
update: managerProcedure
.input(z.object({ id: z.string(), data: UpdateResourceSchema.extend({ roles: z.array(ResourceRoleSchema).optional() }) }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
const existing = await findUniqueOrThrow(
ctx.db.resource.findUnique({ where: { id: input.id } }),
"Resource",
);
const nextBlueprintId = input.data.blueprintId ?? existing.blueprintId ?? undefined;
const nextDynamicFields = (input.data.dynamicFields ?? existing.dynamicFields ?? {}) as Record<string, unknown>;
await assertBlueprintDynamicFields({
db: ctx.db,
blueprintId: nextBlueprintId,
dynamicFields: nextDynamicFields,
target: BlueprintTarget.RESOURCE,
});
// Enforce max 1 primary role
if (input.data.roles !== undefined) {
const primaryCount = input.data.roles.filter((r) => r.isPrimary).length;
if (primaryCount > 1) {
throw new TRPCError({ code: "BAD_REQUEST", message: "A resource can have at most one primary role" });
}
}
const updated = await ctx.db.resource.update({
where: { id: input.id },
data: {
...(input.data.displayName !== undefined ? { displayName: input.data.displayName } : {}),
...(input.data.email !== undefined ? { email: input.data.email } : {}),
...(input.data.chapter !== undefined ? { chapter: input.data.chapter } : {}),
...(input.data.lcrCents !== undefined ? { lcrCents: input.data.lcrCents } : {}),
...(input.data.ucrCents !== undefined ? { ucrCents: input.data.ucrCents } : {}),
...(input.data.currency !== undefined ? { currency: input.data.currency } : {}),
...(input.data.chargeabilityTarget !== undefined ? { chargeabilityTarget: input.data.chargeabilityTarget } : {}),
...(input.data.availability !== undefined ? { availability: input.data.availability as unknown as import("@capakraken/db").Prisma.InputJsonValue } : {}),
...(input.data.skills !== undefined ? { skills: input.data.skills as unknown as import("@capakraken/db").Prisma.InputJsonValue } : {}),
...(input.data.dynamicFields !== undefined ? { dynamicFields: input.data.dynamicFields as unknown as import("@capakraken/db").Prisma.InputJsonValue } : {}),
...(input.data.blueprintId !== undefined ? { blueprintId: input.data.blueprintId } : {}),
...(input.data.isActive !== undefined ? { isActive: input.data.isActive } : {}),
...(input.data.portfolioUrl !== undefined ? { portfolioUrl: input.data.portfolioUrl || null } : {}),
...(input.data.roleId !== undefined ? { roleId: input.data.roleId || null } : {}),
...(input.data.postalCode !== undefined ? { postalCode: input.data.postalCode } : {}),
...(input.data.postalCode && !input.data.federalState
? { federalState: inferStateFromPostalCode(input.data.postalCode) }
: input.data.federalState !== undefined
? { federalState: input.data.federalState }
: {}),
...(input.data.countryId !== undefined ? { countryId: input.data.countryId || null } : {}),
...(input.data.metroCityId !== undefined ? { metroCityId: input.data.metroCityId || null } : {}),
...(input.data.orgUnitId !== undefined ? { orgUnitId: input.data.orgUnitId || null } : {}),
...(input.data.managementLevelGroupId !== undefined ? { managementLevelGroupId: input.data.managementLevelGroupId || null } : {}),
...(input.data.managementLevelId !== undefined ? { managementLevelId: input.data.managementLevelId || null } : {}),
...(input.data.resourceType !== undefined ? { resourceType: input.data.resourceType } : {}),
...(input.data.chgResponsibility !== undefined ? { chgResponsibility: input.data.chgResponsibility } : {}),
...(input.data.rolledOff !== undefined ? { rolledOff: input.data.rolledOff } : {}),
...(input.data.departed !== undefined ? { departed: input.data.departed } : {}),
...(input.data.enterpriseId !== undefined ? { enterpriseId: input.data.enterpriseId || null } : {}),
...(input.data.clientUnitId !== undefined ? { clientUnitId: input.data.clientUnitId || null } : {}),
...(input.data.fte !== undefined ? { fte: input.data.fte } : {}),
} as unknown as Parameters<typeof ctx.db.resource.update>[0]["data"],
include: {
resourceRoles: { include: { role: { select: ROLE_BRIEF_SELECT } } },
},
});
// Replace roles if provided
if (input.data.roles !== undefined) {
await ctx.db.resourceRole.deleteMany({ where: { resourceId: input.id } });
if (input.data.roles.length > 0) {
await ctx.db.resourceRole.createMany({
data: input.data.roles.map((r) => ({
resourceId: input.id,
roleId: r.roleId,
isPrimary: r.isPrimary,
})),
});
}
}
await ctx.db.auditLog.create({
data: {
entityType: "Resource",
entityId: input.id,
action: "UPDATE",
changes: { before: existing, after: updated },
},
});
return updated;
}),
deactivate: managerProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
const resource = await ctx.db.resource.update({
where: { id: input.id },
data: { isActive: false },
});
await ctx.db.auditLog.create({
data: {
entityType: "Resource",
entityId: input.id,
action: "UPDATE",
changes: { after: { isActive: false } },
},
});
return resource;
}),
batchDeactivate: managerProcedure
.input(z.object({ ids: z.array(z.string()).min(1).max(100) }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
const updated = await ctx.db.$transaction(
input.ids.map((id) =>
ctx.db.resource.update({ where: { id }, data: { isActive: false } }),
),
);
await ctx.db.auditLog.create({
data: {
entityType: "Resource",
entityId: input.ids.join(","),
action: "UPDATE",
changes: { after: { isActive: false, ids: input.ids } },
},
});
return { count: updated.length };
}),
// ─── 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,
});