Files
Nexus/packages/api/src/lib/auto-staffing.ts
T
Hartmut cd78f72f33 chore: full technical rename planarchy → capakraken
Complete rename of all technical identifiers across the codebase:

Package names (11 packages):
- @planarchy/* → @capakraken/* in all package.json, tsconfig, imports

Import statements: 277 files, 548 occurrences replaced

Database & Docker:
- PostgreSQL user/db: planarchy → capakraken
- Docker volumes: planarchy_pgdata → capakraken_pgdata
- Connection strings updated in docker-compose, .env, CI

CI/CD:
- GitHub Actions workflow: all filter commands updated
- Test database credentials updated

Infrastructure:
- Redis channel: planarchy:sse → capakraken:sse
- Logger service name: planarchy-api → capakraken-api
- Anonymization seed updated
- Start/stop/restart scripts updated

Test data:
- Seed emails: @planarchy.dev → @capakraken.dev
- E2E test credentials: all 11 spec files updated
- Email defaults: @planarchy.app → @capakraken.app
- localStorage keys: planarchy_* → capakraken_*

Documentation: 30+ .md files updated

Verification:
- pnpm install: workspace resolution works
- TypeScript: only pre-existing TS2589 (no new errors)
- Engine: 310/310 tests pass
- Staffing: 37/37 tests pass

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-27 13:18:09 +01:00

247 lines
7.3 KiB
TypeScript

import { listAssignmentBookings } from "@capakraken/application";
import { rankResources } from "@capakraken/staffing";
import type { SkillEntry } from "@capakraken/shared";
import { createNotificationsForUsers } from "./create-notification.js";
/**
* Minimal DB interface for auto-staffing — avoids importing the full PrismaClient.
* Follows the same pattern as budget-alerts.ts.
*/
type DbClient = Parameters<typeof listAssignmentBookings>[0] & {
demandRequirement: {
findUnique: (args: {
where: { id: string };
select: {
id: true;
projectId: true;
startDate: true;
endDate: true;
hoursPerDay: true;
role: true;
roleId: true;
headcount: true;
budgetCents: true;
};
}) => Promise<{
id: string;
projectId: string;
startDate: Date;
endDate: Date;
hoursPerDay: number;
role: string | null;
roleId: string | null;
headcount: number;
budgetCents: number;
} | null>;
};
project: {
findUnique: (args: {
where: { id: string };
select: { id: true; name: true };
}) => Promise<{ id: string; name: string } | null>;
};
role: {
findUnique: (args: {
where: { id: string };
select: { id: true; name: true };
}) => Promise<{ id: string; name: string } | null>;
};
resource: {
findMany: (args: {
where: { isActive: true };
}) => Promise<Array<{
id: string;
displayName: string;
eid: string | null;
skills: unknown;
lcrCents: number;
chargeabilityTarget: number;
availability: unknown;
valueScore: number | null;
}>>;
};
notification: {
create: (args: {
data: {
userId: string;
type: string;
category: string;
priority: string;
title: string;
body: string;
entityId: string;
entityType: string;
link: string;
channel: string;
};
}) => Promise<{ id: string; userId: string }>;
};
user: {
findMany: (args: {
where: { systemRole: { in: string[] } };
select: { id: true };
}) => Promise<Array<{ id: string }>>;
};
};
const TOP_N = 3;
/**
* Generate automatic staffing suggestions for a demand requirement.
*
* Fetches the demand's role/dates/hours, runs the staffing ranking algorithm
* for the top 3 matches, and creates a notification for project managers
* with a summary of the suggestions.
*
* This function is designed to be called fire-and-forget (non-blocking).
* It swallows all errors to avoid disrupting the caller.
*/
export async function generateAutoSuggestions(
db: DbClient,
demandRequirementId: string,
): Promise<void> {
try {
// 1. Load the demand requirement
const demand = await db.demandRequirement.findUnique({
where: { id: demandRequirementId },
select: {
id: true,
projectId: true,
startDate: true,
endDate: true,
hoursPerDay: true,
role: true,
roleId: true,
headcount: true,
budgetCents: true,
},
});
if (!demand) return;
// 2. Resolve project and role names
const [project, roleEntity] = await Promise.all([
db.project.findUnique({
where: { id: demand.projectId },
select: { id: true, name: true },
}),
demand.roleId
? db.role.findUnique({
where: { id: demand.roleId },
select: { id: true, name: true },
})
: Promise.resolve(null),
]);
if (!project) return;
const roleName = roleEntity?.name ?? demand.role ?? "Unspecified role";
// 3. Derive required skills from role name
// The role name itself is treated as the primary required skill.
// Resources with matching skill names in their skill matrix will rank highest.
const requiredSkills = [roleName];
// 4. Fetch all active resources and their current bookings
const resources = await db.resource.findMany({
where: { isActive: true },
});
if (resources.length === 0) return;
const bookings = await listAssignmentBookings(db, {
startDate: demand.startDate,
endDate: demand.endDate,
resourceIds: resources.map((r) => r.id),
});
// 5. Enrich resources with utilization data for the demand's date range
const enrichedResources = resources.map((resource) => {
const avail = resource.availability as
| { monday?: number; tuesday?: number; wednesday?: number; thursday?: number; friday?: number }
| null;
const totalAvailableHours = avail?.monday ?? 8;
const resourceBookings = bookings.filter((b) => b.resourceId === resource.id);
const allocatedHoursPerDay = resourceBookings.reduce(
(sum, b) => sum + b.hoursPerDay,
0,
);
const utilizationPercent =
totalAvailableHours > 0
? Math.min(100, (allocatedHoursPerDay / totalAvailableHours) * 100)
: 0;
const wouldExceedCapacity =
allocatedHoursPerDay + demand.hoursPerDay > totalAvailableHours;
return {
id: resource.id,
displayName: resource.displayName,
eid: resource.eid,
skills: resource.skills as unknown as SkillEntry[],
lcrCents: resource.lcrCents,
chargeabilityTarget: resource.chargeabilityTarget,
currentUtilizationPercent: utilizationPercent,
hasAvailabilityConflicts: wouldExceedCapacity,
conflictDays: wouldExceedCapacity ? ["(multiple days)"] : [],
valueScore: resource.valueScore ?? 0,
};
});
// 6. Rank resources using the staffing algorithm
const budgetLcrCentsPerHour =
demand.budgetCents > 0 ? demand.budgetCents : undefined;
const ranked = rankResources({
requiredSkills,
resources: enrichedResources,
budgetLcrCentsPerHour,
} as unknown as Parameters<typeof rankResources>[0]);
// Value-score tiebreaker (same logic as staffing router)
ranked.sort((a, b) => {
if (Math.abs(a.score - b.score) <= 2) {
const aVal = enrichedResources.find((r) => r.id === a.resourceId)?.valueScore ?? 0;
const bVal = enrichedResources.find((r) => r.id === b.resourceId)?.valueScore ?? 0;
return bVal - aVal;
}
return 0;
});
const topSuggestions = ranked.slice(0, TOP_N);
if (topSuggestions.length === 0) return;
// 7. Build notification message
const suggestionSummary = topSuggestions
.map((s) => `${s.resourceName} (${s.score}%)`)
.join(", ");
const title = `Staffing suggestions for ${roleName} on ${project.name}`;
const body = `${topSuggestions.length} matching resources found for ${roleName} on ${project.name}: ${suggestionSummary}`;
// 8. Notify all managers and admins
const managers = await db.user.findMany({
where: { systemRole: { in: ["ADMIN", "MANAGER"] } },
select: { id: true },
});
await createNotificationsForUsers({
db,
userIds: managers.map((m) => m.id),
type: "AUTO_STAFFING_SUGGESTION",
category: "NOTIFICATION",
priority: "NORMAL",
title,
body,
entityId: demandRequirementId,
entityType: "demand",
link: `/staffing?demandId=${demandRequirementId}`,
channel: "in_app",
});
} catch {
// Fire-and-forget: swallow all errors to avoid disrupting the caller.
}
}