refactor(api): split resource mutation concerns
This commit is contained in:
@@ -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 };
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -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<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,
|
||||||
|
});
|
||||||
|
|
||||||
|
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<typeof ctx.db.resource.update>[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 };
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -1,535 +1,16 @@
|
|||||||
import { createAiClient, isAiConfigured, loggedAiCall } from "../ai-client.js";
|
import { createTRPCRouter } from "../trpc.js";
|
||||||
import {
|
import { resourceAiSummaryProcedures } from "./resource-ai-summary.js";
|
||||||
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 { resourceInsightProcedures } from "./resource-insights.js";
|
import { resourceInsightProcedures } from "./resource-insights.js";
|
||||||
|
import { resourceMutationProcedures } from "./resource-mutations.js";
|
||||||
import { resourceReadProcedures } from "./resource-read.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({
|
export const resourceRouter = createTRPCRouter({
|
||||||
...resourceReadProcedures,
|
...resourceReadProcedures,
|
||||||
...resourceInsightProcedures,
|
...resourceInsightProcedures,
|
||||||
|
...resourceMutationProcedures,
|
||||||
create: managerProcedure
|
...resourceSkillImportProcedures,
|
||||||
.input(CreateResourceSchema.extend({ roles: z.array(ResourceRoleSchema).optional() }))
|
...resourceAiSummaryProcedures,
|
||||||
.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 };
|
|
||||||
}),
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user