chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -0,0 +1,999 @@
|
||||
import { createAiClient, isAiConfigured } from "../ai-client.js";
|
||||
import { listAssignmentBookings } from "@planarchy/application";
|
||||
import { BlueprintTarget, CreateResourceSchema, FieldType, PermissionKey, ResourceRoleSchema, SkillEntrySchema, UpdateResourceSchema, VALUE_SCORE_WEIGHTS, inferStateFromPostalCode } from "@planarchy/shared";
|
||||
import type { WeekdayAvailability } from "@planarchy/shared";
|
||||
import { computeValueScore } from "@planarchy/staffing";
|
||||
import { computeChargeability } from "@planarchy/engine";
|
||||
import { assertBlueprintDynamicFields } from "./blueprint-validation.js";
|
||||
import { buildDynamicFieldWhereClauses } from "./custom-field-filters.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 { adminProcedure, controllerProcedure, createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
|
||||
|
||||
export const resourceRouter = createTRPCRouter({
|
||||
list: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
chapter: z.string().optional(),
|
||||
isActive: z.boolean().optional().default(true),
|
||||
search: z.string().optional(),
|
||||
eids: z.array(z.string()).optional(),
|
||||
page: z.number().int().min(1).default(1),
|
||||
limit: z.number().int().min(1).max(500).default(50),
|
||||
includeRoles: z.boolean().optional().default(false),
|
||||
// Cursor-based pagination (additive — page/limit still supported)
|
||||
cursor: z.string().optional(),
|
||||
// Custom field JSONB filters
|
||||
customFieldFilters: z.array(z.object({
|
||||
key: z.string(),
|
||||
value: z.string(),
|
||||
type: z.nativeEnum(FieldType),
|
||||
})).optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { chapter, isActive, search, eids, page, limit, includeRoles, cursor, customFieldFilters } = input;
|
||||
|
||||
const cfConditions = buildDynamicFieldWhereClauses(customFieldFilters).map((dynamicFields) => ({ dynamicFields }));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const where: any = {
|
||||
...(eids ? {} : { isActive }),
|
||||
...(eids ? { eid: { in: eids } } : {}),
|
||||
...(chapter ? { chapter } : {}),
|
||||
...(search
|
||||
? {
|
||||
OR: [
|
||||
{ displayName: { contains: search, mode: "insensitive" as const } },
|
||||
{ eid: { contains: search, mode: "insensitive" as const } },
|
||||
{ email: { contains: search, mode: "insensitive" as const } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
...(cfConditions.length > 0 ? { AND: cfConditions } : {}),
|
||||
};
|
||||
|
||||
const skip = cursor ? 0 : (page - 1) * limit;
|
||||
const orderBy = [{ displayName: "asc" as const }, { id: "asc" as const }];
|
||||
// Apply cursor filter directly on where to avoid exactOptionalPropertyTypes issues
|
||||
const whereWithCursor = cursor ? { ...where, id: { gt: cursor } } : where;
|
||||
const baseQuery = { where: whereWithCursor, skip, take: limit + 1, orderBy };
|
||||
|
||||
const [rawResources, total] = await Promise.all([
|
||||
includeRoles
|
||||
? ctx.db.resource.findMany({
|
||||
...baseQuery,
|
||||
include: {
|
||||
resourceRoles: {
|
||||
include: { role: { select: { id: true, name: true, color: true } } },
|
||||
},
|
||||
},
|
||||
})
|
||||
: ctx.db.resource.findMany(baseQuery),
|
||||
ctx.db.resource.count({ where }),
|
||||
]);
|
||||
|
||||
const hasMore = rawResources.length > limit;
|
||||
const resources = hasMore ? rawResources.slice(0, limit) : rawResources;
|
||||
const nextCursor = hasMore ? resources[resources.length - 1]!.id : null;
|
||||
|
||||
return { resources, total, page, limit, nextCursor };
|
||||
}),
|
||||
|
||||
getById: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const resource = await ctx.db.resource.findUnique({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
blueprint: true,
|
||||
resourceRoles: {
|
||||
include: { role: { select: { id: true, name: true, color: true } } },
|
||||
},
|
||||
areaRole: { select: { id: true, name: true } },
|
||||
user: { select: { email: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!resource) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
|
||||
}
|
||||
|
||||
return resource;
|
||||
}),
|
||||
|
||||
getByEid: protectedProcedure
|
||||
.input(z.object({ eid: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const resource = await ctx.db.resource.findUnique({ where: { eid: input.eid } });
|
||||
if (!resource) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
|
||||
}
|
||||
return resource;
|
||||
}),
|
||||
|
||||
create: managerProcedure
|
||||
.input(CreateResourceSchema.extend({ roles: z.array(ResourceRoleSchema).optional() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
|
||||
const existing = await ctx.db.resource.findFirst({
|
||||
where: { OR: [{ eid: input.eid }, { email: input.email }] },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: `Resource with EID "${input.eid}" or email "${input.email}" already exists`,
|
||||
});
|
||||
}
|
||||
|
||||
await assertBlueprintDynamicFields({
|
||||
db: ctx.db,
|
||||
blueprintId: input.blueprintId,
|
||||
dynamicFields: input.dynamicFields,
|
||||
target: BlueprintTarget.RESOURCE,
|
||||
});
|
||||
|
||||
// Enforce max 1 primary role
|
||||
const primaryCount = (input.roles ?? []).filter((r) => r.isPrimary).length;
|
||||
if (primaryCount > 1) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "A resource can have at most one primary role" });
|
||||
}
|
||||
|
||||
const resource = await ctx.db.resource.create({
|
||||
data: {
|
||||
eid: input.eid,
|
||||
displayName: input.displayName,
|
||||
email: input.email,
|
||||
chapter: input.chapter,
|
||||
lcrCents: input.lcrCents,
|
||||
ucrCents: input.ucrCents,
|
||||
currency: input.currency,
|
||||
chargeabilityTarget: input.chargeabilityTarget,
|
||||
availability: input.availability,
|
||||
skills: input.skills as unknown as import("@planarchy/db").Prisma.InputJsonValue,
|
||||
dynamicFields: input.dynamicFields as unknown as import("@planarchy/db").Prisma.InputJsonValue,
|
||||
blueprintId: input.blueprintId,
|
||||
portfolioUrl: input.portfolioUrl || undefined,
|
||||
roleId: input.roleId || undefined,
|
||||
...(input.postalCode !== undefined ? { postalCode: input.postalCode } : {}),
|
||||
...(input.postalCode && !input.federalState
|
||||
? { federalState: inferStateFromPostalCode(input.postalCode) }
|
||||
: input.federalState !== undefined
|
||||
? { federalState: input.federalState }
|
||||
: {}),
|
||||
...(input.countryId !== undefined ? { countryId: input.countryId || null } : {}),
|
||||
...(input.metroCityId !== undefined ? { metroCityId: input.metroCityId || null } : {}),
|
||||
...(input.orgUnitId !== undefined ? { orgUnitId: input.orgUnitId || null } : {}),
|
||||
...(input.managementLevelGroupId !== undefined ? { managementLevelGroupId: input.managementLevelGroupId || null } : {}),
|
||||
...(input.managementLevelId !== undefined ? { managementLevelId: input.managementLevelId || null } : {}),
|
||||
...(input.resourceType !== undefined ? { resourceType: input.resourceType } : {}),
|
||||
...(input.chgResponsibility !== undefined ? { chgResponsibility: input.chgResponsibility } : {}),
|
||||
...(input.rolledOff !== undefined ? { rolledOff: input.rolledOff } : {}),
|
||||
...(input.departed !== undefined ? { departed: input.departed } : {}),
|
||||
...(input.enterpriseId !== undefined ? { enterpriseId: input.enterpriseId || null } : {}),
|
||||
...(input.clientUnitId !== undefined ? { clientUnitId: input.clientUnitId || null } : {}),
|
||||
...(input.fte !== undefined ? { fte: input.fte } : {}),
|
||||
resourceRoles: input.roles?.length
|
||||
? {
|
||||
create: input.roles.map((r) => ({
|
||||
roleId: r.roleId,
|
||||
isPrimary: r.isPrimary,
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
} as unknown as Parameters<typeof ctx.db.resource.create>[0]["data"],
|
||||
include: {
|
||||
resourceRoles: { include: { role: { select: { id: true, name: true, color: true } } } },
|
||||
},
|
||||
});
|
||||
|
||||
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 ctx.db.resource.findUnique({ where: { id: input.id } });
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
|
||||
}
|
||||
|
||||
const nextBlueprintId = input.data.blueprintId ?? existing.blueprintId ?? undefined;
|
||||
const nextDynamicFields = (input.data.dynamicFields ?? existing.dynamicFields ?? {}) as Record<string, unknown>;
|
||||
|
||||
await assertBlueprintDynamicFields({
|
||||
db: ctx.db,
|
||||
blueprintId: nextBlueprintId,
|
||||
dynamicFields: nextDynamicFields,
|
||||
target: BlueprintTarget.RESOURCE,
|
||||
});
|
||||
|
||||
// Enforce max 1 primary role
|
||||
if (input.data.roles !== undefined) {
|
||||
const primaryCount = input.data.roles.filter((r) => r.isPrimary).length;
|
||||
if (primaryCount > 1) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "A resource can have at most one primary role" });
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await ctx.db.resource.update({
|
||||
where: { id: input.id },
|
||||
data: {
|
||||
...(input.data.displayName !== undefined ? { displayName: input.data.displayName } : {}),
|
||||
...(input.data.email !== undefined ? { email: input.data.email } : {}),
|
||||
...(input.data.chapter !== undefined ? { chapter: input.data.chapter } : {}),
|
||||
...(input.data.lcrCents !== undefined ? { lcrCents: input.data.lcrCents } : {}),
|
||||
...(input.data.ucrCents !== undefined ? { ucrCents: input.data.ucrCents } : {}),
|
||||
...(input.data.currency !== undefined ? { currency: input.data.currency } : {}),
|
||||
...(input.data.chargeabilityTarget !== undefined ? { chargeabilityTarget: input.data.chargeabilityTarget } : {}),
|
||||
...(input.data.availability !== undefined ? { availability: input.data.availability as unknown as import("@planarchy/db").Prisma.InputJsonValue } : {}),
|
||||
...(input.data.skills !== undefined ? { skills: input.data.skills as unknown as import("@planarchy/db").Prisma.InputJsonValue } : {}),
|
||||
...(input.data.dynamicFields !== undefined ? { dynamicFields: input.data.dynamicFields as unknown as import("@planarchy/db").Prisma.InputJsonValue } : {}),
|
||||
...(input.data.blueprintId !== undefined ? { blueprintId: input.data.blueprintId } : {}),
|
||||
...(input.data.isActive !== undefined ? { isActive: input.data.isActive } : {}),
|
||||
...(input.data.portfolioUrl !== undefined ? { portfolioUrl: input.data.portfolioUrl || null } : {}),
|
||||
...(input.data.roleId !== undefined ? { roleId: input.data.roleId || null } : {}),
|
||||
...(input.data.postalCode !== undefined ? { postalCode: input.data.postalCode } : {}),
|
||||
...(input.data.postalCode && !input.data.federalState
|
||||
? { federalState: inferStateFromPostalCode(input.data.postalCode) }
|
||||
: input.data.federalState !== undefined
|
||||
? { federalState: input.data.federalState }
|
||||
: {}),
|
||||
...(input.data.countryId !== undefined ? { countryId: input.data.countryId || null } : {}),
|
||||
...(input.data.metroCityId !== undefined ? { metroCityId: input.data.metroCityId || null } : {}),
|
||||
...(input.data.orgUnitId !== undefined ? { orgUnitId: input.data.orgUnitId || null } : {}),
|
||||
...(input.data.managementLevelGroupId !== undefined ? { managementLevelGroupId: input.data.managementLevelGroupId || null } : {}),
|
||||
...(input.data.managementLevelId !== undefined ? { managementLevelId: input.data.managementLevelId || null } : {}),
|
||||
...(input.data.resourceType !== undefined ? { resourceType: input.data.resourceType } : {}),
|
||||
...(input.data.chgResponsibility !== undefined ? { chgResponsibility: input.data.chgResponsibility } : {}),
|
||||
...(input.data.rolledOff !== undefined ? { rolledOff: input.data.rolledOff } : {}),
|
||||
...(input.data.departed !== undefined ? { departed: input.data.departed } : {}),
|
||||
...(input.data.enterpriseId !== undefined ? { enterpriseId: input.data.enterpriseId || null } : {}),
|
||||
...(input.data.clientUnitId !== undefined ? { clientUnitId: input.data.clientUnitId || null } : {}),
|
||||
...(input.data.fte !== undefined ? { fte: input.data.fte } : {}),
|
||||
} as unknown as Parameters<typeof ctx.db.resource.update>[0]["data"],
|
||||
include: {
|
||||
resourceRoles: { include: { role: { select: { id: true, name: true, color: true } } } },
|
||||
},
|
||||
});
|
||||
|
||||
// Replace roles if provided
|
||||
if (input.data.roles !== undefined) {
|
||||
await ctx.db.resourceRole.deleteMany({ where: { resourceId: input.id } });
|
||||
if (input.data.roles.length > 0) {
|
||||
await ctx.db.resourceRole.createMany({
|
||||
data: input.data.roles.map((r) => ({
|
||||
resourceId: input.id,
|
||||
roleId: r.roleId,
|
||||
isPrimary: r.isPrimary,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.db.auditLog.create({
|
||||
data: {
|
||||
entityType: "Resource",
|
||||
entityId: input.id,
|
||||
action: "UPDATE",
|
||||
changes: { before: existing, after: updated },
|
||||
},
|
||||
});
|
||||
|
||||
return updated;
|
||||
}),
|
||||
|
||||
deactivate: managerProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
|
||||
const resource = await ctx.db.resource.update({
|
||||
where: { id: input.id },
|
||||
data: { isActive: false },
|
||||
});
|
||||
|
||||
await ctx.db.auditLog.create({
|
||||
data: {
|
||||
entityType: "Resource",
|
||||
entityId: input.id,
|
||||
action: "UPDATE",
|
||||
changes: { after: { isActive: false } },
|
||||
},
|
||||
});
|
||||
|
||||
return resource;
|
||||
}),
|
||||
|
||||
batchDeactivate: managerProcedure
|
||||
.input(z.object({ ids: z.array(z.string()).min(1).max(100) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
|
||||
const updated = await ctx.db.$transaction(
|
||||
input.ids.map((id) =>
|
||||
ctx.db.resource.update({ where: { id }, data: { isActive: false } }),
|
||||
),
|
||||
);
|
||||
|
||||
await ctx.db.auditLog.create({
|
||||
data: {
|
||||
entityType: "Resource",
|
||||
entityId: input.ids.join(","),
|
||||
action: "UPDATE",
|
||||
changes: { after: { isActive: false, ids: input.ids } },
|
||||
},
|
||||
});
|
||||
|
||||
return { count: updated.length };
|
||||
}),
|
||||
|
||||
chapters: protectedProcedure.query(async ({ ctx }) => {
|
||||
const resources = await ctx.db.resource.findMany({
|
||||
where: { isActive: true, chapter: { not: null } },
|
||||
select: { chapter: true },
|
||||
distinct: ["chapter"],
|
||||
orderBy: { chapter: "asc" },
|
||||
});
|
||||
return resources.map((r) => r.chapter as string);
|
||||
}),
|
||||
|
||||
// ─── Skill Matrix Import ────────────────────────────────────────────────────
|
||||
|
||||
importSkillMatrix: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
skills: z.array(SkillEntrySchema),
|
||||
employeeInfo: z
|
||||
.object({
|
||||
roleId: z.string().optional(),
|
||||
yearsOfExperience: z.number().optional(),
|
||||
portfolioUrl: z.string().url().optional().or(z.literal("")),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Find the resource linked to this user
|
||||
const user = await ctx.db.user.findUnique({
|
||||
where: { email: ctx.session.user?.email ?? "" },
|
||||
include: { resource: true },
|
||||
});
|
||||
if (!user?.resource) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "No resource linked to your account" });
|
||||
}
|
||||
const resourceId = user.resource.id;
|
||||
|
||||
await ctx.db.resource.update({
|
||||
where: { id: resourceId },
|
||||
data: {
|
||||
skills: input.skills as unknown as import("@planarchy/db").Prisma.InputJsonValue,
|
||||
skillMatrixUpdatedAt: new Date(),
|
||||
...(input.employeeInfo?.portfolioUrl !== undefined
|
||||
? { portfolioUrl: input.employeeInfo.portfolioUrl || null }
|
||||
: {}),
|
||||
...(input.employeeInfo?.roleId !== undefined ? { roleId: input.employeeInfo.roleId } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
return { count: input.skills.length };
|
||||
}),
|
||||
|
||||
importSkillMatrixForResource: managerProcedure
|
||||
.input(
|
||||
z.object({
|
||||
resourceId: z.string(),
|
||||
skills: z.array(SkillEntrySchema),
|
||||
employeeInfo: z
|
||||
.object({
|
||||
roleId: z.string().optional(),
|
||||
yearsOfExperience: z.number().optional(),
|
||||
portfolioUrl: z.string().url().optional().or(z.literal("")),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
|
||||
const existing = await ctx.db.resource.findUnique({ where: { id: input.resourceId } });
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
|
||||
}
|
||||
|
||||
await ctx.db.resource.update({
|
||||
where: { id: input.resourceId },
|
||||
data: {
|
||||
skills: input.skills as unknown as import("@planarchy/db").Prisma.InputJsonValue,
|
||||
skillMatrixUpdatedAt: new Date(),
|
||||
...(input.employeeInfo?.portfolioUrl !== undefined
|
||||
? { portfolioUrl: input.employeeInfo.portfolioUrl || null }
|
||||
: {}),
|
||||
...(input.employeeInfo?.roleId !== undefined ? { roleId: input.employeeInfo.roleId } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
return { count: input.skills.length };
|
||||
}),
|
||||
|
||||
batchImportSkillMatrices: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
entries: z.array(
|
||||
z.object({
|
||||
eid: z.string(),
|
||||
skills: z.array(SkillEntrySchema),
|
||||
employeeInfo: z
|
||||
.object({
|
||||
roleId: z.string().optional(),
|
||||
yearsOfExperience: z.number().optional(),
|
||||
portfolioUrl: z.string().url().optional().or(z.literal("")),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Single findMany to avoid N+1 (was: findUnique per entry)
|
||||
const eids = input.entries.map((e) => e.eid);
|
||||
const existing = await ctx.db.resource.findMany({
|
||||
where: { eid: { in: eids } },
|
||||
select: { id: true, eid: true },
|
||||
});
|
||||
const eidToId = new Map(existing.map((r) => [r.eid, r.id]));
|
||||
const notFound = input.entries.length - existing.length;
|
||||
|
||||
const now = new Date();
|
||||
const updates = input.entries
|
||||
.filter((entry) => eidToId.has(entry.eid))
|
||||
.map((entry) =>
|
||||
ctx.db.resource.update({
|
||||
where: { id: eidToId.get(entry.eid)! },
|
||||
data: {
|
||||
skills: entry.skills as unknown as import("@planarchy/db").Prisma.InputJsonValue,
|
||||
skillMatrixUpdatedAt: now,
|
||||
...(entry.employeeInfo?.portfolioUrl !== undefined
|
||||
? { portfolioUrl: entry.employeeInfo.portfolioUrl || null }
|
||||
: {}),
|
||||
...(entry.employeeInfo?.roleId !== undefined ? { roleId: entry.employeeInfo.roleId } : {}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await ctx.db.$transaction(updates);
|
||||
return { updated: updates.length, notFound };
|
||||
}),
|
||||
|
||||
// ─── AI Summary ─────────────────────────────────────────────────────────────
|
||||
|
||||
generateAiSummary: managerProcedure
|
||||
.input(z.object({ resourceId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const [resource, settings] = await Promise.all([
|
||||
ctx.db.resource.findUnique({
|
||||
where: { id: input.resourceId },
|
||||
include: { areaRole: { select: { name: true } } },
|
||||
}),
|
||||
ctx.db.systemSettings.findUnique({ where: { id: "singleton" } }),
|
||||
]);
|
||||
|
||||
if (!resource) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
|
||||
}
|
||||
|
||||
if (!isAiConfigured(settings)) {
|
||||
throw new TRPCError({
|
||||
code: "PRECONDITION_FAILED",
|
||||
message: "AI is not configured. Please set credentials in Admin → Settings.",
|
||||
});
|
||||
}
|
||||
|
||||
type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean };
|
||||
const skills = (resource.skills as unknown as SkillRow[]) ?? [];
|
||||
const mainSkills = skills.filter((s) => s.isMainSkill).map((s) => s.skill);
|
||||
const top10 = [...skills]
|
||||
.sort((a, b) => b.proficiency - a.proficiency)
|
||||
.slice(0, 10)
|
||||
.map((s) => `${s.skill} (${s.proficiency}/5)`);
|
||||
|
||||
const vars = {
|
||||
role: resource.areaRole?.name ?? "Not specified",
|
||||
chapter: resource.chapter ?? "Not specified",
|
||||
mainSkills: mainSkills.length > 0 ? mainSkills.join(", ") : "Not specified",
|
||||
topSkills: top10.join(", "),
|
||||
};
|
||||
|
||||
const templateStr = settings!.aiSummaryPrompt ?? DEFAULT_SUMMARY_PROMPT;
|
||||
const prompt = templateStr
|
||||
.replace("{role}", vars.role)
|
||||
.replace("{chapter}", vars.chapter)
|
||||
.replace("{mainSkills}", vars.mainSkills)
|
||||
.replace("{topSkills}", vars.topSkills);
|
||||
|
||||
const client = createAiClient(settings!);
|
||||
const model = settings!.azureOpenAiDeployment!;
|
||||
const maxTokens = settings!.aiMaxCompletionTokens ?? 300;
|
||||
const temperature = settings!.aiTemperature ?? 1;
|
||||
|
||||
async function callChatCompletions(withTemperature: boolean) {
|
||||
return client.chat.completions.create({
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
max_completion_tokens: maxTokens,
|
||||
model,
|
||||
...(withTemperature && temperature !== 1 ? { temperature } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
let summary = "";
|
||||
try {
|
||||
let completion;
|
||||
try {
|
||||
completion = await callChatCompletions(true);
|
||||
console.log("[generateAiSummary] chat.completions response:", JSON.stringify({
|
||||
choices: completion.choices?.map(c => ({ content: c.message?.content, finish_reason: c.finish_reason })),
|
||||
}));
|
||||
} catch (tempErr) {
|
||||
const status = (tempErr as { status?: number }).status;
|
||||
const msg = (tempErr as Error).message ?? "";
|
||||
console.log("[generateAiSummary] chat.completions error:", status, msg.slice(0, 200));
|
||||
if (status === 400 && msg.includes("temperature")) {
|
||||
completion = await callChatCompletions(false);
|
||||
} else if (status === 404) {
|
||||
console.log("[generateAiSummary] falling back to responses API");
|
||||
const resp = await client.responses.create({ model, input: prompt, max_output_tokens: maxTokens });
|
||||
console.log("[generateAiSummary] responses output_text:", resp.output_text?.slice(0, 100));
|
||||
summary = resp.output_text?.trim() ?? "";
|
||||
completion = null;
|
||||
} else {
|
||||
throw tempErr;
|
||||
}
|
||||
}
|
||||
if (completion) summary = completion.choices[0]?.message?.content?.trim() ?? "";
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
await ctx.db.resource.update({
|
||||
where: { id: input.resourceId },
|
||||
data: { aiSummary: summary, aiSummaryUpdatedAt: new Date() },
|
||||
});
|
||||
|
||||
return { summary };
|
||||
}),
|
||||
|
||||
// ─── Skills Analytics ───────────────────────────────────────────────────────
|
||||
|
||||
getSkillsAnalytics: controllerProcedure.query(async ({ ctx }) => {
|
||||
const resources = await ctx.db.resource.findMany({
|
||||
where: { isActive: true },
|
||||
select: { id: true, displayName: true, chapter: true, skills: true },
|
||||
});
|
||||
|
||||
type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean };
|
||||
|
||||
// Aggregate: { skillName, category, count, totalProficiency, chapters }
|
||||
const skillMap = new Map<
|
||||
string,
|
||||
{ skill: string; category: string; count: number; totalProficiency: number; chapters: Set<string> }
|
||||
>();
|
||||
|
||||
for (const resource of resources) {
|
||||
const skills = (resource.skills as unknown as SkillRow[]) ?? [];
|
||||
for (const s of skills) {
|
||||
const key = s.skill;
|
||||
if (!skillMap.has(key)) {
|
||||
skillMap.set(key, {
|
||||
skill: s.skill,
|
||||
category: s.category ?? "Uncategorized",
|
||||
count: 0,
|
||||
totalProficiency: 0,
|
||||
chapters: new Set(),
|
||||
});
|
||||
}
|
||||
const entry = skillMap.get(key)!;
|
||||
entry.count++;
|
||||
entry.totalProficiency += s.proficiency;
|
||||
if (resource.chapter) entry.chapters.add(resource.chapter);
|
||||
}
|
||||
}
|
||||
|
||||
const aggregated = Array.from(skillMap.values())
|
||||
.map((e) => ({
|
||||
skill: e.skill,
|
||||
category: e.category,
|
||||
count: e.count,
|
||||
avgProficiency: Math.round((e.totalProficiency / e.count) * 10) / 10,
|
||||
chapters: Array.from(e.chapters),
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
const categories = [...new Set(aggregated.map((e) => e.category))].sort();
|
||||
const allChapters = [...new Set(resources.map((r) => r.chapter).filter(Boolean))].sort() as string[];
|
||||
|
||||
return {
|
||||
totalResources: resources.length,
|
||||
totalSkillEntries: aggregated.length,
|
||||
aggregated,
|
||||
categories,
|
||||
allChapters,
|
||||
};
|
||||
}),
|
||||
|
||||
searchBySkills: controllerProcedure
|
||||
.input(
|
||||
z.object({
|
||||
rules: z.array(
|
||||
z.object({
|
||||
skill: z.string().min(1),
|
||||
minProficiency: z.number().int().min(1).max(5).default(1),
|
||||
}),
|
||||
),
|
||||
chapter: z.string().optional(),
|
||||
operator: z.enum(["AND", "OR"]).default("AND"),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { rules, chapter, operator } = input;
|
||||
|
||||
const resources = await ctx.db.resource.findMany({
|
||||
where: { isActive: true, ...(chapter ? { chapter } : {}) },
|
||||
select: { id: true, eid: true, displayName: true, chapter: true, skills: true },
|
||||
});
|
||||
|
||||
type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean };
|
||||
|
||||
const results = resources
|
||||
.map((r) => {
|
||||
const skills = (r.skills as unknown as SkillRow[]) ?? [];
|
||||
|
||||
const matchFn = (rule: { skill: string; minProficiency: number }) => {
|
||||
const s = skills.find((sk) => sk.skill.toLowerCase().includes(rule.skill.toLowerCase()));
|
||||
return s && s.proficiency >= rule.minProficiency ? s : null;
|
||||
};
|
||||
|
||||
const matched = rules.map(matchFn);
|
||||
const passes =
|
||||
operator === "AND" ? matched.every(Boolean) : matched.some(Boolean);
|
||||
|
||||
if (!passes) return null;
|
||||
|
||||
return {
|
||||
id: r.id,
|
||||
eid: r.eid,
|
||||
displayName: r.displayName,
|
||||
chapter: r.chapter,
|
||||
matchedSkills: rules
|
||||
.map((rule, i) => {
|
||||
const s = matched[i];
|
||||
return s ? { skill: s.skill, proficiency: s.proficiency, category: s.category ?? "" } : null;
|
||||
})
|
||||
.filter((s): s is { skill: string; proficiency: number; category: string } => s !== null),
|
||||
};
|
||||
})
|
||||
.filter((r): r is NonNullable<typeof r> => r !== null)
|
||||
.sort((a, b) => a.displayName.localeCompare(b.displayName));
|
||||
|
||||
return results;
|
||||
}),
|
||||
|
||||
// ─── Self-service ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Get the resource linked to the current user (for self-service pages). */
|
||||
getMyResource: protectedProcedure.query(async ({ ctx }) => {
|
||||
const email = ctx.session.user?.email;
|
||||
if (!email) return null;
|
||||
const user = await ctx.db.user.findUnique({
|
||||
where: { email },
|
||||
select: { resource: { select: { id: true, displayName: true, eid: true, chapter: true } } },
|
||||
});
|
||||
return user?.resource ?? null;
|
||||
}),
|
||||
|
||||
// ─── Value Score ─────────────────────────────────────────────────────────────
|
||||
|
||||
getValueScores: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
isActive: z.boolean().optional().default(true),
|
||||
limit: z.number().int().min(1).max(500).default(100),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
|
||||
const visibleRoles = (settings?.scoreVisibleRoles as unknown as string[]) ?? ["ADMIN", "MANAGER"];
|
||||
const userRole = (ctx.session.user as { role?: string } | undefined)?.role ?? "USER";
|
||||
if (!visibleRoles.includes(userRole)) return [];
|
||||
|
||||
const resources = await ctx.db.resource.findMany({
|
||||
where: { isActive: input.isActive },
|
||||
select: {
|
||||
id: true,
|
||||
eid: true,
|
||||
displayName: true,
|
||||
chapter: true,
|
||||
lcrCents: true,
|
||||
valueScore: true,
|
||||
valueScoreBreakdown: true,
|
||||
valueScoreUpdatedAt: true,
|
||||
},
|
||||
orderBy: [{ valueScore: "desc" }, { displayName: "asc" }],
|
||||
take: input.limit,
|
||||
});
|
||||
|
||||
return resources;
|
||||
}),
|
||||
|
||||
recomputeValueScores: adminProcedure.mutation(async ({ ctx }) => {
|
||||
const [resources, settings] = await Promise.all([
|
||||
ctx.db.resource.findMany({
|
||||
where: { isActive: true },
|
||||
select: {
|
||||
id: true,
|
||||
skills: true,
|
||||
lcrCents: true,
|
||||
chargeabilityTarget: true,
|
||||
},
|
||||
}),
|
||||
ctx.db.systemSettings.findUnique({ where: { id: "singleton" } }),
|
||||
]);
|
||||
const bookings = await listAssignmentBookings(ctx.db, {
|
||||
startDate: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000),
|
||||
endDate: new Date(),
|
||||
resourceIds: resources.map((resource) => resource.id),
|
||||
});
|
||||
|
||||
const defaultWeights = {
|
||||
skillDepth: VALUE_SCORE_WEIGHTS.SKILL_DEPTH,
|
||||
skillBreadth: VALUE_SCORE_WEIGHTS.SKILL_BREADTH,
|
||||
costEfficiency: VALUE_SCORE_WEIGHTS.COST_EFFICIENCY,
|
||||
chargeability: VALUE_SCORE_WEIGHTS.CHARGEABILITY,
|
||||
experience: VALUE_SCORE_WEIGHTS.EXPERIENCE,
|
||||
};
|
||||
const weights = (settings?.scoreWeights as unknown as typeof defaultWeights) ?? defaultWeights;
|
||||
|
||||
const maxLcrCents = resources.reduce((max, r) => Math.max(max, r.lcrCents), 0);
|
||||
const now = new Date();
|
||||
|
||||
type SkillRow = { skill: string; category?: string; proficiency: number; yearsExperience?: number; isMainSkill?: boolean };
|
||||
const totalWorkDays = 90 * (5 / 7); // approx working days
|
||||
const availableHours = totalWorkDays * 8;
|
||||
|
||||
const updates = resources.map((resource) => {
|
||||
const resourceBookings = bookings.filter((booking) => booking.resourceId === resource.id);
|
||||
const bookedHours = resourceBookings.reduce((sum, booking) => {
|
||||
const days = Math.max(
|
||||
0,
|
||||
(new Date(booking.endDate).getTime() - new Date(booking.startDate).getTime()) /
|
||||
(1000 * 60 * 60 * 24) +
|
||||
1,
|
||||
);
|
||||
return sum + booking.hoursPerDay * days;
|
||||
}, 0);
|
||||
const currentChargeability = availableHours > 0 ? Math.min(100, (bookedHours / availableHours) * 100) : 0;
|
||||
const skills = (resource.skills as unknown as SkillRow[]) ?? [];
|
||||
|
||||
const breakdown = computeValueScore(
|
||||
{
|
||||
skills: skills as unknown as import("@planarchy/shared").SkillEntry[],
|
||||
lcrCents: resource.lcrCents,
|
||||
chargeabilityTarget: resource.chargeabilityTarget,
|
||||
currentChargeability,
|
||||
maxLcrCents,
|
||||
},
|
||||
weights,
|
||||
);
|
||||
|
||||
return ctx.db.resource.update({
|
||||
where: { id: resource.id },
|
||||
data: {
|
||||
valueScore: breakdown.total,
|
||||
valueScoreBreakdown: breakdown as unknown as import("@planarchy/db").Prisma.InputJsonValue,
|
||||
valueScoreUpdatedAt: now,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await ctx.db.$transaction(updates);
|
||||
const updated = updates.length;
|
||||
|
||||
return { updated };
|
||||
}),
|
||||
|
||||
listWithUtilization: controllerProcedure
|
||||
.input(
|
||||
z.object({
|
||||
startDate: z.string().datetime().optional(),
|
||||
endDate: z.string().datetime().optional(),
|
||||
chapter: z.string().optional(),
|
||||
limit: z.number().int().min(1).max(500).default(100),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const now = new Date();
|
||||
const start = input.startDate ? new Date(input.startDate) : new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const end = input.endDate ? new Date(input.endDate) : new Date(now.getFullYear(), now.getMonth() + 3, 0);
|
||||
|
||||
const resources = await ctx.db.resource.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
...(input.chapter ? { chapter: input.chapter } : {}),
|
||||
},
|
||||
take: input.limit,
|
||||
orderBy: { displayName: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
eid: true,
|
||||
displayName: true,
|
||||
email: true,
|
||||
chapter: true,
|
||||
lcrCents: true,
|
||||
ucrCents: true,
|
||||
currency: true,
|
||||
chargeabilityTarget: true,
|
||||
availability: true,
|
||||
skills: true,
|
||||
dynamicFields: true,
|
||||
blueprintId: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
roleId: true,
|
||||
portfolioUrl: true,
|
||||
postalCode: true,
|
||||
federalState: true,
|
||||
valueScore: true,
|
||||
valueScoreBreakdown: true,
|
||||
valueScoreUpdatedAt: true,
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
const bookings = await listAssignmentBookings(ctx.db, {
|
||||
startDate: start,
|
||||
endDate: end,
|
||||
resourceIds: resources.map((resource) => resource.id),
|
||||
});
|
||||
|
||||
return resources.map((r) => {
|
||||
const avail = r.availability as Record<string, number>;
|
||||
const dailyAvailHours = Object.values(avail).reduce((s, h) => s + (h ?? 0), 0) / 5;
|
||||
const periodDays =
|
||||
(end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24) + 1;
|
||||
const availableHours = dailyAvailHours * periodDays * (5 / 7);
|
||||
|
||||
let bookedHours = 0;
|
||||
let isOverbooked = false;
|
||||
const resourceBookings = bookings.filter((booking) => booking.resourceId === r.id);
|
||||
for (const a of resourceBookings) {
|
||||
const days =
|
||||
(new Date(a.endDate).getTime() - new Date(a.startDate).getTime()) /
|
||||
(1000 * 60 * 60 * 24) +
|
||||
1;
|
||||
bookedHours += a.hoursPerDay * days;
|
||||
if (a.hoursPerDay > dailyAvailHours) isOverbooked = true;
|
||||
}
|
||||
|
||||
const utilizationPercent =
|
||||
availableHours > 0 ? Math.round((bookedHours / availableHours) * 100) : 0;
|
||||
|
||||
return {
|
||||
...r,
|
||||
bookingCount: resourceBookings.length,
|
||||
bookedHours: Math.round(bookedHours),
|
||||
availableHours: Math.round(availableHours),
|
||||
utilizationPercent,
|
||||
isOverbooked,
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
getChargeabilityStats: controllerProcedure
|
||||
.input(z.object({ resourceId: z.string().optional() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const now = new Date();
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
|
||||
const resources = await ctx.db.resource.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
...(input.resourceId ? { id: input.resourceId } : {}),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
eid: true,
|
||||
displayName: true,
|
||||
chapter: true,
|
||||
chargeabilityTarget: true,
|
||||
availability: true,
|
||||
},
|
||||
});
|
||||
const bookings = await listAssignmentBookings(ctx.db, {
|
||||
startDate: start,
|
||||
endDate: end,
|
||||
resourceIds: resources.map((resource) => resource.id),
|
||||
});
|
||||
|
||||
return resources.map((r) => {
|
||||
const avail = r.availability as unknown as WeekdayAvailability;
|
||||
const resourceBookings = bookings.filter((booking) => booking.resourceId === r.id);
|
||||
|
||||
// Actual: CONFIRMED or ACTIVE allocations on non-DRAFT, non-CANCELLED projects
|
||||
const actualAllocs = resourceBookings.filter(
|
||||
(a) =>
|
||||
(a.status === "CONFIRMED" || a.status === "ACTIVE") &&
|
||||
a.project.status !== "DRAFT" &&
|
||||
a.project.status !== "CANCELLED",
|
||||
);
|
||||
|
||||
// Expected: all non-CANCELLED assignment-like bookings, all project statuses
|
||||
const expectedAllocs = resourceBookings;
|
||||
|
||||
const actual = computeChargeability(avail, actualAllocs, start, end);
|
||||
const expected = computeChargeability(avail, expectedAllocs, start, end);
|
||||
|
||||
return {
|
||||
id: r.id,
|
||||
eid: r.eid,
|
||||
displayName: r.displayName,
|
||||
chapter: r.chapter,
|
||||
chargeabilityTarget: r.chargeabilityTarget,
|
||||
actualChargeability: actual.chargeability,
|
||||
expectedChargeability: expected.chargeability,
|
||||
availableHours: actual.availableHours,
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
/**
|
||||
* Bulk-update dynamicFields on a set of resources (merges — does not overwrite other keys).
|
||||
*/
|
||||
batchUpdateCustomFields: managerProcedure
|
||||
.input(z.object({
|
||||
ids: z.array(z.string()).min(1).max(100),
|
||||
fields: z.record(z.string(), z.unknown()),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
|
||||
|
||||
await ctx.db.$transaction(
|
||||
input.ids.map((id) =>
|
||||
ctx.db.$executeRaw`
|
||||
UPDATE "Resource"
|
||||
SET "dynamicFields" = "dynamicFields" || ${JSON.stringify(input.fields)}::jsonb
|
||||
WHERE id = ${id}
|
||||
`,
|
||||
),
|
||||
);
|
||||
|
||||
await ctx.db.auditLog.create({
|
||||
data: {
|
||||
entityType: "Resource",
|
||||
entityId: input.ids.join(","),
|
||||
action: "UPDATE",
|
||||
changes: { after: { dynamicFields: input.fields, ids: input.ids } } as unknown as import("@planarchy/db").Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return { updated: input.ids.length };
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user