Files
CapaKraken/packages/api/src/router/resource.ts
T
Hartmut 6f34659587 feat: Sprint 3 — automation, intelligence, skill marketplace
Auto-Staffing Suggestions (A6):
- generateAutoSuggestions() ranks top-3 resources on demand creation
- Uses existing staffing engine (skill 40%, availability 30%, cost 20%, util 10%)
- Creates in-app notification with match scores for managers
- Triggered after createDemandRequirement and partial fillDemandRequirement

Vacation Conflict Detection (A7):
- checkVacationConflicts() warns when >50% chapter absent on same days
- Returns warnings array in approve/batchApprove responses (advisory, non-blocking)
- Creates VACATION_CONFLICT_WARNING notification for approver

Weekly Chargeability Alerts (A10):
- checkChargeabilityAlerts() finds resources >15pp below target
- Cron endpoint: GET /api/cron/chargeability-alerts
- Duplicate-safe by resourceId + month composite key

Rate Card Auto-Apply (A11):
- lookupRate() finds best matching rate card line (weighted scoring)
- Auto-fills demand line rates in estimate create/updateDraft when rates are 0
- Marks auto-filled lines with metadata.autoAppliedRateCard
- New lookupDemandLineRate query for on-demand UI lookups

Public Holiday Auto-Import (A12):
- autoImportPublicHolidays() generates holidays by resource federal state
- Cron endpoint: GET /api/cron/public-holidays?year=2027
- Duplicate-safe, uses existing getPublicHolidays() from shared

Skill Marketplace MVP (G6):
- New page: /analytics/skill-marketplace with 3 sections
- Skill Search: filter by name, proficiency, availability, sortable results
- Skill Gap Heat Map: supply vs demand per skill, shortage/surplus indicators
- Skill Distribution: top-20 horizontal bar chart (reuses SkillDistributionChart)
- New getSkillMarketplace query in resource router
- Sidebar nav link under Analytics for ADMIN/MANAGER/CONTROLLER

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-19 21:39:05 +01:00

1419 lines
53 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createAiClient, isAiConfigured } from "../ai-client.js";
import {
isChargeabilityActualBooking,
isChargeabilityRelevantProject,
listAssignmentBookings,
recomputeResourceValueScores,
} from "@planarchy/application";
import { BlueprintTarget, CreateResourceSchema, FieldType, PermissionKey, ResourceRoleSchema, ResourceType, SkillEntrySchema, UpdateResourceSchema, inferStateFromPostalCode } from "@planarchy/shared";
import type { WeekdayAvailability } from "@planarchy/shared";
import { computeChargeability } from "@planarchy/engine";
import { assertBlueprintDynamicFields } from "./blueprint-validation.js";
import { buildDynamicFieldWhereClauses } from "./custom-field-filters.js";
import {
anonymizeResource,
anonymizeResources,
anonymizeSearchMatches,
getAnonymizationDirectory,
resolveResourceIdsByDisplayedEids,
} from "../lib/anonymization.js";
export const DEFAULT_SUMMARY_PROMPT = `You are writing a short professional profile for an internal resource planning tool.
Artist profile:
- Role: {role}
- Chapter: {chapter}
- Main skills: {mainSkills}
- Top skills: {topSkills}
Write a 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, controllerProcedure, createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
import { ROLE_BRIEF_SELECT } from "../db/selects.js";
function parseResourceCursor(cursor: string | undefined): { displayName: string; id: string } | null {
if (!cursor) return null;
try {
const decoded = JSON.parse(cursor) as { displayName?: string; id?: string };
if (typeof decoded.displayName === "string" && typeof decoded.id === "string") {
return { displayName: decoded.displayName, id: decoded.id };
}
} catch {
return null;
}
return null;
}
export const resourceRouter = createTRPCRouter({
list: protectedProcedure
.input(
z.object({
chapter: z.string().optional(),
chapters: z.array(z.string()).optional(),
isActive: z.boolean().optional().default(true),
search: z.string().optional(),
eids: z.array(z.string()).optional(),
countryIds: z.array(z.string()).optional(),
excludedCountryIds: z.array(z.string()).optional(),
includeWithoutCountry: z.boolean().optional().default(true),
resourceTypes: z.array(z.nativeEnum(ResourceType)).optional(),
excludedResourceTypes: z.array(z.nativeEnum(ResourceType)).optional(),
includeWithoutResourceType: z.boolean().optional().default(true),
rolledOff: z.boolean().optional(),
departed: z.boolean().optional(),
page: z.number().int().min(1).default(1),
limit: z.number().int().min(1).max(500).default(50),
includeRoles: z.boolean().optional().default(false),
// Cursor-based pagination (additive — page/limit still supported)
cursor: z.string().optional(),
// Custom field JSONB filters
customFieldFilters: z.array(z.object({
key: z.string(),
value: z.string(),
type: z.nativeEnum(FieldType),
})).optional(),
}),
)
.query(async ({ ctx, input }) => {
const {
chapter,
chapters,
isActive,
search,
eids,
countryIds,
excludedCountryIds,
includeWithoutCountry,
resourceTypes,
excludedResourceTypes,
includeWithoutResourceType,
rolledOff,
departed,
page,
limit,
includeRoles,
cursor,
customFieldFilters,
} = input;
const parsedCursor = parseResourceCursor(cursor);
const cfConditions = buildDynamicFieldWhereClauses(customFieldFilters).map((dynamicFields) => ({ dynamicFields }));
type WhereClause = Record<string, unknown>;
const andClauses: WhereClause[] = [];
const chapterFilters = Array.from(
new Set([
...(chapter ? [chapter] : []),
...(chapters ?? []),
]),
);
const directory = await getAnonymizationDirectory(ctx.db);
if (!eids) {
andClauses.push({ isActive });
}
if (eids && !directory) {
andClauses.push({ eid: { in: eids } });
}
if (chapterFilters.length === 1) {
andClauses.push({ chapter: chapterFilters[0] });
} else if (chapterFilters.length > 1) {
andClauses.push({ chapter: { in: chapterFilters } });
}
if (search && !directory) {
andClauses.push({
OR: [
{ displayName: { contains: search, mode: "insensitive" as const } },
{ eid: { contains: search, mode: "insensitive" as const } },
{ email: { contains: search, mode: "insensitive" as const } },
],
});
}
if (countryIds && countryIds.length > 0) {
const countryClauses: WhereClause[] = [{ countryId: { in: countryIds } }];
if (includeWithoutCountry) {
countryClauses.push({ countryId: null });
}
andClauses.push(countryClauses.length === 1 ? countryClauses[0]! : { OR: countryClauses });
}
if (excludedCountryIds && excludedCountryIds.length > 0) {
andClauses.push({ NOT: { countryId: { in: excludedCountryIds } } });
}
if (!includeWithoutCountry) {
andClauses.push({ NOT: { countryId: null } });
}
if (resourceTypes && resourceTypes.length > 0) {
const resourceTypeClauses: WhereClause[] = [{ resourceType: { in: resourceTypes } }];
if (includeWithoutResourceType) {
resourceTypeClauses.push({ resourceType: null });
}
andClauses.push(
resourceTypeClauses.length === 1 ? resourceTypeClauses[0]! : { OR: resourceTypeClauses },
);
}
if (excludedResourceTypes && excludedResourceTypes.length > 0) {
andClauses.push({ NOT: { resourceType: { in: excludedResourceTypes } } });
}
if (!includeWithoutResourceType) {
andClauses.push({ NOT: { resourceType: null } });
}
if (rolledOff !== undefined) {
andClauses.push({ rolledOff });
}
if (departed !== undefined) {
andClauses.push({ departed });
}
andClauses.push(...cfConditions);
const where = andClauses.length > 0 ? { AND: andClauses } : {};
if (directory) {
const rawResources = await (includeRoles
? ctx.db.resource.findMany({
where,
include: {
resourceRoles: {
include: { role: { select: ROLE_BRIEF_SELECT } },
},
},
orderBy: [{ displayName: "asc" }, { id: "asc" }],
})
: ctx.db.resource.findMany({
where,
orderBy: [{ displayName: "asc" }, { id: "asc" }],
}));
const directoryResources = rawResources.map((resource) => ({
id: resource.id,
eid: resource.eid,
displayName: resource.displayName,
email: resource.email,
}));
const requestedIds = eids
? resolveResourceIdsByDisplayedEids(directoryResources, directory, eids)
: [];
const requestedIdSet = requestedIds.length > 0 ? new Set(requestedIds) : null;
const filteredResources = rawResources.filter((resource) => {
const alias = directory.byResourceId.get(resource.id);
if (requestedIdSet && !requestedIdSet.has(resource.id)) {
return false;
}
if (eids && eids.length > 0 && requestedIds.length === 0) {
return false;
}
if (search && !anonymizeSearchMatches(
{
id: resource.id,
eid: resource.eid,
displayName: resource.displayName,
email: resource.email,
},
alias,
search,
)) {
return false;
}
return true;
});
const anonymizedResources = anonymizeResources(filteredResources, directory).sort((left, right) => {
const displayNameCompare = left.displayName.localeCompare(right.displayName);
if (displayNameCompare !== 0) {
return displayNameCompare;
}
return left.id.localeCompare(right.id);
});
const total = anonymizedResources.length;
const afterCursor = parsedCursor
? anonymizedResources.filter(
(resource) =>
resource.displayName > parsedCursor.displayName ||
(resource.displayName === parsedCursor.displayName && resource.id > parsedCursor.id),
)
: anonymizedResources;
const skip = cursor ? 0 : (page - 1) * limit;
const paged = afterCursor.slice(skip, skip + limit + 1);
const hasMore = paged.length > limit;
const resources = hasMore ? paged.slice(0, limit) : paged;
const nextCursor = hasMore
? JSON.stringify({
displayName: resources[resources.length - 1]!.displayName,
id: resources[resources.length - 1]!.id,
})
: null;
return { resources, total, page, limit, nextCursor };
}
const skip = cursor ? 0 : (page - 1) * limit;
const orderBy = [{ displayName: "asc" as const }, { id: "asc" as const }];
const whereWithCursor = parsedCursor
? {
AND: [
...((where as { AND?: WhereClause[] }).AND ?? []),
{
OR: [
{ displayName: { gt: parsedCursor.displayName } },
{ displayName: parsedCursor.displayName, id: { gt: parsedCursor.id } },
],
},
],
}
: where;
const baseQuery = { where: whereWithCursor, skip, take: limit + 1, orderBy };
const [rawResources, total] = await Promise.all([
includeRoles
? ctx.db.resource.findMany({
...baseQuery,
include: {
resourceRoles: {
include: { role: { select: ROLE_BRIEF_SELECT } },
},
},
})
: ctx.db.resource.findMany(baseQuery),
ctx.db.resource.count({ where }),
]);
const hasMore = rawResources.length > limit;
const resources = hasMore ? rawResources.slice(0, limit) : rawResources;
const nextCursor = hasMore
? JSON.stringify({
displayName: resources[resources.length - 1]!.displayName,
id: resources[resources.length - 1]!.id,
})
: null;
return { resources, total, page, limit, nextCursor };
}),
/** Lightweight resource card for hover tooltips on the timeline. */
getHoverCard: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const resource = await findUniqueOrThrow(
ctx.db.resource.findUnique({
where: { id: input.id },
select: {
id: true,
displayName: true,
eid: true,
email: true,
chapter: true,
lcrCents: true,
ucrCents: true,
currency: true,
chargeabilityTarget: true,
skills: true,
availability: true,
isActive: true,
areaRole: { select: ROLE_BRIEF_SELECT },
country: { select: { name: true, code: true } },
managementLevel: { select: { name: true } },
resourceType: true,
},
}),
"Resource",
);
const directory = await getAnonymizationDirectory(ctx.db);
const anon = anonymizeResource(resource, directory);
return {
id: anon.id,
displayName: anon.displayName ?? "",
eid: anon.eid ?? "",
chapter: resource.chapter,
lcrCents: resource.lcrCents,
ucrCents: resource.ucrCents,
currency: resource.currency,
chargeabilityTarget: resource.chargeabilityTarget,
skills: resource.skills as Record<string, unknown>[],
isActive: resource.isActive,
resourceType: resource.resourceType,
areaRole: resource.areaRole,
country: resource.country,
managementLevel: resource.managementLevel,
};
}),
getById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const resource = await findUniqueOrThrow(
ctx.db.resource.findUnique({
where: { id: input.id },
include: {
blueprint: true,
resourceRoles: {
include: { role: { select: ROLE_BRIEF_SELECT } },
},
areaRole: { select: { id: true, name: true } },
},
}),
"Resource",
);
const directory = await getAnonymizationDirectory(ctx.db);
return {
...anonymizeResource(resource, directory),
isOwnedByCurrentUser: Boolean(resource.userId && ctx.dbUser?.id && resource.userId === ctx.dbUser.id),
};
}),
getByEid: protectedProcedure
.input(z.object({ eid: z.string() }))
.query(async ({ ctx, input }) => {
const directory = await getAnonymizationDirectory(ctx.db);
let resource = await ctx.db.resource.findUnique({ where: { eid: input.eid } });
if (!resource && directory) {
const resourceId = directory.byAliasEid.get(input.eid.trim().toLowerCase());
if (resourceId) {
resource = await ctx.db.resource.findUnique({ where: { id: resourceId } });
}
}
if (!resource) {
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
}
return anonymizeResource(resource, directory);
}),
create: managerProcedure
.input(CreateResourceSchema.extend({ roles: z.array(ResourceRoleSchema).optional() }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
const existing = await ctx.db.resource.findFirst({
where: { OR: [{ eid: input.eid }, { email: input.email }] },
});
if (existing) {
throw new TRPCError({
code: "CONFLICT",
message: `Resource with EID "${input.eid}" or email "${input.email}" already exists`,
});
}
await assertBlueprintDynamicFields({
db: ctx.db,
blueprintId: input.blueprintId,
dynamicFields: input.dynamicFields,
target: BlueprintTarget.RESOURCE,
});
// Enforce max 1 primary role
const primaryCount = (input.roles ?? []).filter((r) => r.isPrimary).length;
if (primaryCount > 1) {
throw new TRPCError({ code: "BAD_REQUEST", message: "A resource can have at most one primary role" });
}
const resource = await ctx.db.resource.create({
data: {
eid: input.eid,
displayName: input.displayName,
email: input.email,
chapter: input.chapter,
lcrCents: input.lcrCents,
ucrCents: input.ucrCents,
currency: input.currency,
chargeabilityTarget: input.chargeabilityTarget,
availability: input.availability,
skills: input.skills as unknown as import("@planarchy/db").Prisma.InputJsonValue,
dynamicFields: input.dynamicFields as unknown as import("@planarchy/db").Prisma.InputJsonValue,
blueprintId: input.blueprintId,
portfolioUrl: input.portfolioUrl || undefined,
roleId: input.roleId || undefined,
...(input.postalCode !== undefined ? { postalCode: input.postalCode } : {}),
...(input.postalCode && !input.federalState
? { federalState: inferStateFromPostalCode(input.postalCode) }
: input.federalState !== undefined
? { federalState: input.federalState }
: {}),
...(input.countryId !== undefined ? { countryId: input.countryId || null } : {}),
...(input.metroCityId !== undefined ? { metroCityId: input.metroCityId || null } : {}),
...(input.orgUnitId !== undefined ? { orgUnitId: input.orgUnitId || null } : {}),
...(input.managementLevelGroupId !== undefined ? { managementLevelGroupId: input.managementLevelGroupId || null } : {}),
...(input.managementLevelId !== undefined ? { managementLevelId: input.managementLevelId || null } : {}),
...(input.resourceType !== undefined ? { resourceType: input.resourceType } : {}),
...(input.chgResponsibility !== undefined ? { chgResponsibility: input.chgResponsibility } : {}),
...(input.rolledOff !== undefined ? { rolledOff: input.rolledOff } : {}),
...(input.departed !== undefined ? { departed: input.departed } : {}),
...(input.enterpriseId !== undefined ? { enterpriseId: input.enterpriseId || null } : {}),
...(input.clientUnitId !== undefined ? { clientUnitId: input.clientUnitId || null } : {}),
...(input.fte !== undefined ? { fte: input.fte } : {}),
resourceRoles: input.roles?.length
? {
create: input.roles.map((r) => ({
roleId: r.roleId,
isPrimary: r.isPrimary,
})),
}
: undefined,
} as unknown as Parameters<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("@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: ROLE_BRIEF_SELECT } } },
},
});
// Replace roles if provided
if (input.data.roles !== undefined) {
await ctx.db.resourceRole.deleteMany({ where: { resourceId: input.id } });
if (input.data.roles.length > 0) {
await ctx.db.resourceRole.createMany({
data: input.data.roles.map((r) => ({
resourceId: input.id,
roleId: r.roleId,
isPrimary: r.isPrimary,
})),
});
}
}
await ctx.db.auditLog.create({
data: {
entityType: "Resource",
entityId: input.id,
action: "UPDATE",
changes: { before: existing, after: updated },
},
});
return updated;
}),
deactivate: managerProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
const resource = await ctx.db.resource.update({
where: { id: input.id },
data: { isActive: false },
});
await ctx.db.auditLog.create({
data: {
entityType: "Resource",
entityId: input.id,
action: "UPDATE",
changes: { after: { isActive: false } },
},
});
return resource;
}),
batchDeactivate: managerProcedure
.input(z.object({ ids: z.array(z.string()).min(1).max(100) }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
const updated = await ctx.db.$transaction(
input.ids.map((id) =>
ctx.db.resource.update({ where: { id }, data: { isActive: false } }),
),
);
await ctx.db.auditLog.create({
data: {
entityType: "Resource",
entityId: input.ids.join(","),
action: "UPDATE",
changes: { after: { isActive: false, ids: input.ids } },
},
});
return { count: updated.length };
}),
chapters: protectedProcedure.query(async ({ ctx }) => {
const resources = await ctx.db.resource.findMany({
where: { isActive: true, chapter: { not: null } },
select: { chapter: true },
distinct: ["chapter"],
orderBy: { chapter: "asc" },
});
return resources.map((r) => r.chapter as string);
}),
// ─── Skill Matrix Import ────────────────────────────────────────────────────
importSkillMatrix: protectedProcedure
.input(
z.object({
skills: z.array(SkillEntrySchema),
employeeInfo: z
.object({
roleId: z.string().optional(),
yearsOfExperience: z.number().optional(),
portfolioUrl: z.string().url().optional().or(z.literal("")),
})
.optional(),
}),
)
.mutation(async ({ ctx, input }) => {
// Find the resource linked to this user
const user = await findUniqueOrThrow(
ctx.db.user.findUnique({
where: { email: ctx.session.user?.email ?? "" },
include: { resource: true },
}),
"User",
);
if (!user.resource) {
throw new TRPCError({ code: "NOT_FOUND", message: "No resource linked to your account" });
}
const resourceId = user.resource.id;
await ctx.db.resource.update({
where: { id: resourceId },
data: {
skills: input.skills as unknown as import("@planarchy/db").Prisma.InputJsonValue,
skillMatrixUpdatedAt: new Date(),
...(input.employeeInfo?.portfolioUrl !== undefined
? { portfolioUrl: input.employeeInfo.portfolioUrl || null }
: {}),
...(input.employeeInfo?.roleId !== undefined ? { roleId: input.employeeInfo.roleId } : {}),
},
});
return { count: input.skills.length };
}),
importSkillMatrixForResource: managerProcedure
.input(
z.object({
resourceId: z.string(),
skills: z.array(SkillEntrySchema),
employeeInfo: z
.object({
roleId: z.string().optional(),
yearsOfExperience: z.number().optional(),
portfolioUrl: z.string().url().optional().or(z.literal("")),
})
.optional(),
}),
)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
await findUniqueOrThrow(
ctx.db.resource.findUnique({ where: { id: input.resourceId } }),
"Resource",
);
await ctx.db.resource.update({
where: { id: input.resourceId },
data: {
skills: input.skills as unknown as import("@planarchy/db").Prisma.InputJsonValue,
skillMatrixUpdatedAt: new Date(),
...(input.employeeInfo?.portfolioUrl !== undefined
? { portfolioUrl: input.employeeInfo.portfolioUrl || null }
: {}),
...(input.employeeInfo?.roleId !== undefined ? { roleId: input.employeeInfo.roleId } : {}),
},
});
return { count: input.skills.length };
}),
batchImportSkillMatrices: adminProcedure
.input(
z.object({
entries: z.array(
z.object({
eid: z.string(),
skills: z.array(SkillEntrySchema),
employeeInfo: z
.object({
roleId: z.string().optional(),
yearsOfExperience: z.number().optional(),
portfolioUrl: z.string().url().optional().or(z.literal("")),
})
.optional(),
}),
),
}),
)
.mutation(async ({ ctx, input }) => {
// Single findMany to avoid N+1 (was: findUnique per entry)
const eids = input.entries.map((e) => e.eid);
const existing = await ctx.db.resource.findMany({
where: { eid: { in: eids } },
select: { id: true, eid: true },
});
const eidToId = new Map(existing.map((r) => [r.eid, r.id]));
const notFound = input.entries.length - existing.length;
const now = new Date();
const updates = input.entries
.filter((entry) => eidToId.has(entry.eid))
.map((entry) =>
ctx.db.resource.update({
where: { id: eidToId.get(entry.eid)! },
data: {
skills: entry.skills as unknown as import("@planarchy/db").Prisma.InputJsonValue,
skillMatrixUpdatedAt: now,
...(entry.employeeInfo?.portfolioUrl !== undefined
? { portfolioUrl: entry.employeeInfo.portfolioUrl || null }
: {}),
...(entry.employeeInfo?.roleId !== undefined ? { roleId: entry.employeeInfo.roleId } : {}),
},
}),
);
await ctx.db.$transaction(updates);
return { updated: updates.length, notFound };
}),
// ─── AI Summary ─────────────────────────────────────────────────────────────
generateAiSummary: managerProcedure
.input(z.object({ resourceId: z.string() }))
.mutation(async ({ ctx, input }) => {
const [resource, settings] = await Promise.all([
findUniqueOrThrow(
ctx.db.resource.findUnique({
where: { id: input.resourceId },
include: { areaRole: { select: { name: true } } },
}),
"Resource",
),
ctx.db.systemSettings.findUnique({ where: { id: "singleton" } }),
]);
if (!isAiConfigured(settings)) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "AI is not configured. Please set credentials in Admin → Settings.",
});
}
type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean };
const skills = (resource.skills as unknown as SkillRow[]) ?? [];
const mainSkills = skills.filter((s) => s.isMainSkill).map((s) => s.skill);
const top10 = [...skills]
.sort((a, b) => b.proficiency - a.proficiency)
.slice(0, 10)
.map((s) => `${s.skill} (${s.proficiency}/5)`);
const vars = {
role: resource.areaRole?.name ?? "Not specified",
chapter: resource.chapter ?? "Not specified",
mainSkills: mainSkills.length > 0 ? mainSkills.join(", ") : "Not specified",
topSkills: top10.join(", "),
};
const templateStr = settings!.aiSummaryPrompt ?? DEFAULT_SUMMARY_PROMPT;
const prompt = templateStr
.replace("{role}", vars.role)
.replace("{chapter}", vars.chapter)
.replace("{mainSkills}", vars.mainSkills)
.replace("{topSkills}", vars.topSkills);
const client = createAiClient(settings!);
const model = settings!.azureOpenAiDeployment!;
const maxTokens = settings!.aiMaxCompletionTokens ?? 300;
const temperature = settings!.aiTemperature ?? 1;
async function callChatCompletions(withTemperature: boolean) {
return client.chat.completions.create({
messages: [{ role: "user", content: prompt }],
max_completion_tokens: maxTokens,
model,
...(withTemperature && temperature !== 1 ? { temperature } : {}),
});
}
let summary = "";
try {
let completion;
try {
completion = await callChatCompletions(true);
console.log("[generateAiSummary] chat.completions response:", JSON.stringify({
choices: completion.choices?.map(c => ({ content: c.message?.content, finish_reason: c.finish_reason })),
}));
} catch (tempErr) {
const status = (tempErr as { status?: number }).status;
const msg = (tempErr as Error).message ?? "";
console.log("[generateAiSummary] chat.completions error:", status, msg.slice(0, 200));
if (status === 400 && msg.includes("temperature")) {
completion = await callChatCompletions(false);
} else if (status === 404) {
console.log("[generateAiSummary] falling back to responses API");
const resp = await client.responses.create({ model, input: prompt, max_output_tokens: maxTokens });
console.log("[generateAiSummary] responses output_text:", resp.output_text?.slice(0, 100));
summary = resp.output_text?.trim() ?? "";
completion = null;
} else {
throw tempErr;
}
}
if (completion) summary = completion.choices[0]?.message?.content?.trim() ?? "";
} catch (e) {
throw e;
}
await ctx.db.resource.update({
where: { id: input.resourceId },
data: { aiSummary: summary, aiSummaryUpdatedAt: new Date() },
});
return { summary };
}),
// ─── Skills Analytics ───────────────────────────────────────────────────────
getSkillsAnalytics: controllerProcedure.query(async ({ ctx }) => {
const resources = await ctx.db.resource.findMany({
where: { isActive: true },
select: { id: true, displayName: true, chapter: true, skills: true },
});
type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean };
// Aggregate: { skillName, category, count, totalProficiency, chapters }
const skillMap = new Map<
string,
{ skill: string; category: string; count: number; totalProficiency: number; chapters: Set<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));
const directory = await getAnonymizationDirectory(ctx.db);
return anonymizeResources(results, directory);
}),
// ─── Self-service ────────────────────────────────────────────────────────────
/** Get the resource linked to the current user (for self-service pages). */
getMyResource: protectedProcedure.query(async ({ ctx }) => {
const email = ctx.session.user?.email;
if (!email) return null;
const user = await ctx.db.user.findUnique({
where: { email },
select: { resource: { select: { id: true, displayName: true, eid: true, chapter: true } } },
});
const directory = await getAnonymizationDirectory(ctx.db);
return user?.resource ? anonymizeResource(user.resource, directory) : null;
}),
// ─── Value Score ─────────────────────────────────────────────────────────────
getValueScores: protectedProcedure
.input(
z.object({
isActive: z.boolean().optional().default(true),
limit: z.number().int().min(1).max(500).default(100),
}),
)
.query(async ({ ctx, input }) => {
const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
const visibleRoles = (settings?.scoreVisibleRoles as unknown as string[]) ?? ["ADMIN", "MANAGER"];
const userRole = (ctx.session.user as { role?: string } | undefined)?.role ?? "USER";
if (!visibleRoles.includes(userRole)) return [];
const resources = await ctx.db.resource.findMany({
where: { isActive: input.isActive },
select: {
id: true,
eid: true,
displayName: true,
chapter: true,
lcrCents: true,
valueScore: true,
valueScoreBreakdown: true,
valueScoreUpdatedAt: true,
},
orderBy: [{ valueScore: "desc" }, { displayName: "asc" }],
take: input.limit,
});
const directory = await getAnonymizationDirectory(ctx.db);
return anonymizeResources(resources, directory);
}),
recomputeValueScores: adminProcedure.mutation(async ({ ctx }) => {
return recomputeResourceValueScores(ctx.db);
}),
listWithUtilization: controllerProcedure
.input(
z.object({
startDate: z.string().datetime().optional(),
endDate: z.string().datetime().optional(),
chapter: z.string().optional(),
includeProposed: z.boolean().default(false),
limit: z.number().int().min(1).max(500).default(100),
}),
)
.query(async ({ ctx, input }) => {
const now = new Date();
const start = input.startDate ? new Date(input.startDate) : new Date(now.getFullYear(), now.getMonth(), 1);
const end = input.endDate ? new Date(input.endDate) : new Date(now.getFullYear(), now.getMonth() + 3, 0);
const resources = await ctx.db.resource.findMany({
where: {
isActive: true,
...(input.chapter ? { chapter: input.chapter } : {}),
},
take: input.limit,
orderBy: { displayName: "asc" },
select: {
id: true,
eid: true,
displayName: true,
email: true,
chapter: true,
lcrCents: true,
ucrCents: true,
currency: true,
chargeabilityTarget: true,
availability: true,
skills: true,
dynamicFields: true,
blueprintId: true,
isActive: true,
createdAt: true,
updatedAt: true,
roleId: true,
portfolioUrl: true,
postalCode: true,
federalState: true,
valueScore: true,
valueScoreBreakdown: true,
valueScoreUpdatedAt: true,
userId: true,
},
});
const bookings = await listAssignmentBookings(ctx.db, {
startDate: start,
endDate: end,
resourceIds: resources.map((resource) => resource.id),
});
const directory = await getAnonymizationDirectory(ctx.db);
return resources.map((r) => {
const avail = r.availability as Record<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 &&
(input.includeProposed || booking.status !== "PROPOSED"),
);
for (const a of resourceBookings) {
const days =
(new Date(a.endDate).getTime() - new Date(a.startDate).getTime()) /
(1000 * 60 * 60 * 24) +
1;
bookedHours += a.hoursPerDay * days;
if (a.hoursPerDay > dailyAvailHours) isOverbooked = true;
}
const utilizationPercent =
availableHours > 0 ? Math.round((bookedHours / availableHours) * 100) : 0;
return anonymizeResource({
...r,
bookingCount: resourceBookings.length,
bookedHours: Math.round(bookedHours),
availableHours: Math.round(availableHours),
utilizationPercent,
isOverbooked,
}, directory);
});
}),
getChargeabilityStats: controllerProcedure
.input(z.object({ includeProposed: z.boolean().default(false), resourceId: z.string().optional() }))
.query(async ({ ctx, input }) => {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), 1);
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
const resources = await ctx.db.resource.findMany({
where: {
isActive: true,
...(input.resourceId ? { id: input.resourceId } : {}),
},
select: {
id: true,
eid: true,
displayName: true,
chapter: true,
chargeabilityTarget: true,
availability: true,
},
});
const bookings = await listAssignmentBookings(ctx.db, {
startDate: start,
endDate: end,
resourceIds: resources.map((resource) => resource.id),
});
const directory = await getAnonymizationDirectory(ctx.db);
return resources.map((r) => {
const avail = r.availability as unknown as WeekdayAvailability;
const resourceBookings = bookings.filter((booking) => booking.resourceId === r.id);
const actualAllocs = resourceBookings.filter((booking) =>
isChargeabilityActualBooking(booking, input.includeProposed),
);
const expectedAllocs = resourceBookings.filter((booking) =>
isChargeabilityRelevantProject(booking.project, true),
);
const actual = computeChargeability(avail, actualAllocs, start, end);
const expected = computeChargeability(avail, expectedAllocs, start, end);
return anonymizeResource({
id: r.id,
eid: r.eid,
displayName: r.displayName,
chapter: r.chapter,
chargeabilityTarget: r.chargeabilityTarget,
actualChargeability: actual.chargeability,
expectedChargeability: expected.chargeability,
availableHours: actual.availableHours,
}, directory);
});
}),
/**
* Bulk-update dynamicFields on a set of resources (merges — does not overwrite other keys).
*/
batchUpdateCustomFields: managerProcedure
.input(z.object({
ids: z.array(z.string()).min(1).max(100),
fields: z.record(z.string(), z.unknown()),
}))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
await ctx.db.$transaction(
input.ids.map((id) =>
ctx.db.$executeRaw`
UPDATE "Resource"
SET "dynamicFields" = "dynamicFields" || ${JSON.stringify(input.fields)}::jsonb
WHERE id = ${id}
`,
),
);
await ctx.db.auditLog.create({
data: {
entityType: "Resource",
entityId: input.ids.join(","),
action: "UPDATE",
changes: { after: { dynamicFields: input.fields, ids: input.ids } } as unknown as import("@planarchy/db").Prisma.InputJsonValue,
},
});
return { updated: input.ids.length };
}),
// ─── Skill Marketplace ────────────────────────────────────────────────────
getSkillMarketplace: controllerProcedure
.input(
z.object({
// Section 1: Skill search
searchSkill: z.string().optional(),
minProficiency: z.number().int().min(1).max(5).optional().default(1),
availableOnly: z.boolean().optional().default(false),
}),
)
.query(async ({ ctx, input }) => {
const now = new Date();
const thirtyDaysFromNow = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean };
// ── Fetch all active resources with skills ──
const resources = await ctx.db.resource.findMany({
where: { isActive: true },
select: {
id: true,
displayName: true,
eid: true,
chapter: true,
skills: true,
availability: true,
chargeabilityTarget: true,
},
});
// ── Fetch current assignments for utilization calc ──
const allResourceIds = resources.map((r) => r.id);
const assignments = await ctx.db.assignment.findMany({
where: {
resourceId: { in: allResourceIds },
status: { in: ["PROPOSED", "CONFIRMED", "ACTIVE", "COMPLETED"] },
endDate: { gte: now },
startDate: { lte: thirtyDaysFromNow },
},
select: {
resourceId: true,
startDate: true,
endDate: true,
hoursPerDay: true,
},
});
// Build utilization map (simple: booked hours per day / available hours per day)
const utilizationMap = new Map<string, { utilizationPercent: number; earliestAvailableDate: Date | null }>();
for (const r of resources) {
const avail = r.availability as Record<string, number>;
const dailyAvailHours = Object.values(avail).reduce((s, h) => s + (h ?? 0), 0) / 5;
const resourceAssignments = assignments.filter((a) => a.resourceId === r.id);
// Current daily booked hours (assignments overlapping today)
let todayBooked = 0;
for (const a of resourceAssignments) {
if (a.startDate <= now && a.endDate >= now) {
todayBooked += a.hoursPerDay;
}
}
const utilizationPercent = dailyAvailHours > 0 ? Math.round((todayBooked / dailyAvailHours) * 100) : 0;
// Find earliest date when resource has capacity (within 30 days)
let earliestAvailableDate: Date | null = null;
const checkDate = new Date(now);
for (let i = 0; i < 30; i++) {
const day = checkDate.getDay();
if (day !== 0 && day !== 6) {
let dayBooked = 0;
for (const a of resourceAssignments) {
if (a.startDate <= checkDate && a.endDate >= checkDate) {
dayBooked += a.hoursPerDay;
}
}
if (dayBooked < dailyAvailHours * 0.8) {
earliestAvailableDate = new Date(checkDate);
break;
}
}
checkDate.setDate(checkDate.getDate() + 1);
}
utilizationMap.set(r.id, { utilizationPercent, earliestAvailableDate });
}
// ── Section 1: Skill Search ──
let searchResults: Array<{
id: string;
displayName: string;
chapter: string | null;
skillProficiency: number;
skillName: string;
utilizationPercent: number;
availableFrom: string | null;
}> = [];
if (input.searchSkill && input.searchSkill.trim().length > 0) {
const needle = input.searchSkill.toLowerCase();
for (const r of resources) {
const skills = (r.skills as unknown as SkillRow[]) ?? [];
const match = skills.find(
(s) => s.skill.toLowerCase().includes(needle) && s.proficiency >= input.minProficiency,
);
if (!match) continue;
const util = utilizationMap.get(r.id);
if (input.availableOnly && !util?.earliestAvailableDate) continue;
searchResults.push({
id: r.id,
displayName: r.displayName,
chapter: r.chapter,
skillProficiency: match.proficiency,
skillName: match.skill,
utilizationPercent: util?.utilizationPercent ?? 0,
availableFrom: util?.earliestAvailableDate?.toISOString() ?? null,
});
}
searchResults.sort((a, b) => b.skillProficiency - a.skillProficiency || a.utilizationPercent - b.utilizationPercent);
}
// ── Section 2: Skill Gap Heat Map ──
// Demand: from unfilled DemandRequirements + project staffingReqs skills
const unfilled = await ctx.db.demandRequirement.findMany({
where: {
endDate: { gte: now },
assignments: { none: {} },
},
select: {
id: true,
role: true,
roleId: true,
headcount: true,
project: {
select: { staffingReqs: true },
},
},
});
// Collect demanded skills from project staffingReqs
const demandSkillCounts = new Map<string, number>();
for (const demand of unfilled) {
const staffingReqs = (demand.project.staffingReqs as unknown as Array<{
role?: string;
roleId?: string;
requiredSkills?: string[];
}>) ?? [];
// Match demand to staffing req by role or roleId
const matchedReq = staffingReqs.find(
(sr) =>
(demand.roleId && sr.roleId === demand.roleId) ||
(demand.role && sr.role === demand.role),
);
if (matchedReq?.requiredSkills) {
for (const skill of matchedReq.requiredSkills) {
demandSkillCounts.set(skill, (demandSkillCounts.get(skill) ?? 0) + demand.headcount);
}
}
}
// Supply: count resources with skill at proficiency >= 3
const supplySkillCounts = new Map<string, number>();
const allSkillCounts = new Map<string, number>();
for (const r of resources) {
const skills = (r.skills as unknown as SkillRow[]) ?? [];
for (const s of skills) {
allSkillCounts.set(s.skill, (allSkillCounts.get(s.skill) ?? 0) + 1);
if (s.proficiency >= 3) {
supplySkillCounts.set(s.skill, (supplySkillCounts.get(s.skill) ?? 0) + 1);
}
}
}
// Merge all skill names from both demand and supply
const allGapSkills = new Set([...demandSkillCounts.keys(), ...supplySkillCounts.keys()]);
const gapData = Array.from(allGapSkills)
.map((skill) => {
const supply = supplySkillCounts.get(skill) ?? 0;
const demand = demandSkillCounts.get(skill) ?? 0;
return { skill, supply, demand, gap: demand - supply };
})
.sort((a, b) => b.gap - a.gap);
// ── Section 3: Distribution (top 20 by resource count) ──
const aggregated = Array.from(
(() => {
const map = new Map<string, { skill: string; count: number; totalProficiency: number }>();
for (const r of resources) {
const skills = (r.skills as unknown as SkillRow[]) ?? [];
for (const s of skills) {
const entry = map.get(s.skill);
if (entry) {
entry.count++;
entry.totalProficiency += s.proficiency;
} else {
map.set(s.skill, { skill: s.skill, count: 1, totalProficiency: s.proficiency });
}
}
}
return map;
})().values(),
)
.map((e) => ({
skill: e.skill,
count: e.count,
avgProficiency: Math.round((e.totalProficiency / e.count) * 10) / 10,
}))
.sort((a, b) => b.count - a.count)
.slice(0, 20);
const directory = await getAnonymizationDirectory(ctx.db);
return {
searchResults: anonymizeResources(searchResults, directory),
gapData,
distribution: aggregated,
totalResources: resources.length,
};
}),
});