Files
CapaKraken/packages/api/src/router/insights.ts
T
Hartmut fbeab5cd79 feat: Sprint 5 — AI insights, webhooks/Slack, PWA, performance monitoring
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>
2026-03-20 06:57:20 +01:00

500 lines
19 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, 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 (MonFri).
*/
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 };
}),
});