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>
This commit is contained in:
2026-03-20 06:57:20 +01:00
parent e1368c7ef7
commit fbeab5cd79
30 changed files with 2228 additions and 5 deletions
+25
View File
@@ -0,0 +1,25 @@
import pino from "pino";
const isProduction = process.env["NODE_ENV"] === "production";
const LOG_LEVEL = process.env["LOG_LEVEL"] ?? "info";
export const logger = pino({
level: LOG_LEVEL,
base: { service: "planarchy-api" },
...(isProduction
? {}
: {
transport: {
target: "pino/file",
options: { destination: 1 }, // stdout
},
formatters: {
level(label: string) {
return { level: label };
},
},
}),
});
export type Logger = typeof logger;
+26
View File
@@ -0,0 +1,26 @@
/**
* Slack notification helper.
* Sends a simple text message to a Slack incoming webhook URL.
*/
export async function sendSlackNotification(
webhookUrl: string,
message: string,
_channel?: string,
): Promise<void> {
const body: Record<string, string> = { text: message };
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5_000);
try {
await fetch(webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: controller.signal,
});
} finally {
clearTimeout(timeout);
}
}
+142
View File
@@ -0,0 +1,142 @@
/**
* Outbound webhook dispatcher.
*
* Fetches active webhooks matching a given event, sends POST requests
* with JSON payloads, and optionally signs them with HMAC-SHA256.
*
* Fire-and-forget — errors are logged, never thrown.
*/
import { createHmac } from "node:crypto";
import { sendSlackNotification } from "./slack-notify.js";
/** Available webhook event types. */
export const WEBHOOK_EVENTS = [
"allocation.created",
"allocation.updated",
"allocation.deleted",
"project.created",
"project.status_changed",
"vacation.approved",
"estimate.submitted",
"estimate.approved",
] as const;
export type WebhookEvent = (typeof WEBHOOK_EVENTS)[number];
interface MinimalDb {
webhook: {
findMany: (args: {
where: { isActive: boolean; events: { has: string } };
}) => Promise<
Array<{
id: string;
name: string;
url: string;
secret: string | null;
events: string[];
}>
>;
};
}
/**
* Dispatch an event to all matching active webhooks.
* This is fire-and-forget: errors are logged and swallowed.
*/
export function dispatchWebhooks(
db: MinimalDb,
event: string,
payload: Record<string, unknown>,
): void {
void _dispatch(db, event, payload);
}
async function _dispatch(
db: MinimalDb,
event: string,
payload: Record<string, unknown>,
): Promise<void> {
try {
const webhooks = await db.webhook.findMany({
where: { isActive: true, events: { has: event } },
});
if (webhooks.length === 0) return;
const timestamp = new Date().toISOString();
const body = JSON.stringify({ event, timestamp, payload });
const promises = webhooks.map((wh) =>
_sendToWebhook(wh, event, body, timestamp, payload),
);
await Promise.allSettled(promises);
} catch (err) {
console.error("[webhook-dispatcher] failed to dispatch:", err);
}
}
async function _sendToWebhook(
wh: { id: string; name: string; url: string; secret: string | null },
event: string,
body: string,
timestamp: string,
payload: Record<string, unknown>,
): Promise<void> {
try {
// Slack-specific path: use the Slack notification helper
if (wh.url.includes("hooks.slack.com")) {
const message = formatSlackMessage(event, payload);
await sendSlackNotification(wh.url, message);
return;
}
const headers: Record<string, string> = {
"Content-Type": "application/json",
"X-Webhook-Event": event,
"X-Webhook-Timestamp": timestamp,
};
if (wh.secret) {
const signature = createHmac("sha256", wh.secret)
.update(body)
.digest("hex");
headers["X-Webhook-Signature"] = signature;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5_000);
try {
await fetch(wh.url, {
method: "POST",
headers,
body,
signal: controller.signal,
});
} finally {
clearTimeout(timeout);
}
} catch (err) {
console.error(
`[webhook-dispatcher] error sending to "${wh.name}" (${wh.id}):`,
err,
);
}
}
/**
* Format a human-readable Slack message from a webhook event.
*/
function formatSlackMessage(
event: string,
payload: Record<string, unknown>,
): string {
const label = event.replace(/\./g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
const id = (payload["id"] as string) ?? (payload["projectId"] as string) ?? "";
const name = (payload["name"] as string) ?? "";
const parts = [`*${label}*`];
if (name) parts.push(`\u2022 ${name}`);
if (id) parts.push(`ID: \`${id}\``);
return parts.join("\n");
}