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
+15
View File
@@ -30,6 +30,7 @@ import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { anonymizeResource, getAnonymizationDirectory } from "../lib/anonymization.js";
import { checkBudgetThresholds } from "../lib/budget-alerts.js";
import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
import { emitAllocationCreated, emitAllocationDeleted, emitAllocationUpdated, emitNotificationCreated } from "../sse/event-bus.js";
import { generateAutoSuggestions } from "../lib/auto-staffing.js";
import { invalidateDashboardCache } from "../lib/cache.js";
@@ -245,6 +246,11 @@ export const allocationRouter = createTRPCRouter({
projectId: allocation.projectId,
resourceId: allocation.resourceId,
});
void dispatchWebhooks(ctx.db, "allocation.created", {
id: allocation.id,
projectId: allocation.projectId,
resourceId: allocation.resourceId,
});
void invalidateDashboardCache();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
void checkBudgetThresholds(ctx.db as any, allocation.projectId);
@@ -569,6 +575,11 @@ export const allocationRouter = createTRPCRouter({
projectId: updated.projectId,
resourceId: updated.resourceId,
});
void dispatchWebhooks(ctx.db, "allocation.updated", {
id: updated.id,
projectId: updated.projectId,
resourceId: updated.resourceId,
});
void invalidateDashboardCache();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
void checkBudgetThresholds(ctx.db as any, updated.projectId);
@@ -606,6 +617,10 @@ export const allocationRouter = createTRPCRouter({
});
emitAllocationDeleted(existing.id, existing.projectId);
void dispatchWebhooks(ctx.db, "allocation.deleted", {
id: existing.id,
projectId: existing.projectId,
});
void invalidateDashboardCache();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
void checkBudgetThresholds(ctx.db as any, existing.projectId);
+4
View File
@@ -14,6 +14,7 @@ import { experienceMultiplierRouter } from "./experience-multiplier.js";
import { estimateRouter } from "./estimate.js";
import { entitlementRouter } from "./entitlement.js";
import { importExportRouter } from "./import-export.js";
import { insightsRouter } from "./insights.js";
import { managementLevelRouter } from "./management-level.js";
import { notificationRouter } from "./notification.js";
import { orgUnitRouter } from "./org-unit.js";
@@ -30,6 +31,7 @@ import { timelineRouter } from "./timeline.js";
import { userRouter } from "./user.js";
import { utilizationCategoryRouter } from "./utilization-category.js";
import { vacationRouter } from "./vacation.js";
import { webhookRouter } from "./webhook.js";
export const appRouter = createTRPCRouter({
assistant: assistantRouter,
@@ -46,6 +48,7 @@ export const appRouter = createTRPCRouter({
role: roleRouter,
user: userRouter,
importExport: importExportRouter,
insights: insightsRouter,
vacation: vacationRouter,
entitlement: entitlementRouter,
notification: notificationRouter,
@@ -63,6 +66,7 @@ export const appRouter = createTRPCRouter({
comment: commentRouter,
computationGraph: computationGraphRouter,
systemRoleConfig: systemRoleConfigRouter,
webhook: webhookRouter,
});
export type AppRouter = typeof appRouter;
+499
View File
@@ -0,0 +1,499 @@
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 };
}),
});
+13
View File
@@ -13,6 +13,7 @@ import { loadProjectPlanningReadModel } from "./project-planning-read-model.js";
import { adminProcedure, controllerProcedure, createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
import { createDalleClient, isDalleConfigured, parseAiError } from "../ai-client.js";
import { invalidateDashboardCache } from "../lib/cache.js";
import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
const MAX_COVER_SIZE = 4 * 1024 * 1024; // 4 MB base64 string length limit (client compresses before upload)
@@ -157,6 +158,12 @@ export const projectRouter = createTRPCRouter({
});
void invalidateDashboardCache();
void dispatchWebhooks(ctx.db, "project.created", {
id: project.id,
shortCode: project.shortCode,
name: project.name,
status: project.status,
});
return project;
}),
@@ -222,6 +229,12 @@ export const projectRouter = createTRPCRouter({
data: { status: input.status },
});
void invalidateDashboardCache();
void dispatchWebhooks(ctx.db, "project.status_changed", {
id: result.id,
shortCode: result.shortCode,
name: result.name,
status: result.status,
});
return result;
}),
+1 -1
View File
@@ -478,7 +478,7 @@ export const scenarioRouter = createTRPCRouter({
* Applies a scenario: creates real assignments from scenario changes.
* Manager+ access required.
*/
apply: controllerProcedure
applyScenario: controllerProcedure
.input(SimulateInputSchema)
.mutation(async ({ ctx, input }) => {
const { projectId, changes } = input;
+7
View File
@@ -9,6 +9,7 @@ import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure
import { sendEmail } from "../lib/email.js";
import { anonymizeResource, anonymizeUser, getAnonymizationDirectory } from "../lib/anonymization.js";
import { checkVacationConflicts, checkBatchVacationConflicts } from "../lib/vacation-conflicts.js";
import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
/** Types that consume from annual leave balance */
const BALANCE_TYPES = [VacationType.ANNUAL, VacationType.OTHER];
@@ -293,6 +294,12 @@ export const vacationRouter = createTRPCRouter({
});
emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status });
void dispatchWebhooks(ctx.db, "vacation.approved", {
id: updated.id,
resourceId: updated.resourceId,
startDate: updated.startDate.toISOString(),
endDate: updated.endDate.toISOString(),
});
// Mark approval tasks as DONE
await ctx.db.notification.updateMany({
+152
View File
@@ -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);
}
}),
});