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:
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user