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,152 @@
|
||||
import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { createTRPCRouter, adminProcedure } from "../trpc.js";
|
||||
import { WEBHOOK_EVENTS } from "../lib/webhook-dispatcher.js";
|
||||
|
||||
const webhookEventEnum = z.enum(WEBHOOK_EVENTS as unknown as [string, ...string[]]);
|
||||
|
||||
export const webhookRouter = createTRPCRouter({
|
||||
/** List all webhooks. */
|
||||
list: adminProcedure.query(async ({ ctx }) => {
|
||||
return ctx.db.webhook.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
}),
|
||||
|
||||
/** Get a single webhook by ID. */
|
||||
getById: adminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const wh = await ctx.db.webhook.findUnique({ where: { id: input.id } });
|
||||
if (!wh) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Webhook not found" });
|
||||
}
|
||||
return wh;
|
||||
}),
|
||||
|
||||
/** Create a new webhook. */
|
||||
create: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
url: z.string().url(),
|
||||
secret: z.string().optional(),
|
||||
events: z.array(webhookEventEnum).min(1),
|
||||
isActive: z.boolean().default(true),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return ctx.db.webhook.create({
|
||||
data: {
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
...(input.secret !== undefined ? { secret: input.secret } : {}),
|
||||
events: input.events,
|
||||
isActive: input.isActive,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
/** Update an existing webhook. */
|
||||
update: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
data: z.object({
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
url: z.string().url().optional(),
|
||||
secret: z.string().nullish(),
|
||||
events: z.array(webhookEventEnum).min(1).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.db.webhook.findUnique({ where: { id: input.id } });
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Webhook not found" });
|
||||
}
|
||||
|
||||
return ctx.db.webhook.update({
|
||||
where: { id: input.id },
|
||||
data: {
|
||||
...(input.data.name !== undefined ? { name: input.data.name } : {}),
|
||||
...(input.data.url !== undefined ? { url: input.data.url } : {}),
|
||||
...(input.data.secret !== undefined ? { secret: input.data.secret } : {}),
|
||||
...(input.data.events !== undefined ? { events: input.data.events } : {}),
|
||||
...(input.data.isActive !== undefined ? { isActive: input.data.isActive } : {}),
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
/** Delete a webhook. */
|
||||
delete: adminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.db.webhook.findUnique({ where: { id: input.id } });
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Webhook not found" });
|
||||
}
|
||||
await ctx.db.webhook.delete({ where: { id: input.id } });
|
||||
}),
|
||||
|
||||
/** Send a test payload to a webhook URL. */
|
||||
test: adminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const wh = await ctx.db.webhook.findUnique({ where: { id: input.id } });
|
||||
if (!wh) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Webhook not found" });
|
||||
}
|
||||
|
||||
const testPayload = {
|
||||
event: "webhook.test",
|
||||
timestamp: new Date().toISOString(),
|
||||
payload: {
|
||||
webhookId: wh.id,
|
||||
webhookName: wh.name,
|
||||
message: "This is a test payload from Planarchy.",
|
||||
},
|
||||
};
|
||||
|
||||
const body = JSON.stringify(testPayload);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Webhook-Event": "webhook.test",
|
||||
};
|
||||
|
||||
if (wh.secret) {
|
||||
const { createHmac } = await import("node:crypto");
|
||||
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 {
|
||||
const response = await fetch(wh.url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
return {
|
||||
success: response.ok,
|
||||
statusCode: response.status,
|
||||
statusText: response.statusText,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
statusCode: 0,
|
||||
statusText: err instanceof Error ? err.message : "Unknown error",
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user