fbeab5cd79
AI-Powered Insights (G9): - Rule-based anomaly detection: budget burn rate, staffing gaps, utilization, timeline overruns across all active projects - AI narrative generation via existing Azure OpenAI integration - Cached in project dynamicFields to avoid regeneration - New /analytics/insights page with anomaly feed + project summaries - Sidebar nav: "AI Insights" under Analytics Webhook System + Slack (G10): - Webhook model in Prisma (url, secret, events, isActive) - HMAC-SHA256 signed payloads with 5s timeout fire-and-forget dispatch - Slack-aware: routes hooks.slack.com URLs through Slack formatter - 6 events integrated: allocation.created/updated/deleted, project.created/ status_changed, vacation.approved - Admin UI: /admin/webhooks with CRUD, test button, event checkboxes - webhook router: list, getById, create, update, delete, test PWA Support (G11): - manifest.json with standalone display, brand-colored icons (192+512px) - Service worker: cache-first for static, network-first for API, offline fallback - ServiceWorkerRegistration component with 60-min update checks - InstallPrompt banner with 30-day dismissal memory - Apple Web App meta tags + viewport theme color Performance Monitoring (A15): - Pino structured logging (JSON prod, pretty dev) via LOG_LEVEL env - tRPC logging middleware on all protectedProcedure calls - Request ID (UUID) per call for log correlation - Slow query warnings (>500ms) at warn level - GET /api/perf endpoint: memory, uptime, SSE connections, node version Fix: renamed scenario.apply to scenario.applyScenario (tRPC reserved word) Co-Authored-By: claude-flow <ruv@ruv.net>
500 lines
19 KiB
TypeScript
500 lines
19 KiB
TypeScript
import { createAiClient, isAiConfigured, parseAiError } from "../ai-client.js";
|
||
import { controllerProcedure, createTRPCRouter } from "../trpc.js";
|
||
import { TRPCError } from "@trpc/server";
|
||
import { z } from "zod";
|
||
|
||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||
|
||
export interface Anomaly {
|
||
type: "budget" | "staffing" | "utilization" | "timeline";
|
||
severity: "warning" | "critical";
|
||
entityId: string;
|
||
entityName: string;
|
||
message: string;
|
||
}
|
||
|
||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Count business days between two dates (Mon–Fri).
|
||
*/
|
||
function countBusinessDays(start: Date, end: Date): number {
|
||
let count = 0;
|
||
const d = new Date(start);
|
||
while (d <= end) {
|
||
const dow = d.getDay();
|
||
if (dow !== 0 && dow !== 6) count++;
|
||
d.setDate(d.getDate() + 1);
|
||
}
|
||
return count;
|
||
}
|
||
|
||
// ─── Router ──────────────────────────────────────────────────────────────────
|
||
|
||
export const insightsRouter = createTRPCRouter({
|
||
/**
|
||
* Generate an AI-powered executive narrative for a project.
|
||
* Caches the result in the project's dynamicFields.aiNarrative to avoid
|
||
* calling the AI on every click.
|
||
*/
|
||
generateProjectNarrative: controllerProcedure
|
||
.input(z.object({ projectId: z.string() }))
|
||
.mutation(async ({ ctx, input }) => {
|
||
const [project, settings] = await Promise.all([
|
||
ctx.db.project.findUnique({
|
||
where: { id: input.projectId },
|
||
include: {
|
||
demandRequirements: {
|
||
select: {
|
||
id: true,
|
||
role: true,
|
||
headcount: true,
|
||
hoursPerDay: true,
|
||
startDate: true,
|
||
endDate: true,
|
||
status: true,
|
||
_count: { select: { assignments: true } },
|
||
},
|
||
},
|
||
assignments: {
|
||
select: {
|
||
id: true,
|
||
role: true,
|
||
hoursPerDay: true,
|
||
startDate: true,
|
||
endDate: true,
|
||
status: true,
|
||
dailyCostCents: true,
|
||
resource: { select: { displayName: true } },
|
||
},
|
||
},
|
||
},
|
||
}),
|
||
ctx.db.systemSettings.findUnique({ where: { id: "singleton" } }),
|
||
]);
|
||
|
||
if (!project) {
|
||
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
|
||
}
|
||
|
||
if (!isAiConfigured(settings)) {
|
||
throw new TRPCError({
|
||
code: "PRECONDITION_FAILED",
|
||
message: "AI is not configured. Please set credentials in Admin \u2192 Settings.",
|
||
});
|
||
}
|
||
|
||
// Build context data for the prompt
|
||
const now = new Date();
|
||
const totalDays = countBusinessDays(project.startDate, project.endDate);
|
||
const elapsedDays = countBusinessDays(project.startDate, now < project.endDate ? now : project.endDate);
|
||
const progressPercent = totalDays > 0 ? Math.round((elapsedDays / totalDays) * 100) : 0;
|
||
|
||
const totalDemandHeadcount = project.demandRequirements.reduce((s, d) => s + d.headcount, 0);
|
||
const filledDemandHeadcount = project.demandRequirements.reduce(
|
||
(s, d) => s + Math.min(d._count.assignments, d.headcount),
|
||
0,
|
||
);
|
||
const staffingPercent = totalDemandHeadcount > 0
|
||
? Math.round((filledDemandHeadcount / totalDemandHeadcount) * 100)
|
||
: 100;
|
||
|
||
// Estimated cost from assignments
|
||
const totalCostCents = project.assignments.reduce((s, a) => {
|
||
const days = countBusinessDays(a.startDate, a.endDate);
|
||
return s + a.dailyCostCents * days;
|
||
}, 0);
|
||
|
||
const budgetCents = project.budgetCents;
|
||
const budgetUsedPercent = budgetCents > 0 ? Math.round((totalCostCents / budgetCents) * 100) : 0;
|
||
|
||
const overrunAssignments = project.assignments.filter(
|
||
(a) => a.endDate > project.endDate,
|
||
);
|
||
|
||
const dataContext = [
|
||
`Project: ${project.name} (${project.shortCode})`,
|
||
`Status: ${project.status}`,
|
||
`Timeline: ${project.startDate.toISOString().slice(0, 10)} to ${project.endDate.toISOString().slice(0, 10)} (${progressPercent}% elapsed)`,
|
||
`Budget: ${(budgetCents / 100).toLocaleString("en-US", { style: "currency", currency: "EUR" })} | Estimated cost: ${(totalCostCents / 100).toLocaleString("en-US", { style: "currency", currency: "EUR" })} (${budgetUsedPercent}% of budget)`,
|
||
`Staffing: ${filledDemandHeadcount}/${totalDemandHeadcount} positions filled (${staffingPercent}%)`,
|
||
`Active assignments: ${project.assignments.filter((a) => a.status === "ACTIVE" || a.status === "CONFIRMED").length}`,
|
||
overrunAssignments.length > 0
|
||
? `Timeline risk: ${overrunAssignments.length} assignment(s) extend beyond project end date`
|
||
: "No timeline overruns detected",
|
||
].join("\n");
|
||
|
||
const prompt = `Generate a concise executive summary for this project covering: budget status, staffing completeness, timeline risk, and key action items. Be specific with numbers. Keep it to 3-5 sentences.
|
||
|
||
${dataContext}`;
|
||
|
||
const client = createAiClient(settings!);
|
||
const model = settings!.azureOpenAiDeployment!;
|
||
const maxTokens = settings!.aiMaxCompletionTokens ?? 300;
|
||
const temperature = settings!.aiTemperature ?? 1;
|
||
|
||
let narrative = "";
|
||
try {
|
||
const completion = await client.chat.completions.create({
|
||
messages: [
|
||
{ role: "system", content: "You are a project management analyst providing brief executive summaries. Be factual and action-oriented." },
|
||
{ role: "user", content: prompt },
|
||
],
|
||
max_completion_tokens: maxTokens,
|
||
model,
|
||
...(temperature !== 1 ? { temperature } : {}),
|
||
});
|
||
narrative = completion.choices[0]?.message?.content?.trim() ?? "";
|
||
} catch (err) {
|
||
throw new TRPCError({
|
||
code: "INTERNAL_SERVER_ERROR",
|
||
message: `AI call failed: ${parseAiError(err)}`,
|
||
});
|
||
}
|
||
|
||
if (!narrative) {
|
||
throw new TRPCError({
|
||
code: "INTERNAL_SERVER_ERROR",
|
||
message: "AI returned an empty response.",
|
||
});
|
||
}
|
||
|
||
const generatedAt = new Date().toISOString();
|
||
|
||
// Cache in project dynamicFields
|
||
const existingDynamic = (project.dynamicFields as Record<string, unknown>) ?? {};
|
||
await ctx.db.project.update({
|
||
where: { id: input.projectId },
|
||
data: {
|
||
dynamicFields: {
|
||
...existingDynamic,
|
||
aiNarrative: narrative,
|
||
aiNarrativeGeneratedAt: generatedAt,
|
||
},
|
||
},
|
||
});
|
||
|
||
return { narrative, generatedAt };
|
||
}),
|
||
|
||
/**
|
||
* Rule-based anomaly detection across all active projects.
|
||
* No AI involved — pure data analysis.
|
||
*/
|
||
detectAnomalies: controllerProcedure.query(async ({ ctx }) => {
|
||
const now = new Date();
|
||
const twoWeeksFromNow = new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000);
|
||
const anomalies: Anomaly[] = [];
|
||
|
||
// Fetch all active projects with their demands and assignments
|
||
const projects = await ctx.db.project.findMany({
|
||
where: { status: { in: ["ACTIVE", "DRAFT"] } },
|
||
include: {
|
||
demandRequirements: {
|
||
select: {
|
||
id: true,
|
||
headcount: true,
|
||
startDate: true,
|
||
endDate: true,
|
||
status: true,
|
||
_count: { select: { assignments: true } },
|
||
},
|
||
},
|
||
assignments: {
|
||
select: {
|
||
id: true,
|
||
resourceId: true,
|
||
startDate: true,
|
||
endDate: true,
|
||
hoursPerDay: true,
|
||
dailyCostCents: true,
|
||
status: true,
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
for (const project of projects) {
|
||
// ── Budget anomaly: spending faster than expected burn rate ──
|
||
if (project.budgetCents > 0) {
|
||
const totalDays = countBusinessDays(project.startDate, project.endDate);
|
||
const elapsedDays = countBusinessDays(project.startDate, now < project.endDate ? now : project.endDate);
|
||
|
||
if (totalDays > 0 && elapsedDays > 0) {
|
||
const expectedBurnRate = elapsedDays / totalDays; // fraction of timeline elapsed
|
||
const totalCostCents = project.assignments.reduce((s, a) => {
|
||
const aStart = a.startDate < project.startDate ? project.startDate : a.startDate;
|
||
const aEnd = a.endDate > now ? now : a.endDate;
|
||
if (aEnd < aStart) return s;
|
||
const days = countBusinessDays(aStart, aEnd);
|
||
return s + a.dailyCostCents * days;
|
||
}, 0);
|
||
const actualBurnRate = totalCostCents / project.budgetCents;
|
||
|
||
if (actualBurnRate > expectedBurnRate * 1.2) {
|
||
const overSpendPercent = Math.round(((actualBurnRate - expectedBurnRate) / expectedBurnRate) * 100);
|
||
anomalies.push({
|
||
type: "budget",
|
||
severity: actualBurnRate > expectedBurnRate * 1.5 ? "critical" : "warning",
|
||
entityId: project.id,
|
||
entityName: project.name,
|
||
message: `Burning budget ${overSpendPercent}% faster than expected. ${Math.round(actualBurnRate * 100)}% spent at ${Math.round(expectedBurnRate * 100)}% timeline.`,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Staffing anomaly: unfilled demands close to start ──
|
||
const upcomingDemands = project.demandRequirements.filter(
|
||
(d) => d.startDate <= twoWeeksFromNow && d.endDate >= now,
|
||
);
|
||
for (const demand of upcomingDemands) {
|
||
const unfilledCount = demand.headcount - demand._count.assignments;
|
||
const unfillPct = demand.headcount > 0 ? unfilledCount / demand.headcount : 0;
|
||
if (unfillPct > 0.3) {
|
||
anomalies.push({
|
||
type: "staffing",
|
||
severity: unfillPct > 0.6 ? "critical" : "warning",
|
||
entityId: project.id,
|
||
entityName: project.name,
|
||
message: `${unfilledCount} of ${demand.headcount} positions unfilled, starting ${demand.startDate.toISOString().slice(0, 10)}.`,
|
||
});
|
||
}
|
||
}
|
||
|
||
// ── Timeline anomaly: assignments extending beyond project end ──
|
||
const overrunAssignments = project.assignments.filter(
|
||
(a) => a.endDate > project.endDate && (a.status === "ACTIVE" || a.status === "CONFIRMED"),
|
||
);
|
||
if (overrunAssignments.length > 0) {
|
||
anomalies.push({
|
||
type: "timeline",
|
||
severity: "warning",
|
||
entityId: project.id,
|
||
entityName: project.name,
|
||
message: `${overrunAssignments.length} assignment(s) extend beyond the project end date (${project.endDate.toISOString().slice(0, 10)}).`,
|
||
});
|
||
}
|
||
}
|
||
|
||
// ── Utilization anomaly: resources at extreme utilization ──
|
||
const resources = await ctx.db.resource.findMany({
|
||
where: { isActive: true },
|
||
select: {
|
||
id: true,
|
||
displayName: true,
|
||
availability: true,
|
||
},
|
||
});
|
||
|
||
// Get all active assignments for current period
|
||
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||
|
||
const activeAssignments = await ctx.db.assignment.findMany({
|
||
where: {
|
||
status: { in: ["ACTIVE", "CONFIRMED"] },
|
||
startDate: { lte: periodEnd },
|
||
endDate: { gte: periodStart },
|
||
},
|
||
select: {
|
||
resourceId: true,
|
||
hoursPerDay: true,
|
||
},
|
||
});
|
||
|
||
// Build resource utilization map
|
||
const resourceHoursMap = new Map<string, number>();
|
||
for (const assignment of activeAssignments) {
|
||
const current = resourceHoursMap.get(assignment.resourceId) ?? 0;
|
||
resourceHoursMap.set(assignment.resourceId, current + assignment.hoursPerDay);
|
||
}
|
||
|
||
for (const resource of resources) {
|
||
const avail = resource.availability as Record<string, number> | null;
|
||
if (!avail) continue;
|
||
const dailyAvailHours = Object.values(avail).reduce((s, h) => s + (h ?? 0), 0) / 5;
|
||
if (dailyAvailHours <= 0) continue;
|
||
|
||
const bookedHours = resourceHoursMap.get(resource.id) ?? 0;
|
||
const utilizationPercent = Math.round((bookedHours / dailyAvailHours) * 100);
|
||
|
||
if (utilizationPercent > 110) {
|
||
anomalies.push({
|
||
type: "utilization",
|
||
severity: utilizationPercent > 130 ? "critical" : "warning",
|
||
entityId: resource.id,
|
||
entityName: resource.displayName,
|
||
message: `Resource at ${utilizationPercent}% utilization (${bookedHours.toFixed(1)}h/${dailyAvailHours.toFixed(1)}h per day).`,
|
||
});
|
||
} else if (utilizationPercent < 40 && utilizationPercent > 0) {
|
||
// Only flag under-utilization if resource has at least some bookings
|
||
// to avoid flagging bench resources
|
||
if (bookedHours > 0) {
|
||
anomalies.push({
|
||
type: "utilization",
|
||
severity: "warning",
|
||
entityId: resource.id,
|
||
entityName: resource.displayName,
|
||
message: `Resource at only ${utilizationPercent}% utilization (${bookedHours.toFixed(1)}h/${dailyAvailHours.toFixed(1)}h per day).`,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// Sort: critical first, then by type
|
||
anomalies.sort((a, b) => {
|
||
if (a.severity !== b.severity) return a.severity === "critical" ? -1 : 1;
|
||
return a.type.localeCompare(b.type);
|
||
});
|
||
|
||
return anomalies;
|
||
}),
|
||
|
||
/**
|
||
* Dashboard-friendly summary: anomaly counts by category + total.
|
||
*/
|
||
getInsightsSummary: controllerProcedure.query(async ({ ctx }) => {
|
||
// Re-use the detectAnomalies logic inline (calling it directly would
|
||
// require the full context to be passed through — simpler to share code
|
||
// via the router caller pattern, but for now we duplicate the call).
|
||
const now = new Date();
|
||
const twoWeeksFromNow = new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000);
|
||
|
||
const projects = await ctx.db.project.findMany({
|
||
where: { status: { in: ["ACTIVE", "DRAFT"] } },
|
||
include: {
|
||
demandRequirements: {
|
||
select: {
|
||
headcount: true,
|
||
startDate: true,
|
||
endDate: true,
|
||
_count: { select: { assignments: true } },
|
||
},
|
||
},
|
||
assignments: {
|
||
select: {
|
||
resourceId: true,
|
||
startDate: true,
|
||
endDate: true,
|
||
hoursPerDay: true,
|
||
dailyCostCents: true,
|
||
status: true,
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
let budgetCount = 0;
|
||
let staffingCount = 0;
|
||
let timelineCount = 0;
|
||
let criticalCount = 0;
|
||
|
||
for (const project of projects) {
|
||
// Budget check
|
||
if (project.budgetCents > 0) {
|
||
const totalDays = countBusinessDays(project.startDate, project.endDate);
|
||
const elapsedDays = countBusinessDays(project.startDate, now < project.endDate ? now : project.endDate);
|
||
if (totalDays > 0 && elapsedDays > 0) {
|
||
const expectedBurnRate = elapsedDays / totalDays;
|
||
const totalCostCents = project.assignments.reduce((s, a) => {
|
||
const aStart = a.startDate < project.startDate ? project.startDate : a.startDate;
|
||
const aEnd = a.endDate > now ? now : a.endDate;
|
||
if (aEnd < aStart) return s;
|
||
return s + a.dailyCostCents * countBusinessDays(aStart, aEnd);
|
||
}, 0);
|
||
const actualBurnRate = totalCostCents / project.budgetCents;
|
||
if (actualBurnRate > expectedBurnRate * 1.2) {
|
||
budgetCount++;
|
||
if (actualBurnRate > expectedBurnRate * 1.5) criticalCount++;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Staffing check
|
||
const upcomingDemands = project.demandRequirements.filter(
|
||
(d) => d.startDate <= twoWeeksFromNow && d.endDate >= now,
|
||
);
|
||
for (const demand of upcomingDemands) {
|
||
const unfillPct = demand.headcount > 0 ? (demand.headcount - demand._count.assignments) / demand.headcount : 0;
|
||
if (unfillPct > 0.3) {
|
||
staffingCount++;
|
||
if (unfillPct > 0.6) criticalCount++;
|
||
}
|
||
}
|
||
|
||
// Timeline check
|
||
const overruns = project.assignments.filter(
|
||
(a) => a.endDate > project.endDate && (a.status === "ACTIVE" || a.status === "CONFIRMED"),
|
||
);
|
||
if (overruns.length > 0) timelineCount++;
|
||
}
|
||
|
||
// Utilization check
|
||
const resources = await ctx.db.resource.findMany({
|
||
where: { isActive: true },
|
||
select: { id: true, availability: true },
|
||
});
|
||
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||
const activeAssignments = await ctx.db.assignment.findMany({
|
||
where: {
|
||
status: { in: ["ACTIVE", "CONFIRMED"] },
|
||
startDate: { lte: periodEnd },
|
||
endDate: { gte: periodStart },
|
||
},
|
||
select: { resourceId: true, hoursPerDay: true },
|
||
});
|
||
const resourceHoursMap = new Map<string, number>();
|
||
for (const a of activeAssignments) {
|
||
resourceHoursMap.set(a.resourceId, (resourceHoursMap.get(a.resourceId) ?? 0) + a.hoursPerDay);
|
||
}
|
||
|
||
let utilizationCount = 0;
|
||
for (const resource of resources) {
|
||
const avail = resource.availability as Record<string, number> | null;
|
||
if (!avail) continue;
|
||
const dailyAvail = Object.values(avail).reduce((s, h) => s + (h ?? 0), 0) / 5;
|
||
if (dailyAvail <= 0) continue;
|
||
const booked = resourceHoursMap.get(resource.id) ?? 0;
|
||
const pct = Math.round((booked / dailyAvail) * 100);
|
||
if (pct > 110) {
|
||
utilizationCount++;
|
||
if (pct > 130) criticalCount++;
|
||
} else if (pct < 40 && booked > 0) {
|
||
utilizationCount++;
|
||
}
|
||
}
|
||
|
||
const total = budgetCount + staffingCount + timelineCount + utilizationCount;
|
||
|
||
return {
|
||
total,
|
||
criticalCount,
|
||
budget: budgetCount,
|
||
staffing: staffingCount,
|
||
timeline: timelineCount,
|
||
utilization: utilizationCount,
|
||
};
|
||
}),
|
||
|
||
/**
|
||
* Retrieve a cached AI narrative for a project (if one was previously generated).
|
||
*/
|
||
getCachedNarrative: controllerProcedure
|
||
.input(z.object({ projectId: z.string() }))
|
||
.query(async ({ ctx, input }) => {
|
||
const project = await ctx.db.project.findUnique({
|
||
where: { id: input.projectId },
|
||
select: { dynamicFields: true },
|
||
});
|
||
if (!project) {
|
||
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
|
||
}
|
||
const df = project.dynamicFields as Record<string, unknown> | null;
|
||
const narrative = (df?.aiNarrative as string) ?? null;
|
||
const generatedAt = (df?.aiNarrativeGeneratedAt as string) ?? null;
|
||
return { narrative, generatedAt };
|
||
}),
|
||
});
|