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:
@@ -8,7 +8,8 @@
|
||||
"./router": "./src/router/index.ts",
|
||||
"./trpc": "./src/trpc.ts",
|
||||
"./sse": "./src/sse/event-bus.ts",
|
||||
"./lib/reminder-scheduler": "./src/lib/reminder-scheduler.ts"
|
||||
"./lib/reminder-scheduler": "./src/lib/reminder-scheduler.ts",
|
||||
"./lib/logger": "./src/lib/logger.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
@@ -26,6 +27,7 @@
|
||||
"ioredis": "^5.10.0",
|
||||
"nodemailer": "^8.0.1",
|
||||
"openai": "^6.27.0",
|
||||
"pino": "^10.3.1",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export { appRouter, type AppRouter } from "./router/index.js";
|
||||
export { createTRPCContext, createTRPCRouter, createCallerFactory, publicProcedure, protectedProcedure, managerProcedure, controllerProcedure, adminProcedure, requirePermission, loadRoleDefaults, invalidateRoleDefaultsCache } from "./trpc.js";
|
||||
export { eventBus, emitAllocationCreated, emitAllocationUpdated, emitAllocationDeleted, emitProjectShifted, emitBudgetWarning, flushPendingEvents, cancelPendingEvents } from "./sse/event-bus.js";
|
||||
export { logger } from "./lib/logger.js";
|
||||
export { anonymizeResource, anonymizeResources, anonymizeUser, getAnonymizationConfig, getAnonymizationDirectory } from "./lib/anonymization.js";
|
||||
export { checkBudgetThresholds } from "./lib/budget-alerts.js";
|
||||
export { checkPendingEstimateReminders } from "./lib/estimate-reminders.js";
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { logger } from "../lib/logger.js";
|
||||
|
||||
const SLOW_THRESHOLD_MS = 500;
|
||||
|
||||
/**
|
||||
* Core logging logic for tRPC procedure calls.
|
||||
*
|
||||
* Designed to be wrapped with `t.middleware()` in trpc.ts.
|
||||
* Generates a requestId (UUID) per call and attaches it to the context.
|
||||
*
|
||||
* Log levels:
|
||||
* - debug: normal requests
|
||||
* - warn: slow requests (>500ms)
|
||||
* - error: failed requests
|
||||
*/
|
||||
export async function loggingMiddleware(opts: {
|
||||
ctx: { dbUser?: { id: string } | null; requestId?: string };
|
||||
type: "query" | "mutation" | "subscription";
|
||||
path: string;
|
||||
next: (opts: { ctx: Record<string, unknown> }) => Promise<{ ok: boolean }>;
|
||||
}) {
|
||||
const { ctx, type, path, next } = opts;
|
||||
const requestId = crypto.randomUUID();
|
||||
const userId = ctx.dbUser?.id ?? "anonymous";
|
||||
const start = performance.now();
|
||||
|
||||
const logBase = {
|
||||
requestId,
|
||||
type,
|
||||
path,
|
||||
userId,
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await next({
|
||||
ctx: { ...ctx, requestId },
|
||||
});
|
||||
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const logData = { ...logBase, durationMs, status: "ok" as const };
|
||||
|
||||
if (durationMs > SLOW_THRESHOLD_MS) {
|
||||
logger.warn(logData, "Slow tRPC call");
|
||||
} else {
|
||||
logger.debug(logData, "tRPC call");
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const errorCode =
|
||||
error instanceof TRPCError ? error.code : "INTERNAL_SERVER_ERROR";
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unknown error";
|
||||
|
||||
logger.error(
|
||||
{ ...logBase, durationMs, status: "error" as const, errorCode, errorMessage },
|
||||
"tRPC call failed",
|
||||
);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (Mon–Fri).
|
||||
*/
|
||||
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,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;
|
||||
}),
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { prisma } from "@planarchy/db";
|
||||
import { resolvePermissions, PermissionKey, SystemRole } from "@planarchy/shared";
|
||||
import { initTRPC, TRPCError } from "@trpc/server";
|
||||
import { ZodError } from "zod";
|
||||
import { loggingMiddleware } from "./middleware/logging.js";
|
||||
|
||||
// Minimal Session type to avoid next-auth peer-dep in this package
|
||||
interface Session {
|
||||
@@ -16,6 +17,7 @@ export interface TRPCContext {
|
||||
db: typeof prisma;
|
||||
dbUser: { id: string; systemRole: string; permissionOverrides: unknown } | null;
|
||||
roleDefaults: Record<string, PermissionKey[]> | null;
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
// Cache role defaults for 60 seconds to avoid DB hit on every request
|
||||
@@ -84,11 +86,14 @@ export const createCallerFactory = t.createCallerFactory;
|
||||
*/
|
||||
export const publicProcedure = t.procedure;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const withLogging = t.middleware(loggingMiddleware as any);
|
||||
|
||||
/**
|
||||
* Protected procedure — requires authenticated session AND a valid DB user record.
|
||||
* This prevents stale sessions from accessing data after the DB user is deleted.
|
||||
*/
|
||||
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
|
||||
export const protectedProcedure = t.procedure.use(withLogging).use(({ ctx, next }) => {
|
||||
if (!ctx.session?.user) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "Authentication required" });
|
||||
}
|
||||
|
||||
@@ -1523,3 +1523,18 @@ model AuditLog {
|
||||
@@index([createdAt])
|
||||
@@map("audit_logs")
|
||||
}
|
||||
|
||||
// ─── Webhook ──────────────────────────────────────────────────────────────────
|
||||
|
||||
model Webhook {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
url String
|
||||
secret String? // HMAC signing secret
|
||||
events String[] // ["allocation.created", "project.status_changed", etc.]
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("webhooks")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user