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:
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "Planarchy — Resource Planning",
|
||||||
|
"short_name": "Planarchy",
|
||||||
|
"description": "Resource planning and project staffing for 3D production",
|
||||||
|
"start_url": "/dashboard",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"theme_color": "#0284c7",
|
||||||
|
"orientation": "any",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
|
||||||
|
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/// <reference lib="webworker" />
|
||||||
|
|
||||||
|
const CACHE_NAME = "planarchy-v1";
|
||||||
|
const STATIC_EXTENSIONS = /\.(js|css|png|jpg|jpeg|svg|gif|ico|woff2?|ttf|eot)$/;
|
||||||
|
|
||||||
|
// Offline fallback page (simple inline HTML)
|
||||||
|
const OFFLINE_HTML = `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Planarchy — Offline</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
min-height: 100vh; background: #f8fafc; color: #334155;
|
||||||
|
}
|
||||||
|
.container { text-align: center; padding: 2rem; }
|
||||||
|
h1 { font-size: 1.5rem; margin-bottom: 0.75rem; color: #0284c7; }
|
||||||
|
p { font-size: 1rem; margin-bottom: 1.5rem; color: #64748b; }
|
||||||
|
button {
|
||||||
|
padding: 0.625rem 1.5rem; border: none; border-radius: 0.5rem;
|
||||||
|
background: #0284c7; color: white; font-size: 0.875rem;
|
||||||
|
cursor: pointer; transition: background 0.2s;
|
||||||
|
}
|
||||||
|
button:hover { background: #0369a1; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>You are offline</h1>
|
||||||
|
<p>Planarchy requires an internet connection. Please check your network and try again.</p>
|
||||||
|
<button onclick="location.reload()">Retry</button>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
// Install: pre-cache the offline fallback
|
||||||
|
self.addEventListener("install", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE_NAME).then((cache) => {
|
||||||
|
return cache.put(
|
||||||
|
new Request("/_offline"),
|
||||||
|
new Response(OFFLINE_HTML, {
|
||||||
|
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// Activate immediately
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Activate: clean up old caches
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((keys) =>
|
||||||
|
Promise.all(
|
||||||
|
keys
|
||||||
|
.filter((key) => key !== CACHE_NAME)
|
||||||
|
.map((key) => caches.delete(key))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
// Take control of all clients immediately
|
||||||
|
self.clients.claim();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch: strategy depends on request type
|
||||||
|
self.addEventListener("fetch", (event) => {
|
||||||
|
const { request } = event;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
// Skip non-GET requests
|
||||||
|
if (request.method !== "GET") return;
|
||||||
|
|
||||||
|
// Skip chrome-extension, ws, etc.
|
||||||
|
if (!url.protocol.startsWith("http")) return;
|
||||||
|
|
||||||
|
// API calls and tRPC: network-first
|
||||||
|
if (url.pathname.startsWith("/api/")) {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(request).catch(() => {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "offline" }),
|
||||||
|
{
|
||||||
|
status: 503,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static assets: cache-first
|
||||||
|
if (STATIC_EXTENSIONS.test(url.pathname) || url.pathname.startsWith("/_next/static/")) {
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(request).then((cached) => {
|
||||||
|
if (cached) return cached;
|
||||||
|
return fetch(request).then((response) => {
|
||||||
|
// Only cache successful responses
|
||||||
|
if (response.ok) {
|
||||||
|
const clone = response.clone();
|
||||||
|
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigation requests: network-first with offline fallback
|
||||||
|
if (request.mode === "navigate") {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(request).catch(() => caches.match("/_offline"))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything else: network-first, silent fail
|
||||||
|
event.respondWith(
|
||||||
|
fetch(request).catch(() => caches.match(request))
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { WebhooksClient } from "~/components/admin/WebhooksClient.js";
|
||||||
|
|
||||||
|
export default function AdminWebhooksPage() {
|
||||||
|
return <WebhooksClient />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { InsightsPanel } from "~/components/analytics/InsightsPanel.js";
|
||||||
|
|
||||||
|
export default function InsightsPage() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-5xl space-y-6 p-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">
|
||||||
|
AI Insights
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Anomaly detection and AI-generated project narratives
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<InsightsPanel />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { eventBus } from "@planarchy/api/sse";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/perf — Runtime performance metrics.
|
||||||
|
*
|
||||||
|
* Protected by CRON_SECRET header or query param.
|
||||||
|
* Returns Node.js memory usage, process uptime, and SSE connection count.
|
||||||
|
*/
|
||||||
|
export function GET(request: Request) {
|
||||||
|
const cronSecret = process.env["CRON_SECRET"];
|
||||||
|
|
||||||
|
if (cronSecret) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const headerToken = request.headers.get("authorization")?.replace("Bearer ", "");
|
||||||
|
const queryToken = url.searchParams.get("token");
|
||||||
|
|
||||||
|
if (headerToken !== cronSecret && queryToken !== cronSecret) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mem = process.memoryUsage();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
uptime: {
|
||||||
|
seconds: Math.round(process.uptime()),
|
||||||
|
formatted: formatUptime(process.uptime()),
|
||||||
|
},
|
||||||
|
memory: {
|
||||||
|
heapUsedMB: round(mem.heapUsed / 1024 / 1024),
|
||||||
|
heapTotalMB: round(mem.heapTotal / 1024 / 1024),
|
||||||
|
rssMB: round(mem.rss / 1024 / 1024),
|
||||||
|
externalMB: round(mem.external / 1024 / 1024),
|
||||||
|
arrayBuffersMB: round(mem.arrayBuffers / 1024 / 1024),
|
||||||
|
},
|
||||||
|
sse: {
|
||||||
|
activeConnections: eventBus.subscriberCount,
|
||||||
|
},
|
||||||
|
node: {
|
||||||
|
version: process.version,
|
||||||
|
platform: process.platform,
|
||||||
|
arch: process.arch,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function round(n: number): number {
|
||||||
|
return Math.round(n * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUptime(seconds: number): string {
|
||||||
|
const d = Math.floor(seconds / 86400);
|
||||||
|
const h = Math.floor((seconds % 86400) / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
const s = Math.floor(seconds % 60);
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (d > 0) parts.push(`${d}d`);
|
||||||
|
if (h > 0) parts.push(`${h}h`);
|
||||||
|
if (m > 0) parts.push(`${m}m`);
|
||||||
|
parts.push(`${s}s`);
|
||||||
|
return parts.join(" ");
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { Manrope, Source_Sans_3 } from "next/font/google";
|
import { Manrope, Source_Sans_3 } from "next/font/google";
|
||||||
import { TRPCProvider } from "~/lib/trpc/provider.js";
|
import { TRPCProvider } from "~/lib/trpc/provider.js";
|
||||||
|
import { ServiceWorkerRegistration } from "~/components/layout/ServiceWorkerRegistration.js";
|
||||||
|
import { InstallPrompt } from "~/components/layout/InstallPrompt.js";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const uiFont = Source_Sans_3({
|
const uiFont = Source_Sans_3({
|
||||||
@@ -19,6 +21,12 @@ export const metadata: Metadata = {
|
|||||||
metadataBase: new URL("https://planarchy.hartmut-noerenberg.com"),
|
metadataBase: new URL("https://planarchy.hartmut-noerenberg.com"),
|
||||||
title: "plANARCHY — Resource Planning",
|
title: "plANARCHY — Resource Planning",
|
||||||
description: "Interactive resource planning and project staffing tool",
|
description: "Interactive resource planning and project staffing tool",
|
||||||
|
manifest: "/manifest.json",
|
||||||
|
appleWebApp: {
|
||||||
|
capable: true,
|
||||||
|
statusBarStyle: "default",
|
||||||
|
title: "Planarchy",
|
||||||
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: "plANARCHY — Resource Planning",
|
title: "plANARCHY — Resource Planning",
|
||||||
description: "Estimates, staffing, chargeability, and timelines in one workspace.",
|
description: "Estimates, staffing, chargeability, and timelines in one workspace.",
|
||||||
@@ -33,6 +41,10 @@ export const metadata: Metadata = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
themeColor: "#0284c7",
|
||||||
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
@@ -47,6 +59,8 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
</head>
|
</head>
|
||||||
<body className={`${uiFont.variable} ${displayFont.variable} min-h-screen bg-gray-50 font-sans antialiased`}>
|
<body className={`${uiFont.variable} ${displayFont.variable} min-h-screen bg-gray-50 font-sans antialiased`}>
|
||||||
<TRPCProvider>{children}</TRPCProvider>
|
<TRPCProvider>{children}</TRPCProvider>
|
||||||
|
<ServiceWorkerRegistration />
|
||||||
|
<InstallPrompt />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,416 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
|
|
||||||
|
const WEBHOOK_EVENTS = [
|
||||||
|
"allocation.created",
|
||||||
|
"allocation.updated",
|
||||||
|
"allocation.deleted",
|
||||||
|
"project.created",
|
||||||
|
"project.status_changed",
|
||||||
|
"vacation.approved",
|
||||||
|
"estimate.submitted",
|
||||||
|
"estimate.approved",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const EVENT_LABELS: Record<string, string> = {
|
||||||
|
"allocation.created": "Allocation Created",
|
||||||
|
"allocation.updated": "Allocation Updated",
|
||||||
|
"allocation.deleted": "Allocation Deleted",
|
||||||
|
"project.created": "Project Created",
|
||||||
|
"project.status_changed": "Project Status Changed",
|
||||||
|
"vacation.approved": "Vacation Approved",
|
||||||
|
"estimate.submitted": "Estimate Submitted",
|
||||||
|
"estimate.approved": "Estimate Approved",
|
||||||
|
};
|
||||||
|
|
||||||
|
const INPUT_CLASS = "app-input";
|
||||||
|
const LABEL_CLASS = "app-label";
|
||||||
|
const PRIMARY_BUTTON =
|
||||||
|
"rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700 disabled:opacity-50";
|
||||||
|
const SECONDARY_BUTTON =
|
||||||
|
"rounded-xl border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800";
|
||||||
|
const DANGER_BUTTON =
|
||||||
|
"rounded-xl bg-red-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-red-700 disabled:opacity-50";
|
||||||
|
|
||||||
|
interface WebhookFormData {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
secret: string;
|
||||||
|
events: string[];
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyForm: WebhookFormData = {
|
||||||
|
name: "",
|
||||||
|
url: "",
|
||||||
|
secret: "",
|
||||||
|
events: [],
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
function maskUrl(url: string): string {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
const host = u.hostname;
|
||||||
|
// Show scheme + host, mask the rest
|
||||||
|
if (u.pathname.length > 1) {
|
||||||
|
return `${u.protocol}//${host}/****`;
|
||||||
|
}
|
||||||
|
return `${u.protocol}//${host}`;
|
||||||
|
} catch {
|
||||||
|
return "****";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WebhooksClient() {
|
||||||
|
const utils = trpc.useUtils();
|
||||||
|
const { data: webhooks, isLoading } = trpc.webhook.list.useQuery();
|
||||||
|
const createMut = trpc.webhook.create.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.webhook.list.invalidate();
|
||||||
|
setModalOpen(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const updateMut = trpc.webhook.update.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.webhook.list.invalidate();
|
||||||
|
setModalOpen(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const deleteMut = trpc.webhook.delete.useMutation({
|
||||||
|
onSuccess: () => void utils.webhook.list.invalidate(),
|
||||||
|
});
|
||||||
|
const testMut = trpc.webhook.test.useMutation();
|
||||||
|
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [form, setForm] = useState<WebhookFormData>(emptyForm);
|
||||||
|
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||||
|
const [testResult, setTestResult] = useState<{
|
||||||
|
id: string;
|
||||||
|
success: boolean;
|
||||||
|
statusCode: number;
|
||||||
|
statusText: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
function openCreateModal() {
|
||||||
|
setEditingId(null);
|
||||||
|
setForm(emptyForm);
|
||||||
|
setModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditModal(wh: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
secret: string | null;
|
||||||
|
events: string[];
|
||||||
|
isActive: boolean;
|
||||||
|
}) {
|
||||||
|
setEditingId(wh.id);
|
||||||
|
setForm({
|
||||||
|
name: wh.name,
|
||||||
|
url: wh.url,
|
||||||
|
secret: wh.secret ?? "",
|
||||||
|
events: wh.events,
|
||||||
|
isActive: wh.isActive,
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleEvent(event: string) {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
events: prev.events.includes(event)
|
||||||
|
? prev.events.filter((e) => e !== event)
|
||||||
|
: [...prev.events, event],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
if (editingId) {
|
||||||
|
updateMut.mutate({
|
||||||
|
id: editingId,
|
||||||
|
data: {
|
||||||
|
name: form.name,
|
||||||
|
url: form.url,
|
||||||
|
...(form.secret ? { secret: form.secret } : { secret: null }),
|
||||||
|
events: form.events,
|
||||||
|
isActive: form.isActive,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
createMut.mutate({
|
||||||
|
name: form.name,
|
||||||
|
url: form.url,
|
||||||
|
...(form.secret ? { secret: form.secret } : {}),
|
||||||
|
events: form.events,
|
||||||
|
isActive: form.isActive,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTest(id: string) {
|
||||||
|
setTestResult(null);
|
||||||
|
testMut.mutate(
|
||||||
|
{ id },
|
||||||
|
{
|
||||||
|
onSuccess: (result) => {
|
||||||
|
setTestResult({ id, ...result });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToggleActive(id: string, currentActive: boolean) {
|
||||||
|
updateMut.mutate({ id, data: { isActive: !currentActive } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSaving = createMut.isPending || updateMut.isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-4xl space-y-6 p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Webhooks</h1>
|
||||||
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Configure outbound webhooks to notify external services about events in Planarchy.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button className={PRIMARY_BUTTON} onClick={openCreateModal}>
|
||||||
|
Add Webhook
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Webhook List */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="app-surface p-8 text-center text-gray-500">Loading...</div>
|
||||||
|
) : !webhooks?.length ? (
|
||||||
|
<div className="app-surface p-8 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
No webhooks configured yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{webhooks.map((wh) => (
|
||||||
|
<div
|
||||||
|
key={wh.id}
|
||||||
|
className="app-surface flex items-center gap-4 p-4"
|
||||||
|
>
|
||||||
|
{/* Active indicator */}
|
||||||
|
<div
|
||||||
|
className={`h-3 w-3 shrink-0 rounded-full ${
|
||||||
|
wh.isActive ? "bg-green-500" : "bg-gray-300 dark:bg-gray-600"
|
||||||
|
}`}
|
||||||
|
title={wh.isActive ? "Active" : "Inactive"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{wh.name}
|
||||||
|
</span>
|
||||||
|
{wh.url.includes("hooks.slack.com") && (
|
||||||
|
<span className="rounded bg-purple-100 px-1.5 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/40 dark:text-purple-300">
|
||||||
|
Slack
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 truncate text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{maskUrl(wh.url)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
{wh.events.map((ev) => (
|
||||||
|
<span
|
||||||
|
key={ev}
|
||||||
|
className="rounded bg-gray-100 px-1.5 py-0.5 text-xs text-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{EVENT_LABELS[ev] ?? ev}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Test result */}
|
||||||
|
{testResult && testResult.id === wh.id && (
|
||||||
|
<div
|
||||||
|
className={`mt-1 text-xs ${
|
||||||
|
testResult.success
|
||||||
|
? "text-green-600 dark:text-green-400"
|
||||||
|
: "text-red-600 dark:text-red-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Test: {testResult.statusCode} {testResult.statusText}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<button
|
||||||
|
className={SECONDARY_BUTTON}
|
||||||
|
onClick={() => handleTest(wh.id)}
|
||||||
|
disabled={testMut.isPending}
|
||||||
|
title="Send test payload"
|
||||||
|
>
|
||||||
|
Test
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={SECONDARY_BUTTON}
|
||||||
|
onClick={() =>
|
||||||
|
handleToggleActive(wh.id, wh.isActive)
|
||||||
|
}
|
||||||
|
disabled={updateMut.isPending}
|
||||||
|
>
|
||||||
|
{wh.isActive ? "Disable" : "Enable"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={SECONDARY_BUTTON}
|
||||||
|
onClick={() => openEditModal(wh)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
{deleteConfirmId === wh.id ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
className={DANGER_BUTTON}
|
||||||
|
onClick={() => {
|
||||||
|
deleteMut.mutate({ id: wh.id });
|
||||||
|
setDeleteConfirmId(null);
|
||||||
|
}}
|
||||||
|
disabled={deleteMut.isPending}
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={SECONDARY_BUTTON}
|
||||||
|
onClick={() => setDeleteConfirmId(null)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className={SECONDARY_BUTTON}
|
||||||
|
onClick={() => setDeleteConfirmId(wh.id)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
{modalOpen && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||||
|
<div className="app-surface-strong mx-4 w-full max-w-lg space-y-5 rounded-2xl p-6 shadow-xl">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{editingId ? "Edit Webhook" : "Create Webhook"}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Name */}
|
||||||
|
<div>
|
||||||
|
<label className={LABEL_CLASS}>Name</label>
|
||||||
|
<input
|
||||||
|
className={INPUT_CLASS}
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||||
|
placeholder="e.g. Slack Notifications"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* URL */}
|
||||||
|
<div>
|
||||||
|
<label className={LABEL_CLASS}>URL</label>
|
||||||
|
<input
|
||||||
|
className={INPUT_CLASS}
|
||||||
|
value={form.url}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, url: e.target.value }))}
|
||||||
|
placeholder="https://hooks.slack.com/services/..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Secret */}
|
||||||
|
<div>
|
||||||
|
<label className={LABEL_CLASS}>
|
||||||
|
Secret (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className={INPUT_CLASS}
|
||||||
|
type="password"
|
||||||
|
value={form.secret}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, secret: e.target.value }))}
|
||||||
|
placeholder="HMAC signing secret"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
If set, requests include an X-Webhook-Signature header (HMAC-SHA256).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Events */}
|
||||||
|
<div>
|
||||||
|
<label className={LABEL_CLASS}>Events</label>
|
||||||
|
<div className="mt-1 grid grid-cols-2 gap-2">
|
||||||
|
{WEBHOOK_EVENTS.map((ev) => (
|
||||||
|
<label
|
||||||
|
key={ev}
|
||||||
|
className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900/70"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.events.includes(ev)}
|
||||||
|
onChange={() => toggleEvent(ev)}
|
||||||
|
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||||
|
/>
|
||||||
|
<span className="text-gray-700 dark:text-gray-300">
|
||||||
|
{EVENT_LABELS[ev] ?? ev}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active toggle */}
|
||||||
|
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.isActive}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, isActive: e.target.checked }))}
|
||||||
|
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||||
|
/>
|
||||||
|
Active
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Error display */}
|
||||||
|
{(createMut.error || updateMut.error) && (
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400">
|
||||||
|
{createMut.error?.message ?? updateMut.error?.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
className={SECONDARY_BUTTON}
|
||||||
|
onClick={() => setModalOpen(false)}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={PRIMARY_BUTTON}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isSaving || !form.name || !form.url || form.events.length === 0}
|
||||||
|
>
|
||||||
|
{isSaving ? "Saving..." : editingId ? "Update" : "Create"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { Route } from "next";
|
||||||
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
|
|
||||||
|
// ─── Anomaly type badge colors ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
const SEVERITY_STYLES = {
|
||||||
|
critical: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300",
|
||||||
|
warning: "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const TYPE_LABELS: Record<string, string> = {
|
||||||
|
budget: "Budget",
|
||||||
|
staffing: "Staffing",
|
||||||
|
utilization: "Utilization",
|
||||||
|
timeline: "Timeline",
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_ICONS: Record<string, string> = {
|
||||||
|
budget: "\u20AC", // Euro sign
|
||||||
|
staffing: "\u2642", // Person sign
|
||||||
|
utilization: "\u2B24", // Circle
|
||||||
|
timeline: "\u23F0", // Clock
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Shimmer skeleton ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function Shimmer({ className = "" }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`animate-pulse rounded bg-gray-200 dark:bg-slate-700 ${className}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AnomalyListSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-3 rounded-xl border border-gray-100 p-4 dark:border-slate-800">
|
||||||
|
<Shimmer className="h-6 w-16 rounded-full" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Shimmer className="h-4 w-3/4" />
|
||||||
|
<Shimmer className="h-3 w-1/2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NarrativeSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Shimmer className="h-4 w-full" />
|
||||||
|
<Shimmer className="h-4 w-5/6" />
|
||||||
|
<Shimmer className="h-4 w-4/6" />
|
||||||
|
<Shimmer className="h-4 w-3/4" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Entity link helper ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function entityLink(type: string, entityId: string): string {
|
||||||
|
if (type === "utilization") return `/resources/${entityId}`;
|
||||||
|
return `/projects/${entityId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main component ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function InsightsPanel() {
|
||||||
|
const [selectedProjectId, setSelectedProjectId] = useState<string>("");
|
||||||
|
const [narrativeFilter, setNarrativeFilter] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Fetch anomalies
|
||||||
|
const anomaliesQuery = trpc.insights.detectAnomalies.useQuery(undefined, {
|
||||||
|
staleTime: 60_000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch AI configuration status
|
||||||
|
const aiConfigQuery = trpc.settings.getAiConfigured.useQuery(undefined, {
|
||||||
|
staleTime: 300_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch project list for dropdown
|
||||||
|
const projectsQuery = trpc.project.list.useQuery(
|
||||||
|
{ page: 1, limit: 200 },
|
||||||
|
{ staleTime: 60_000, refetchOnWindowFocus: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch cached narrative for selected project
|
||||||
|
const cachedNarrativeQuery = trpc.insights.getCachedNarrative.useQuery(
|
||||||
|
{ projectId: selectedProjectId },
|
||||||
|
{
|
||||||
|
enabled: !!selectedProjectId,
|
||||||
|
staleTime: 30_000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate narrative mutation
|
||||||
|
const generateMutation = trpc.insights.generateProjectNarrative.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
// Refetch the cached narrative
|
||||||
|
void cachedNarrativeQuery.refetch();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const anomalies = anomaliesQuery.data ?? [];
|
||||||
|
const projects = projectsQuery.data?.projects ?? [];
|
||||||
|
|
||||||
|
// Filter anomalies
|
||||||
|
const filteredAnomalies = narrativeFilter
|
||||||
|
? anomalies.filter((a) => a.type === narrativeFilter)
|
||||||
|
: anomalies;
|
||||||
|
|
||||||
|
const summaryCountsByType = anomalies.reduce(
|
||||||
|
(acc, a) => {
|
||||||
|
acc[a.type] = (acc[a.type] ?? 0) + 1;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, number>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const criticalCount = anomalies.filter((a) => a.severity === "critical").length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* ── Summary cards ─────────────────────────────────────────────── */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
|
{(["budget", "staffing", "utilization", "timeline"] as const).map((type) => {
|
||||||
|
const count = summaryCountsByType[type] ?? 0;
|
||||||
|
const isActive = narrativeFilter === type;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setNarrativeFilter(isActive ? null : type)}
|
||||||
|
className={`rounded-2xl border p-4 text-left transition-all ${
|
||||||
|
isActive
|
||||||
|
? "border-brand-300 bg-brand-50 shadow-sm dark:border-brand-700 dark:bg-brand-950"
|
||||||
|
: "border-gray-200 bg-white hover:border-gray-300 dark:border-slate-800 dark:bg-slate-900 dark:hover:border-slate-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||||
|
{TYPE_LABELS[type]}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-2xl font-bold text-gray-900 dark:text-gray-50">
|
||||||
|
{anomaliesQuery.isLoading ? "-" : count}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Anomaly feed ──────────────────────────────────────────────── */}
|
||||||
|
<section>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50">
|
||||||
|
Anomaly Feed
|
||||||
|
{criticalCount > 0 && (
|
||||||
|
<span className="ml-2 inline-flex items-center rounded-full bg-red-100 px-2.5 py-0.5 text-xs font-medium text-red-800 dark:bg-red-900/30 dark:text-red-300">
|
||||||
|
{criticalCount} critical
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
{narrativeFilter && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setNarrativeFilter(null)}
|
||||||
|
className="text-sm text-brand-600 hover:text-brand-700 dark:text-brand-400"
|
||||||
|
>
|
||||||
|
Show all
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{anomaliesQuery.isLoading ? (
|
||||||
|
<AnomalyListSkeleton />
|
||||||
|
) : anomaliesQuery.error ? (
|
||||||
|
<div className="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700 dark:border-red-900 dark:bg-red-950/30 dark:text-red-300">
|
||||||
|
Failed to load anomalies: {anomaliesQuery.error.message}
|
||||||
|
</div>
|
||||||
|
) : filteredAnomalies.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-green-200 bg-green-50 p-6 text-center dark:border-green-900 dark:bg-green-950/30">
|
||||||
|
<div className="text-lg font-medium text-green-800 dark:text-green-300">
|
||||||
|
All clear
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-green-600 dark:text-green-400">
|
||||||
|
No anomalies detected across active projects.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{filteredAnomalies.map((anomaly, idx) => (
|
||||||
|
<div
|
||||||
|
key={`${anomaly.entityId}-${anomaly.type}-${idx}`}
|
||||||
|
className="flex items-start gap-3 rounded-xl border border-gray-100 bg-white p-4 transition-colors hover:bg-gray-50 dark:border-slate-800 dark:bg-slate-900 dark:hover:bg-slate-800/80"
|
||||||
|
>
|
||||||
|
{/* Severity badge */}
|
||||||
|
<span
|
||||||
|
className={`inline-flex shrink-0 items-center rounded-full px-2.5 py-1 text-xs font-semibold ${SEVERITY_STYLES[anomaly.severity]}`}
|
||||||
|
>
|
||||||
|
{anomaly.severity === "critical" ? "Critical" : "Warning"}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||||
|
{TYPE_ICONS[anomaly.type]} {TYPE_LABELS[anomaly.type]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{anomaly.message}
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href={entityLink(anomaly.type, anomaly.entityId) as Route}
|
||||||
|
className="mt-1 inline-block text-xs font-medium text-brand-600 hover:text-brand-700 dark:text-brand-400"
|
||||||
|
>
|
||||||
|
{anomaly.entityName} →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Project narrative ─────────────────────────────────────────── */}
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-4 text-lg font-semibold text-gray-900 dark:text-gray-50">
|
||||||
|
Project Narrative
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{!aiConfigQuery.data?.configured ? (
|
||||||
|
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 dark:border-amber-900 dark:bg-amber-950/30">
|
||||||
|
<p className="text-sm text-amber-800 dark:text-amber-300">
|
||||||
|
AI is not configured.{" "}
|
||||||
|
<Link
|
||||||
|
href={"/admin/settings" as Route}
|
||||||
|
className="font-medium underline hover:no-underline"
|
||||||
|
>
|
||||||
|
Configure AI credentials in Admin Settings
|
||||||
|
</Link>{" "}
|
||||||
|
to enable project narratives.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-2xl border border-gray-200 bg-white p-6 dark:border-slate-800 dark:bg-slate-900">
|
||||||
|
{/* Project selector */}
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-end">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label
|
||||||
|
htmlFor="project-select"
|
||||||
|
className="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
Select project
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="project-select"
|
||||||
|
value={selectedProjectId}
|
||||||
|
onChange={(e) => setSelectedProjectId(e.target.value)}
|
||||||
|
className="w-full rounded-xl border border-gray-300 bg-white px-3 py-2.5 text-sm text-gray-900 shadow-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500 dark:border-slate-700 dark:bg-slate-800 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
<option value="">Choose a project...</option>
|
||||||
|
{projects.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.shortCode} - {p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!selectedProjectId || generateMutation.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedProjectId) {
|
||||||
|
generateMutation.mutate({ projectId: selectedProjectId });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-2 rounded-xl bg-brand-600 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-brand-500 dark:hover:bg-brand-600"
|
||||||
|
>
|
||||||
|
{generateMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4 animate-spin"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Generating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Generate Summary"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Narrative display */}
|
||||||
|
<div className="mt-6">
|
||||||
|
{generateMutation.isPending ? (
|
||||||
|
<NarrativeSkeleton />
|
||||||
|
) : generateMutation.error ? (
|
||||||
|
<div className="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700 dark:border-red-900 dark:bg-red-950/30 dark:text-red-300">
|
||||||
|
{generateMutation.error.message}
|
||||||
|
</div>
|
||||||
|
) : generateMutation.data ? (
|
||||||
|
<div className="rounded-xl border border-brand-200 bg-brand-50/50 p-5 dark:border-brand-900/50 dark:bg-brand-950/20">
|
||||||
|
<p className="whitespace-pre-wrap text-sm leading-relaxed text-gray-800 dark:text-gray-200">
|
||||||
|
{generateMutation.data.narrative}
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
Generated {new Date(generateMutation.data.generatedAt).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : cachedNarrativeQuery.data?.narrative ? (
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-gray-50/50 p-5 dark:border-slate-700 dark:bg-slate-800/50">
|
||||||
|
<p className="whitespace-pre-wrap text-sm leading-relaxed text-gray-800 dark:text-gray-200">
|
||||||
|
{cachedNarrativeQuery.data.narrative}
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
Previously generated{" "}
|
||||||
|
{cachedNarrativeQuery.data.generatedAt
|
||||||
|
? new Date(cachedNarrativeQuery.data.generatedAt).toLocaleString()
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : selectedProjectId ? (
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Click "Generate Summary" to create an AI-powered executive narrative for this project.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Select a project above to generate or view its executive summary.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -67,6 +67,9 @@ function ReportBuilderIcon() {
|
|||||||
function GraphIcon() {
|
function GraphIcon() {
|
||||||
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle cx="6" cy="6" r="2.5" strokeWidth={1.8} /><circle cx="18" cy="6" r="2.5" strokeWidth={1.8} /><circle cx="12" cy="18" r="2.5" strokeWidth={1.8} /><path strokeLinecap="round" strokeWidth={1.8} d="M8.5 7.5l2 7M15.5 7.5l-2 7M8.5 6h7" /></svg>;
|
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle cx="6" cy="6" r="2.5" strokeWidth={1.8} /><circle cx="18" cy="6" r="2.5" strokeWidth={1.8} /><circle cx="12" cy="18" r="2.5" strokeWidth={1.8} /><path strokeLinecap="round" strokeWidth={1.8} d="M8.5 7.5l2 7M15.5 7.5l-2 7M8.5 6h7" /></svg>;
|
||||||
}
|
}
|
||||||
|
function InsightsIcon() {
|
||||||
|
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" /></svg>;
|
||||||
|
}
|
||||||
function NotificationsIcon() {
|
function NotificationsIcon() {
|
||||||
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6 6 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /></svg>;
|
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6 6 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /></svg>;
|
||||||
}
|
}
|
||||||
@@ -146,6 +149,7 @@ const navSections: NavSection[] = [
|
|||||||
{ href: "/reports/chargeability", label: "Chargeability", icon: <ChargeabilityIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
|
{ href: "/reports/chargeability", label: "Chargeability", icon: <ChargeabilityIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
|
||||||
{ href: "/reports/builder", label: "Report Builder", icon: <ReportBuilderIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
|
{ href: "/reports/builder", label: "Report Builder", icon: <ReportBuilderIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
|
||||||
{ href: "/analytics/computation-graph", label: "Computation Graph", icon: <GraphIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
|
{ href: "/analytics/computation-graph", label: "Computation Graph", icon: <GraphIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
|
||||||
|
{ href: "/analytics/insights", label: "AI Insights", icon: <InsightsIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -184,6 +188,7 @@ const adminNavEntries: AdminEntry[] = [
|
|||||||
{ href: "/admin/settings", label: "Settings", icon: <AdminIcon /> },
|
{ href: "/admin/settings", label: "Settings", icon: <AdminIcon /> },
|
||||||
{ href: "/admin/skill-import", label: "Skill Import", icon: <AdminIcon /> },
|
{ href: "/admin/skill-import", label: "Skill Import", icon: <AdminIcon /> },
|
||||||
{ href: "/admin/notifications", label: "Broadcasts", icon: <BroadcastIcon /> },
|
{ href: "/admin/notifications", label: "Broadcasts", icon: <BroadcastIcon /> },
|
||||||
|
{ href: "/admin/webhooks", label: "Webhooks", icon: <AdminIcon /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
const DISMISS_KEY = "planarchy_pwa_dismiss";
|
||||||
|
const DISMISS_DURATION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||||
|
|
||||||
|
interface BeforeInstallPromptEvent extends Event {
|
||||||
|
prompt(): Promise<void>;
|
||||||
|
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InstallPrompt() {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const deferredPromptRef = useRef<BeforeInstallPromptEvent | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check if dismissed recently
|
||||||
|
try {
|
||||||
|
const dismissed = localStorage.getItem(DISMISS_KEY);
|
||||||
|
if (dismissed) {
|
||||||
|
const timestamp = parseInt(dismissed, 10);
|
||||||
|
if (Date.now() - timestamp < DISMISS_DURATION_MS) return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// localStorage unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already installed (standalone mode)
|
||||||
|
if (window.matchMedia("(display-mode: standalone)").matches) return;
|
||||||
|
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
deferredPromptRef.current = e as BeforeInstallPromptEvent;
|
||||||
|
setVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("beforeinstallprompt", handler);
|
||||||
|
return () => window.removeEventListener("beforeinstallprompt", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleInstall = useCallback(async () => {
|
||||||
|
const prompt = deferredPromptRef.current;
|
||||||
|
if (!prompt) return;
|
||||||
|
|
||||||
|
await prompt.prompt();
|
||||||
|
const { outcome } = await prompt.userChoice;
|
||||||
|
|
||||||
|
if (outcome === "accepted") {
|
||||||
|
setVisible(false);
|
||||||
|
}
|
||||||
|
deferredPromptRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDismiss = useCallback(() => {
|
||||||
|
setVisible(false);
|
||||||
|
deferredPromptRef.current = null;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(DISMISS_KEY, String(Date.now()));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-20 left-4 right-4 z-50 mx-auto max-w-md animate-in slide-in-from-bottom-4 duration-300 sm:left-auto sm:right-6">
|
||||||
|
<div className="flex items-center gap-3 rounded-2xl border border-brand-200/60 bg-white/95 px-4 py-3 shadow-lg backdrop-blur-xl dark:border-brand-900/40 dark:bg-slate-900/95">
|
||||||
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-brand-600 text-white shadow-md shadow-brand-600/25">
|
||||||
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-gray-50">
|
||||||
|
Install Planarchy
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Add to home screen for quick access
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDismiss}
|
||||||
|
className="rounded-lg px-3 py-1.5 text-xs font-medium text-gray-500 transition-colors hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
Later
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleInstall()}
|
||||||
|
className="rounded-lg bg-brand-600 px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-colors hover:bg-brand-700"
|
||||||
|
>
|
||||||
|
Install
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
export function ServiceWorkerRegistration() {
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
typeof window === "undefined" ||
|
||||||
|
!("serviceWorker" in navigator) ||
|
||||||
|
process.env.NODE_ENV === "development"
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
navigator.serviceWorker
|
||||||
|
.register("/sw.js")
|
||||||
|
.then((registration) => {
|
||||||
|
// Check for updates every 60 minutes
|
||||||
|
setInterval(() => {
|
||||||
|
registration.update().catch(() => {
|
||||||
|
// Silent fail on update check
|
||||||
|
});
|
||||||
|
}, 60 * 60 * 1000);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Service worker registration failed — non-critical
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -119,7 +119,7 @@ export function ScenarioPlanner({ projectId, baseline, resources, roles }: Scena
|
|||||||
|
|
||||||
// Simulation mutation
|
// Simulation mutation
|
||||||
const simulateMut = trpc.scenario.simulate.useMutation();
|
const simulateMut = trpc.scenario.simulate.useMutation();
|
||||||
const applyMut = trpc.scenario.apply.useMutation();
|
const applyMut = trpc.scenario.applyScenario.useMutation();
|
||||||
|
|
||||||
// Derived: has the scenario diverged from baseline?
|
// Derived: has the scenario diverged from baseline?
|
||||||
const isDirty = useMemo(() => {
|
const isDirty = useMemo(() => {
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
"./router": "./src/router/index.ts",
|
"./router": "./src/router/index.ts",
|
||||||
"./trpc": "./src/trpc.ts",
|
"./trpc": "./src/trpc.ts",
|
||||||
"./sse": "./src/sse/event-bus.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": {
|
"scripts": {
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
"ioredis": "^5.10.0",
|
"ioredis": "^5.10.0",
|
||||||
"nodemailer": "^8.0.1",
|
"nodemailer": "^8.0.1",
|
||||||
"openai": "^6.27.0",
|
"openai": "^6.27.0",
|
||||||
|
"pino": "^10.3.1",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export { appRouter, type AppRouter } from "./router/index.js";
|
export { appRouter, type AppRouter } from "./router/index.js";
|
||||||
export { createTRPCContext, createTRPCRouter, createCallerFactory, publicProcedure, protectedProcedure, managerProcedure, controllerProcedure, adminProcedure, requirePermission, loadRoleDefaults, invalidateRoleDefaultsCache } from "./trpc.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 { 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 { anonymizeResource, anonymizeResources, anonymizeUser, getAnonymizationConfig, getAnonymizationDirectory } from "./lib/anonymization.js";
|
||||||
export { checkBudgetThresholds } from "./lib/budget-alerts.js";
|
export { checkBudgetThresholds } from "./lib/budget-alerts.js";
|
||||||
export { checkPendingEstimateReminders } from "./lib/estimate-reminders.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 { findUniqueOrThrow } from "../db/helpers.js";
|
||||||
import { anonymizeResource, getAnonymizationDirectory } from "../lib/anonymization.js";
|
import { anonymizeResource, getAnonymizationDirectory } from "../lib/anonymization.js";
|
||||||
import { checkBudgetThresholds } from "../lib/budget-alerts.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 { emitAllocationCreated, emitAllocationDeleted, emitAllocationUpdated, emitNotificationCreated } from "../sse/event-bus.js";
|
||||||
import { generateAutoSuggestions } from "../lib/auto-staffing.js";
|
import { generateAutoSuggestions } from "../lib/auto-staffing.js";
|
||||||
import { invalidateDashboardCache } from "../lib/cache.js";
|
import { invalidateDashboardCache } from "../lib/cache.js";
|
||||||
@@ -245,6 +246,11 @@ export const allocationRouter = createTRPCRouter({
|
|||||||
projectId: allocation.projectId,
|
projectId: allocation.projectId,
|
||||||
resourceId: allocation.resourceId,
|
resourceId: allocation.resourceId,
|
||||||
});
|
});
|
||||||
|
void dispatchWebhooks(ctx.db, "allocation.created", {
|
||||||
|
id: allocation.id,
|
||||||
|
projectId: allocation.projectId,
|
||||||
|
resourceId: allocation.resourceId,
|
||||||
|
});
|
||||||
void invalidateDashboardCache();
|
void invalidateDashboardCache();
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
void checkBudgetThresholds(ctx.db as any, allocation.projectId);
|
void checkBudgetThresholds(ctx.db as any, allocation.projectId);
|
||||||
@@ -569,6 +575,11 @@ export const allocationRouter = createTRPCRouter({
|
|||||||
projectId: updated.projectId,
|
projectId: updated.projectId,
|
||||||
resourceId: updated.resourceId,
|
resourceId: updated.resourceId,
|
||||||
});
|
});
|
||||||
|
void dispatchWebhooks(ctx.db, "allocation.updated", {
|
||||||
|
id: updated.id,
|
||||||
|
projectId: updated.projectId,
|
||||||
|
resourceId: updated.resourceId,
|
||||||
|
});
|
||||||
void invalidateDashboardCache();
|
void invalidateDashboardCache();
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
void checkBudgetThresholds(ctx.db as any, updated.projectId);
|
void checkBudgetThresholds(ctx.db as any, updated.projectId);
|
||||||
@@ -606,6 +617,10 @@ export const allocationRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
emitAllocationDeleted(existing.id, existing.projectId);
|
emitAllocationDeleted(existing.id, existing.projectId);
|
||||||
|
void dispatchWebhooks(ctx.db, "allocation.deleted", {
|
||||||
|
id: existing.id,
|
||||||
|
projectId: existing.projectId,
|
||||||
|
});
|
||||||
void invalidateDashboardCache();
|
void invalidateDashboardCache();
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
void checkBudgetThresholds(ctx.db as any, existing.projectId);
|
void checkBudgetThresholds(ctx.db as any, existing.projectId);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { experienceMultiplierRouter } from "./experience-multiplier.js";
|
|||||||
import { estimateRouter } from "./estimate.js";
|
import { estimateRouter } from "./estimate.js";
|
||||||
import { entitlementRouter } from "./entitlement.js";
|
import { entitlementRouter } from "./entitlement.js";
|
||||||
import { importExportRouter } from "./import-export.js";
|
import { importExportRouter } from "./import-export.js";
|
||||||
|
import { insightsRouter } from "./insights.js";
|
||||||
import { managementLevelRouter } from "./management-level.js";
|
import { managementLevelRouter } from "./management-level.js";
|
||||||
import { notificationRouter } from "./notification.js";
|
import { notificationRouter } from "./notification.js";
|
||||||
import { orgUnitRouter } from "./org-unit.js";
|
import { orgUnitRouter } from "./org-unit.js";
|
||||||
@@ -30,6 +31,7 @@ import { timelineRouter } from "./timeline.js";
|
|||||||
import { userRouter } from "./user.js";
|
import { userRouter } from "./user.js";
|
||||||
import { utilizationCategoryRouter } from "./utilization-category.js";
|
import { utilizationCategoryRouter } from "./utilization-category.js";
|
||||||
import { vacationRouter } from "./vacation.js";
|
import { vacationRouter } from "./vacation.js";
|
||||||
|
import { webhookRouter } from "./webhook.js";
|
||||||
|
|
||||||
export const appRouter = createTRPCRouter({
|
export const appRouter = createTRPCRouter({
|
||||||
assistant: assistantRouter,
|
assistant: assistantRouter,
|
||||||
@@ -46,6 +48,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
role: roleRouter,
|
role: roleRouter,
|
||||||
user: userRouter,
|
user: userRouter,
|
||||||
importExport: importExportRouter,
|
importExport: importExportRouter,
|
||||||
|
insights: insightsRouter,
|
||||||
vacation: vacationRouter,
|
vacation: vacationRouter,
|
||||||
entitlement: entitlementRouter,
|
entitlement: entitlementRouter,
|
||||||
notification: notificationRouter,
|
notification: notificationRouter,
|
||||||
@@ -63,6 +66,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
comment: commentRouter,
|
comment: commentRouter,
|
||||||
computationGraph: computationGraphRouter,
|
computationGraph: computationGraphRouter,
|
||||||
systemRoleConfig: systemRoleConfigRouter,
|
systemRoleConfig: systemRoleConfigRouter,
|
||||||
|
webhook: webhookRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AppRouter = typeof appRouter;
|
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 { adminProcedure, controllerProcedure, createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
|
||||||
import { createDalleClient, isDalleConfigured, parseAiError } from "../ai-client.js";
|
import { createDalleClient, isDalleConfigured, parseAiError } from "../ai-client.js";
|
||||||
import { invalidateDashboardCache } from "../lib/cache.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)
|
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 invalidateDashboardCache();
|
||||||
|
void dispatchWebhooks(ctx.db, "project.created", {
|
||||||
|
id: project.id,
|
||||||
|
shortCode: project.shortCode,
|
||||||
|
name: project.name,
|
||||||
|
status: project.status,
|
||||||
|
});
|
||||||
return project;
|
return project;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -222,6 +229,12 @@ export const projectRouter = createTRPCRouter({
|
|||||||
data: { status: input.status },
|
data: { status: input.status },
|
||||||
});
|
});
|
||||||
void invalidateDashboardCache();
|
void invalidateDashboardCache();
|
||||||
|
void dispatchWebhooks(ctx.db, "project.status_changed", {
|
||||||
|
id: result.id,
|
||||||
|
shortCode: result.shortCode,
|
||||||
|
name: result.name,
|
||||||
|
status: result.status,
|
||||||
|
});
|
||||||
return result;
|
return result;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
@@ -478,7 +478,7 @@ export const scenarioRouter = createTRPCRouter({
|
|||||||
* Applies a scenario: creates real assignments from scenario changes.
|
* Applies a scenario: creates real assignments from scenario changes.
|
||||||
* Manager+ access required.
|
* Manager+ access required.
|
||||||
*/
|
*/
|
||||||
apply: controllerProcedure
|
applyScenario: controllerProcedure
|
||||||
.input(SimulateInputSchema)
|
.input(SimulateInputSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const { projectId, changes } = input;
|
const { projectId, changes } = input;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure
|
|||||||
import { sendEmail } from "../lib/email.js";
|
import { sendEmail } from "../lib/email.js";
|
||||||
import { anonymizeResource, anonymizeUser, getAnonymizationDirectory } from "../lib/anonymization.js";
|
import { anonymizeResource, anonymizeUser, getAnonymizationDirectory } from "../lib/anonymization.js";
|
||||||
import { checkVacationConflicts, checkBatchVacationConflicts } from "../lib/vacation-conflicts.js";
|
import { checkVacationConflicts, checkBatchVacationConflicts } from "../lib/vacation-conflicts.js";
|
||||||
|
import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
|
||||||
|
|
||||||
/** Types that consume from annual leave balance */
|
/** Types that consume from annual leave balance */
|
||||||
const BALANCE_TYPES = [VacationType.ANNUAL, VacationType.OTHER];
|
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 });
|
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
|
// Mark approval tasks as DONE
|
||||||
await ctx.db.notification.updateMany({
|
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 { resolvePermissions, PermissionKey, SystemRole } from "@planarchy/shared";
|
||||||
import { initTRPC, TRPCError } from "@trpc/server";
|
import { initTRPC, TRPCError } from "@trpc/server";
|
||||||
import { ZodError } from "zod";
|
import { ZodError } from "zod";
|
||||||
|
import { loggingMiddleware } from "./middleware/logging.js";
|
||||||
|
|
||||||
// Minimal Session type to avoid next-auth peer-dep in this package
|
// Minimal Session type to avoid next-auth peer-dep in this package
|
||||||
interface Session {
|
interface Session {
|
||||||
@@ -16,6 +17,7 @@ export interface TRPCContext {
|
|||||||
db: typeof prisma;
|
db: typeof prisma;
|
||||||
dbUser: { id: string; systemRole: string; permissionOverrides: unknown } | null;
|
dbUser: { id: string; systemRole: string; permissionOverrides: unknown } | null;
|
||||||
roleDefaults: Record<string, PermissionKey[]> | null;
|
roleDefaults: Record<string, PermissionKey[]> | null;
|
||||||
|
requestId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache role defaults for 60 seconds to avoid DB hit on every request
|
// 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;
|
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.
|
* Protected procedure — requires authenticated session AND a valid DB user record.
|
||||||
* This prevents stale sessions from accessing data after the DB user is deleted.
|
* 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) {
|
if (!ctx.session?.user) {
|
||||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "Authentication required" });
|
throw new TRPCError({ code: "UNAUTHORIZED", message: "Authentication required" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1523,3 +1523,18 @@ model AuditLog {
|
|||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@map("audit_logs")
|
@@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")
|
||||||
|
}
|
||||||
|
|||||||
Generated
+93
@@ -181,6 +181,9 @@ importers:
|
|||||||
openai:
|
openai:
|
||||||
specifier: ^6.27.0
|
specifier: ^6.27.0
|
||||||
version: 6.27.0(zod@3.25.76)
|
version: 6.27.0(zod@3.25.76)
|
||||||
|
pino:
|
||||||
|
specifier: ^10.3.1
|
||||||
|
version: 10.3.1
|
||||||
zod:
|
zod:
|
||||||
specifier: ^3.23.8
|
specifier: ^3.23.8
|
||||||
version: 3.25.76
|
version: 3.25.76
|
||||||
@@ -1070,6 +1073,9 @@ packages:
|
|||||||
'@panva/hkdf@1.2.1':
|
'@panva/hkdf@1.2.1':
|
||||||
resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==}
|
resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==}
|
||||||
|
|
||||||
|
'@pinojs/redact@0.4.0':
|
||||||
|
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
||||||
|
|
||||||
'@playwright/test@1.58.2':
|
'@playwright/test@1.58.2':
|
||||||
resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==}
|
resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -1584,6 +1590,10 @@ packages:
|
|||||||
async@3.2.6:
|
async@3.2.6:
|
||||||
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
|
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
|
||||||
|
|
||||||
|
atomic-sleep@1.0.0:
|
||||||
|
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
|
||||||
|
engines: {node: '>=8.0.0'}
|
||||||
|
|
||||||
autoprefixer@10.4.27:
|
autoprefixer@10.4.27:
|
||||||
resolution: {integrity: sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==}
|
resolution: {integrity: sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
@@ -2760,6 +2770,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
|
resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
on-exit-leak-free@2.1.2:
|
||||||
|
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
|
||||||
|
engines: {node: '>=14.0.0'}
|
||||||
|
|
||||||
once@1.4.0:
|
once@1.4.0:
|
||||||
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
||||||
|
|
||||||
@@ -2841,6 +2855,16 @@ packages:
|
|||||||
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
|
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
pino-abstract-transport@3.0.0:
|
||||||
|
resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==}
|
||||||
|
|
||||||
|
pino-std-serializers@7.1.0:
|
||||||
|
resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==}
|
||||||
|
|
||||||
|
pino@10.3.1:
|
||||||
|
resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
pirates@4.0.7:
|
pirates@4.0.7:
|
||||||
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
|
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
@@ -2939,6 +2963,9 @@ packages:
|
|||||||
process-nextick-args@2.0.1:
|
process-nextick-args@2.0.1:
|
||||||
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
||||||
|
|
||||||
|
process-warning@5.0.0:
|
||||||
|
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
|
||||||
|
|
||||||
prop-types@15.8.1:
|
prop-types@15.8.1:
|
||||||
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
||||||
|
|
||||||
@@ -2952,6 +2979,9 @@ packages:
|
|||||||
queue@6.0.2:
|
queue@6.0.2:
|
||||||
resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==}
|
resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==}
|
||||||
|
|
||||||
|
quick-format-unescaped@4.0.4:
|
||||||
|
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
|
||||||
|
|
||||||
react-dom@19.2.4:
|
react-dom@19.2.4:
|
||||||
resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==}
|
resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -3023,6 +3053,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
|
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
|
||||||
engines: {node: '>=8.10.0'}
|
engines: {node: '>=8.10.0'}
|
||||||
|
|
||||||
|
real-require@0.2.0:
|
||||||
|
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
|
||||||
|
engines: {node: '>= 12.13.0'}
|
||||||
|
|
||||||
recharts@3.7.0:
|
recharts@3.7.0:
|
||||||
resolution: {integrity: sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==}
|
resolution: {integrity: sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -3115,6 +3149,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
|
resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
safe-stable-stringify@2.5.0:
|
||||||
|
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
saxes@5.0.1:
|
saxes@5.0.1:
|
||||||
resolution: {integrity: sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==}
|
resolution: {integrity: sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -3183,10 +3221,17 @@ packages:
|
|||||||
simple-swizzle@0.2.4:
|
simple-swizzle@0.2.4:
|
||||||
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
|
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
|
||||||
|
|
||||||
|
sonic-boom@4.2.1:
|
||||||
|
resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==}
|
||||||
|
|
||||||
source-map-js@1.2.1:
|
source-map-js@1.2.1:
|
||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
split2@4.2.0:
|
||||||
|
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||||
|
engines: {node: '>= 10.x'}
|
||||||
|
|
||||||
ssf@0.11.2:
|
ssf@0.11.2:
|
||||||
resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
|
resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
|
||||||
engines: {node: '>=0.8'}
|
engines: {node: '>=0.8'}
|
||||||
@@ -3278,6 +3323,10 @@ packages:
|
|||||||
thenify@3.3.1:
|
thenify@3.3.1:
|
||||||
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
|
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
|
||||||
|
|
||||||
|
thread-stream@4.0.0:
|
||||||
|
resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
three-forcegraph@1.43.1:
|
three-forcegraph@1.43.1:
|
||||||
resolution: {integrity: sha512-lQnYPLvR31gb91mF5xHhU0jPHJgBPw9QB23R6poCk8Tgvz8sQtq7wTxwClcPdfKCBbHXsb7FSqK06Osiu1kQ5A==}
|
resolution: {integrity: sha512-lQnYPLvR31gb91mF5xHhU0jPHJgBPw9QB23R6poCk8Tgvz8sQtq7wTxwClcPdfKCBbHXsb7FSqK06Osiu1kQ5A==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -4066,6 +4115,8 @@ snapshots:
|
|||||||
|
|
||||||
'@panva/hkdf@1.2.1': {}
|
'@panva/hkdf@1.2.1': {}
|
||||||
|
|
||||||
|
'@pinojs/redact@0.4.0': {}
|
||||||
|
|
||||||
'@playwright/test@1.58.2':
|
'@playwright/test@1.58.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
playwright: 1.58.2
|
playwright: 1.58.2
|
||||||
@@ -4664,6 +4715,8 @@ snapshots:
|
|||||||
|
|
||||||
async@3.2.6: {}
|
async@3.2.6: {}
|
||||||
|
|
||||||
|
atomic-sleep@1.0.0: {}
|
||||||
|
|
||||||
autoprefixer@10.4.27(postcss@8.5.8):
|
autoprefixer@10.4.27(postcss@8.5.8):
|
||||||
dependencies:
|
dependencies:
|
||||||
browserslist: 4.28.1
|
browserslist: 4.28.1
|
||||||
@@ -5927,6 +5980,8 @@ snapshots:
|
|||||||
define-properties: 1.2.1
|
define-properties: 1.2.1
|
||||||
es-object-atoms: 1.1.1
|
es-object-atoms: 1.1.1
|
||||||
|
|
||||||
|
on-exit-leak-free@2.1.2: {}
|
||||||
|
|
||||||
once@1.4.0:
|
once@1.4.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
wrappy: 1.0.2
|
wrappy: 1.0.2
|
||||||
@@ -5988,6 +6043,26 @@ snapshots:
|
|||||||
|
|
||||||
pify@2.3.0: {}
|
pify@2.3.0: {}
|
||||||
|
|
||||||
|
pino-abstract-transport@3.0.0:
|
||||||
|
dependencies:
|
||||||
|
split2: 4.2.0
|
||||||
|
|
||||||
|
pino-std-serializers@7.1.0: {}
|
||||||
|
|
||||||
|
pino@10.3.1:
|
||||||
|
dependencies:
|
||||||
|
'@pinojs/redact': 0.4.0
|
||||||
|
atomic-sleep: 1.0.0
|
||||||
|
on-exit-leak-free: 2.1.2
|
||||||
|
pino-abstract-transport: 3.0.0
|
||||||
|
pino-std-serializers: 7.1.0
|
||||||
|
process-warning: 5.0.0
|
||||||
|
quick-format-unescaped: 4.0.4
|
||||||
|
real-require: 0.2.0
|
||||||
|
safe-stable-stringify: 2.5.0
|
||||||
|
sonic-boom: 4.2.1
|
||||||
|
thread-stream: 4.0.0
|
||||||
|
|
||||||
pirates@4.0.7: {}
|
pirates@4.0.7: {}
|
||||||
|
|
||||||
playwright-core@1.58.2: {}
|
playwright-core@1.58.2: {}
|
||||||
@@ -6066,6 +6141,8 @@ snapshots:
|
|||||||
|
|
||||||
process-nextick-args@2.0.1: {}
|
process-nextick-args@2.0.1: {}
|
||||||
|
|
||||||
|
process-warning@5.0.0: {}
|
||||||
|
|
||||||
prop-types@15.8.1:
|
prop-types@15.8.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
loose-envify: 1.4.0
|
loose-envify: 1.4.0
|
||||||
@@ -6080,6 +6157,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
inherits: 2.0.4
|
inherits: 2.0.4
|
||||||
|
|
||||||
|
quick-format-unescaped@4.0.4: {}
|
||||||
|
|
||||||
react-dom@19.2.4(react@19.2.4):
|
react-dom@19.2.4(react@19.2.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
@@ -6163,6 +6242,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
picomatch: 2.3.1
|
picomatch: 2.3.1
|
||||||
|
|
||||||
|
real-require@0.2.0: {}
|
||||||
|
|
||||||
recharts@3.7.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@16.13.1)(react@19.2.4)(redux@5.0.1):
|
recharts@3.7.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@16.13.1)(react@19.2.4)(redux@5.0.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4)
|
'@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4)
|
||||||
@@ -6297,6 +6378,8 @@ snapshots:
|
|||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
is-regex: 1.2.1
|
is-regex: 1.2.1
|
||||||
|
|
||||||
|
safe-stable-stringify@2.5.0: {}
|
||||||
|
|
||||||
saxes@5.0.1:
|
saxes@5.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
xmlchars: 2.2.0
|
xmlchars: 2.2.0
|
||||||
@@ -6405,8 +6488,14 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
is-arrayish: 0.3.4
|
is-arrayish: 0.3.4
|
||||||
|
|
||||||
|
sonic-boom@4.2.1:
|
||||||
|
dependencies:
|
||||||
|
atomic-sleep: 1.0.0
|
||||||
|
|
||||||
source-map-js@1.2.1: {}
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
|
split2@4.2.0: {}
|
||||||
|
|
||||||
ssf@0.11.2:
|
ssf@0.11.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
frac: 1.1.2
|
frac: 1.1.2
|
||||||
@@ -6526,6 +6615,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
any-promise: 1.3.0
|
any-promise: 1.3.0
|
||||||
|
|
||||||
|
thread-stream@4.0.0:
|
||||||
|
dependencies:
|
||||||
|
real-require: 0.2.0
|
||||||
|
|
||||||
three-forcegraph@1.43.1(three@0.183.2):
|
three-forcegraph@1.43.1(three@0.183.2):
|
||||||
dependencies:
|
dependencies:
|
||||||
accessor-fn: 1.5.3
|
accessor-fn: 1.5.3
|
||||||
|
|||||||
Reference in New Issue
Block a user