From fbeab5cd79c4016962bd230416c5fdfc76456dfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Fri, 20 Mar 2026 06:57:20 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Sprint=205=20=E2=80=94=20AI=20insights,?= =?UTF-8?q?=20webhooks/Slack,=20PWA,=20performance=20monitoring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/web/public/icon-192.png | Bin 0 -> 6996 bytes apps/web/public/icon-512.png | Bin 0 -> 12825 bytes apps/web/public/manifest.json | 14 + apps/web/public/sw.js | 128 +++++ .../web/src/app/(app)/admin/webhooks/page.tsx | 5 + .../src/app/(app)/analytics/insights/page.tsx | 17 + apps/web/src/app/api/perf/route.ts | 67 +++ apps/web/src/app/layout.tsx | 16 +- .../src/components/admin/WebhooksClient.tsx | 416 +++++++++++++++ .../components/analytics/InsightsPanel.tsx | 361 +++++++++++++ apps/web/src/components/layout/AppShell.tsx | 5 + .../src/components/layout/InstallPrompt.tsx | 102 ++++ .../layout/ServiceWorkerRegistration.tsx | 31 ++ .../components/projects/ScenarioPlanner.tsx | 2 +- packages/api/package.json | 4 +- packages/api/src/index.ts | 1 + packages/api/src/lib/logger.ts | 25 + packages/api/src/lib/slack-notify.ts | 26 + packages/api/src/lib/webhook-dispatcher.ts | 142 +++++ packages/api/src/middleware/logging.ts | 64 +++ packages/api/src/router/allocation.ts | 15 + packages/api/src/router/index.ts | 4 + packages/api/src/router/insights.ts | 499 ++++++++++++++++++ packages/api/src/router/project.ts | 13 + packages/api/src/router/scenario.ts | 2 +- packages/api/src/router/vacation.ts | 7 + packages/api/src/router/webhook.ts | 152 ++++++ packages/api/src/trpc.ts | 7 +- packages/db/prisma/schema.prisma | 15 + pnpm-lock.yaml | 93 ++++ 30 files changed, 2228 insertions(+), 5 deletions(-) create mode 100644 apps/web/public/icon-192.png create mode 100644 apps/web/public/icon-512.png create mode 100644 apps/web/public/manifest.json create mode 100644 apps/web/public/sw.js create mode 100644 apps/web/src/app/(app)/admin/webhooks/page.tsx create mode 100644 apps/web/src/app/(app)/analytics/insights/page.tsx create mode 100644 apps/web/src/app/api/perf/route.ts create mode 100644 apps/web/src/components/admin/WebhooksClient.tsx create mode 100644 apps/web/src/components/analytics/InsightsPanel.tsx create mode 100644 apps/web/src/components/layout/InstallPrompt.tsx create mode 100644 apps/web/src/components/layout/ServiceWorkerRegistration.tsx create mode 100644 packages/api/src/lib/logger.ts create mode 100644 packages/api/src/lib/slack-notify.ts create mode 100644 packages/api/src/lib/webhook-dispatcher.ts create mode 100644 packages/api/src/middleware/logging.ts create mode 100644 packages/api/src/router/insights.ts create mode 100644 packages/api/src/router/webhook.ts diff --git a/apps/web/public/icon-192.png b/apps/web/public/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..a3ae1e101b2bae09a779b30dab20562204b5584c GIT binary patch literal 6996 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 z z)`&7(5FVvfK%JvPncGb6q7%tUx6bW)zU|-|$N*L$(Jvf?H1yA=b=WPyy~e>auRtK* zq?(&?7s-yLCvMraM$z4*DsY6>yOb23s&8(ECuiGTrG(kd8jp<${?>UzasX}NgB_G7 zwLm8_pa_+0c0TjtrIz^mmuRkryGa}#PQx?hAdsGW206G)DBoEHqI+8Ih)$eTB6)<$ z#pQ^>Z{FMbn#K0^$BBzw8r7QaS;R=>4Z?uV`?vZpy+u3h> zqc}-NZ5O8Rdya6N;{6mNO7+DK|ZlH2cxU|RJe2;E4;K<+%`%LMuIIh;_-uxeI);b&j(ohf}+{B>-)z6 zW|dg{vRE?)vzaEn1H5CCt(7zYy*tVOLTFEEtg;zbH%b3sP9!)$z0=c$zxstftnb~> zMW}sg5YpBQJ>BZN9eLp7YeF=cU3cI6DbN{O5<4v4J`4qn>-h Fe*rDWQqTYZ literal 0 HcmV?d00001 diff --git a/apps/web/public/icon-512.png b/apps/web/public/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..4d234ff67c3b2c72a114fa7cd730c25677e3f584 GIT binary patch literal 12825 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 literal 0 HcmV?d00001 diff --git a/apps/web/public/manifest.json b/apps/web/public/manifest.json new file mode 100644 index 0000000..858c56e --- /dev/null +++ b/apps/web/public/manifest.json @@ -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" } + ] +} diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js new file mode 100644 index 0000000..e52a98b --- /dev/null +++ b/apps/web/public/sw.js @@ -0,0 +1,128 @@ +/// + +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 = ` + + + + + Planarchy — Offline + + + +
+

You are offline

+

Planarchy requires an internet connection. Please check your network and try again.

+ +
+ +`; + +// 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)) + ); +}); diff --git a/apps/web/src/app/(app)/admin/webhooks/page.tsx b/apps/web/src/app/(app)/admin/webhooks/page.tsx new file mode 100644 index 0000000..48961c1 --- /dev/null +++ b/apps/web/src/app/(app)/admin/webhooks/page.tsx @@ -0,0 +1,5 @@ +import { WebhooksClient } from "~/components/admin/WebhooksClient.js"; + +export default function AdminWebhooksPage() { + return ; +} diff --git a/apps/web/src/app/(app)/analytics/insights/page.tsx b/apps/web/src/app/(app)/analytics/insights/page.tsx new file mode 100644 index 0000000..afa268b --- /dev/null +++ b/apps/web/src/app/(app)/analytics/insights/page.tsx @@ -0,0 +1,17 @@ +import { InsightsPanel } from "~/components/analytics/InsightsPanel.js"; + +export default function InsightsPage() { + return ( +
+
+

+ AI Insights +

+

+ Anomaly detection and AI-generated project narratives +

+
+ +
+ ); +} diff --git a/apps/web/src/app/api/perf/route.ts b/apps/web/src/app/api/perf/route.ts new file mode 100644 index 0000000..2e5771d --- /dev/null +++ b/apps/web/src/app/api/perf/route.ts @@ -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(" "); +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index d14eb92..f1cff42 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -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 { TRPCProvider } from "~/lib/trpc/provider.js"; +import { ServiceWorkerRegistration } from "~/components/layout/ServiceWorkerRegistration.js"; +import { InstallPrompt } from "~/components/layout/InstallPrompt.js"; import "./globals.css"; const uiFont = Source_Sans_3({ @@ -19,6 +21,12 @@ export const metadata: Metadata = { metadataBase: new URL("https://planarchy.hartmut-noerenberg.com"), title: "plANARCHY — Resource Planning", description: "Interactive resource planning and project staffing tool", + manifest: "/manifest.json", + appleWebApp: { + capable: true, + statusBarStyle: "default", + title: "Planarchy", + }, openGraph: { title: "plANARCHY — Resource Planning", 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 }) { return ( @@ -47,6 +59,8 @@ export default function RootLayout({ children }: { children: React.ReactNode }) {children} + + ); diff --git a/apps/web/src/components/admin/WebhooksClient.tsx b/apps/web/src/components/admin/WebhooksClient.tsx new file mode 100644 index 0000000..cb19158 --- /dev/null +++ b/apps/web/src/components/admin/WebhooksClient.tsx @@ -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 = { + "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(null); + const [form, setForm] = useState(emptyForm); + const [deleteConfirmId, setDeleteConfirmId] = useState(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 ( +
+
+
+

Webhooks

+

+ Configure outbound webhooks to notify external services about events in Planarchy. +

+
+ +
+ + {/* Webhook List */} + {isLoading ? ( +
Loading...
+ ) : !webhooks?.length ? ( +
+ No webhooks configured yet. +
+ ) : ( +
+ {webhooks.map((wh) => ( +
+ {/* Active indicator */} +
+ + {/* Info */} +
+
+ + {wh.name} + + {wh.url.includes("hooks.slack.com") && ( + + Slack + + )} +
+
+ {maskUrl(wh.url)} +
+
+ {wh.events.map((ev) => ( + + {EVENT_LABELS[ev] ?? ev} + + ))} +
+ {/* Test result */} + {testResult && testResult.id === wh.id && ( +
+ Test: {testResult.statusCode} {testResult.statusText} +
+ )} +
+ + {/* Actions */} +
+ + + + {deleteConfirmId === wh.id ? ( +
+ + +
+ ) : ( + + )} +
+
+ ))} +
+ )} + + {/* Modal */} + {modalOpen && ( +
+
+

+ {editingId ? "Edit Webhook" : "Create Webhook"} +

+ + {/* Name */} +
+ + setForm((prev) => ({ ...prev, name: e.target.value }))} + placeholder="e.g. Slack Notifications" + /> +
+ + {/* URL */} +
+ + setForm((prev) => ({ ...prev, url: e.target.value }))} + placeholder="https://hooks.slack.com/services/..." + /> +
+ + {/* Secret */} +
+ + setForm((prev) => ({ ...prev, secret: e.target.value }))} + placeholder="HMAC signing secret" + /> +

+ If set, requests include an X-Webhook-Signature header (HMAC-SHA256). +

+
+ + {/* Events */} +
+ +
+ {WEBHOOK_EVENTS.map((ev) => ( + + ))} +
+
+ + {/* Active toggle */} + + + {/* Error display */} + {(createMut.error || updateMut.error) && ( +

+ {createMut.error?.message ?? updateMut.error?.message} +

+ )} + + {/* Actions */} +
+ + +
+
+
+ )} +
+ ); +} diff --git a/apps/web/src/components/analytics/InsightsPanel.tsx b/apps/web/src/components/analytics/InsightsPanel.tsx new file mode 100644 index 0000000..196ad21 --- /dev/null +++ b/apps/web/src/components/analytics/InsightsPanel.tsx @@ -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 = { + budget: "Budget", + staffing: "Staffing", + utilization: "Utilization", + timeline: "Timeline", +}; + +const TYPE_ICONS: Record = { + budget: "\u20AC", // Euro sign + staffing: "\u2642", // Person sign + utilization: "\u2B24", // Circle + timeline: "\u23F0", // Clock +}; + +// ─── Shimmer skeleton ──────────────────────────────────────────────────────── + +function Shimmer({ className = "" }: { className?: string }) { + return ( +
+ ); +} + +function AnomalyListSkeleton() { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ +
+ + +
+
+ ))} +
+ ); +} + +function NarrativeSkeleton() { + return ( +
+ + + + +
+ ); +} + +// ─── 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(""); + const [narrativeFilter, setNarrativeFilter] = useState(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, + ); + + const criticalCount = anomalies.filter((a) => a.severity === "critical").length; + + return ( +
+ {/* ── Summary cards ─────────────────────────────────────────────── */} +
+ {(["budget", "staffing", "utilization", "timeline"] as const).map((type) => { + const count = summaryCountsByType[type] ?? 0; + const isActive = narrativeFilter === type; + return ( + + ); + })} +
+ + {/* ── Anomaly feed ──────────────────────────────────────────────── */} +
+
+

+ Anomaly Feed + {criticalCount > 0 && ( + + {criticalCount} critical + + )} +

+ {narrativeFilter && ( + + )} +
+ + {anomaliesQuery.isLoading ? ( + + ) : anomaliesQuery.error ? ( +
+ Failed to load anomalies: {anomaliesQuery.error.message} +
+ ) : filteredAnomalies.length === 0 ? ( +
+
+ All clear +
+

+ No anomalies detected across active projects. +

+
+ ) : ( +
+ {filteredAnomalies.map((anomaly, idx) => ( +
+ {/* Severity badge */} + + {anomaly.severity === "critical" ? "Critical" : "Warning"} + + + {/* Content */} +
+
+ + {TYPE_ICONS[anomaly.type]} {TYPE_LABELS[anomaly.type]} + +
+

+ {anomaly.message} +

+ + {anomaly.entityName} → + +
+
+ ))} +
+ )} +
+ + {/* ── Project narrative ─────────────────────────────────────────── */} +
+

+ Project Narrative +

+ + {!aiConfigQuery.data?.configured ? ( +
+

+ AI is not configured.{" "} + + Configure AI credentials in Admin Settings + {" "} + to enable project narratives. +

+
+ ) : ( +
+ {/* Project selector */} +
+
+ + +
+ +
+ + {/* Narrative display */} +
+ {generateMutation.isPending ? ( + + ) : generateMutation.error ? ( +
+ {generateMutation.error.message} +
+ ) : generateMutation.data ? ( +
+

+ {generateMutation.data.narrative} +

+

+ Generated {new Date(generateMutation.data.generatedAt).toLocaleString()} +

+
+ ) : cachedNarrativeQuery.data?.narrative ? ( +
+

+ {cachedNarrativeQuery.data.narrative} +

+

+ Previously generated{" "} + {cachedNarrativeQuery.data.generatedAt + ? new Date(cachedNarrativeQuery.data.generatedAt).toLocaleString() + : ""} +

+
+ ) : selectedProjectId ? ( +

+ Click "Generate Summary" to create an AI-powered executive narrative for this project. +

+ ) : ( +

+ Select a project above to generate or view its executive summary. +

+ )} +
+
+ )} +
+
+ ); +} diff --git a/apps/web/src/components/layout/AppShell.tsx b/apps/web/src/components/layout/AppShell.tsx index b61bf39..3a76601 100644 --- a/apps/web/src/components/layout/AppShell.tsx +++ b/apps/web/src/components/layout/AppShell.tsx @@ -67,6 +67,9 @@ function ReportBuilderIcon() { function GraphIcon() { return ; } +function InsightsIcon() { + return ; +} function NotificationsIcon() { return ; } @@ -146,6 +149,7 @@ const navSections: NavSection[] = [ { href: "/reports/chargeability", label: "Chargeability", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, { href: "/reports/builder", label: "Report Builder", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, { href: "/analytics/computation-graph", label: "Computation Graph", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, + { href: "/analytics/insights", label: "AI Insights", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, ], }, { @@ -184,6 +188,7 @@ const adminNavEntries: AdminEntry[] = [ { href: "/admin/settings", label: "Settings", icon: }, { href: "/admin/skill-import", label: "Skill Import", icon: }, { href: "/admin/notifications", label: "Broadcasts", icon: }, + { href: "/admin/webhooks", label: "Webhooks", icon: }, ]; /** diff --git a/apps/web/src/components/layout/InstallPrompt.tsx b/apps/web/src/components/layout/InstallPrompt.tsx new file mode 100644 index 0000000..251124d --- /dev/null +++ b/apps/web/src/components/layout/InstallPrompt.tsx @@ -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; + userChoice: Promise<{ outcome: "accepted" | "dismissed" }>; +} + +export function InstallPrompt() { + const [visible, setVisible] = useState(false); + const deferredPromptRef = useRef(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 ( +
+
+
+ + + +
+
+

+ Install Planarchy +

+

+ Add to home screen for quick access +

+
+
+ + +
+
+
+ ); +} diff --git a/apps/web/src/components/layout/ServiceWorkerRegistration.tsx b/apps/web/src/components/layout/ServiceWorkerRegistration.tsx new file mode 100644 index 0000000..c97744c --- /dev/null +++ b/apps/web/src/components/layout/ServiceWorkerRegistration.tsx @@ -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; +} diff --git a/apps/web/src/components/projects/ScenarioPlanner.tsx b/apps/web/src/components/projects/ScenarioPlanner.tsx index d11ef57..b1a7b99 100644 --- a/apps/web/src/components/projects/ScenarioPlanner.tsx +++ b/apps/web/src/components/projects/ScenarioPlanner.tsx @@ -119,7 +119,7 @@ export function ScenarioPlanner({ projectId, baseline, resources, roles }: Scena // Simulation mutation 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? const isDirty = useMemo(() => { diff --git a/packages/api/package.json b/packages/api/package.json index e724c19..5275225 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -8,7 +8,8 @@ "./router": "./src/router/index.ts", "./trpc": "./src/trpc.ts", "./sse": "./src/sse/event-bus.ts", - "./lib/reminder-scheduler": "./src/lib/reminder-scheduler.ts" + "./lib/reminder-scheduler": "./src/lib/reminder-scheduler.ts", + "./lib/logger": "./src/lib/logger.ts" }, "scripts": { "typecheck": "tsc --noEmit", @@ -26,6 +27,7 @@ "ioredis": "^5.10.0", "nodemailer": "^8.0.1", "openai": "^6.27.0", + "pino": "^10.3.1", "zod": "^3.23.8" }, "devDependencies": { diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index acc7607..9289595 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -1,6 +1,7 @@ export { appRouter, type AppRouter } from "./router/index.js"; export { createTRPCContext, createTRPCRouter, createCallerFactory, publicProcedure, protectedProcedure, managerProcedure, controllerProcedure, adminProcedure, requirePermission, loadRoleDefaults, invalidateRoleDefaultsCache } from "./trpc.js"; export { eventBus, emitAllocationCreated, emitAllocationUpdated, emitAllocationDeleted, emitProjectShifted, emitBudgetWarning, flushPendingEvents, cancelPendingEvents } from "./sse/event-bus.js"; +export { logger } from "./lib/logger.js"; export { anonymizeResource, anonymizeResources, anonymizeUser, getAnonymizationConfig, getAnonymizationDirectory } from "./lib/anonymization.js"; export { checkBudgetThresholds } from "./lib/budget-alerts.js"; export { checkPendingEstimateReminders } from "./lib/estimate-reminders.js"; diff --git a/packages/api/src/lib/logger.ts b/packages/api/src/lib/logger.ts new file mode 100644 index 0000000..f096d24 --- /dev/null +++ b/packages/api/src/lib/logger.ts @@ -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; diff --git a/packages/api/src/lib/slack-notify.ts b/packages/api/src/lib/slack-notify.ts new file mode 100644 index 0000000..908b25a --- /dev/null +++ b/packages/api/src/lib/slack-notify.ts @@ -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 { + const body: Record = { 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); + } +} diff --git a/packages/api/src/lib/webhook-dispatcher.ts b/packages/api/src/lib/webhook-dispatcher.ts new file mode 100644 index 0000000..337f385 --- /dev/null +++ b/packages/api/src/lib/webhook-dispatcher.ts @@ -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, +): void { + void _dispatch(db, event, payload); +} + +async function _dispatch( + db: MinimalDb, + event: string, + payload: Record, +): Promise { + 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, +): Promise { + 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 = { + "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 { + 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"); +} diff --git a/packages/api/src/middleware/logging.ts b/packages/api/src/middleware/logging.ts new file mode 100644 index 0000000..2568c48 --- /dev/null +++ b/packages/api/src/middleware/logging.ts @@ -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 }) => 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; + } +} diff --git a/packages/api/src/router/allocation.ts b/packages/api/src/router/allocation.ts index 81c3d35..02275a6 100644 --- a/packages/api/src/router/allocation.ts +++ b/packages/api/src/router/allocation.ts @@ -30,6 +30,7 @@ import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { anonymizeResource, getAnonymizationDirectory } from "../lib/anonymization.js"; import { checkBudgetThresholds } from "../lib/budget-alerts.js"; +import { dispatchWebhooks } from "../lib/webhook-dispatcher.js"; import { emitAllocationCreated, emitAllocationDeleted, emitAllocationUpdated, emitNotificationCreated } from "../sse/event-bus.js"; import { generateAutoSuggestions } from "../lib/auto-staffing.js"; import { invalidateDashboardCache } from "../lib/cache.js"; @@ -245,6 +246,11 @@ export const allocationRouter = createTRPCRouter({ projectId: allocation.projectId, resourceId: allocation.resourceId, }); + void dispatchWebhooks(ctx.db, "allocation.created", { + id: allocation.id, + projectId: allocation.projectId, + resourceId: allocation.resourceId, + }); void invalidateDashboardCache(); // eslint-disable-next-line @typescript-eslint/no-explicit-any void checkBudgetThresholds(ctx.db as any, allocation.projectId); @@ -569,6 +575,11 @@ export const allocationRouter = createTRPCRouter({ projectId: updated.projectId, resourceId: updated.resourceId, }); + void dispatchWebhooks(ctx.db, "allocation.updated", { + id: updated.id, + projectId: updated.projectId, + resourceId: updated.resourceId, + }); void invalidateDashboardCache(); // eslint-disable-next-line @typescript-eslint/no-explicit-any void checkBudgetThresholds(ctx.db as any, updated.projectId); @@ -606,6 +617,10 @@ export const allocationRouter = createTRPCRouter({ }); emitAllocationDeleted(existing.id, existing.projectId); + void dispatchWebhooks(ctx.db, "allocation.deleted", { + id: existing.id, + projectId: existing.projectId, + }); void invalidateDashboardCache(); // eslint-disable-next-line @typescript-eslint/no-explicit-any void checkBudgetThresholds(ctx.db as any, existing.projectId); diff --git a/packages/api/src/router/index.ts b/packages/api/src/router/index.ts index 20868ae..38f4df3 100644 --- a/packages/api/src/router/index.ts +++ b/packages/api/src/router/index.ts @@ -14,6 +14,7 @@ import { experienceMultiplierRouter } from "./experience-multiplier.js"; import { estimateRouter } from "./estimate.js"; import { entitlementRouter } from "./entitlement.js"; import { importExportRouter } from "./import-export.js"; +import { insightsRouter } from "./insights.js"; import { managementLevelRouter } from "./management-level.js"; import { notificationRouter } from "./notification.js"; import { orgUnitRouter } from "./org-unit.js"; @@ -30,6 +31,7 @@ import { timelineRouter } from "./timeline.js"; import { userRouter } from "./user.js"; import { utilizationCategoryRouter } from "./utilization-category.js"; import { vacationRouter } from "./vacation.js"; +import { webhookRouter } from "./webhook.js"; export const appRouter = createTRPCRouter({ assistant: assistantRouter, @@ -46,6 +48,7 @@ export const appRouter = createTRPCRouter({ role: roleRouter, user: userRouter, importExport: importExportRouter, + insights: insightsRouter, vacation: vacationRouter, entitlement: entitlementRouter, notification: notificationRouter, @@ -63,6 +66,7 @@ export const appRouter = createTRPCRouter({ comment: commentRouter, computationGraph: computationGraphRouter, systemRoleConfig: systemRoleConfigRouter, + webhook: webhookRouter, }); export type AppRouter = typeof appRouter; diff --git a/packages/api/src/router/insights.ts b/packages/api/src/router/insights.ts new file mode 100644 index 0000000..2116fe1 --- /dev/null +++ b/packages/api/src/router/insights.ts @@ -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) ?? {}; + 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(); + 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 | 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(); + 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 | 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 | null; + const narrative = (df?.aiNarrative as string) ?? null; + const generatedAt = (df?.aiNarrativeGeneratedAt as string) ?? null; + return { narrative, generatedAt }; + }), +}); diff --git a/packages/api/src/router/project.ts b/packages/api/src/router/project.ts index 0f8d742..01840c4 100644 --- a/packages/api/src/router/project.ts +++ b/packages/api/src/router/project.ts @@ -13,6 +13,7 @@ import { loadProjectPlanningReadModel } from "./project-planning-read-model.js"; import { adminProcedure, controllerProcedure, createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js"; import { createDalleClient, isDalleConfigured, parseAiError } from "../ai-client.js"; import { invalidateDashboardCache } from "../lib/cache.js"; +import { dispatchWebhooks } from "../lib/webhook-dispatcher.js"; const MAX_COVER_SIZE = 4 * 1024 * 1024; // 4 MB base64 string length limit (client compresses before upload) @@ -157,6 +158,12 @@ export const projectRouter = createTRPCRouter({ }); void invalidateDashboardCache(); + void dispatchWebhooks(ctx.db, "project.created", { + id: project.id, + shortCode: project.shortCode, + name: project.name, + status: project.status, + }); return project; }), @@ -222,6 +229,12 @@ export const projectRouter = createTRPCRouter({ data: { status: input.status }, }); void invalidateDashboardCache(); + void dispatchWebhooks(ctx.db, "project.status_changed", { + id: result.id, + shortCode: result.shortCode, + name: result.name, + status: result.status, + }); return result; }), diff --git a/packages/api/src/router/scenario.ts b/packages/api/src/router/scenario.ts index cb028b4..e208202 100644 --- a/packages/api/src/router/scenario.ts +++ b/packages/api/src/router/scenario.ts @@ -478,7 +478,7 @@ export const scenarioRouter = createTRPCRouter({ * Applies a scenario: creates real assignments from scenario changes. * Manager+ access required. */ - apply: controllerProcedure + applyScenario: controllerProcedure .input(SimulateInputSchema) .mutation(async ({ ctx, input }) => { const { projectId, changes } = input; diff --git a/packages/api/src/router/vacation.ts b/packages/api/src/router/vacation.ts index b62a9b9..a90e4fd 100644 --- a/packages/api/src/router/vacation.ts +++ b/packages/api/src/router/vacation.ts @@ -9,6 +9,7 @@ import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure import { sendEmail } from "../lib/email.js"; import { anonymizeResource, anonymizeUser, getAnonymizationDirectory } from "../lib/anonymization.js"; import { checkVacationConflicts, checkBatchVacationConflicts } from "../lib/vacation-conflicts.js"; +import { dispatchWebhooks } from "../lib/webhook-dispatcher.js"; /** Types that consume from annual leave balance */ const BALANCE_TYPES = [VacationType.ANNUAL, VacationType.OTHER]; @@ -293,6 +294,12 @@ export const vacationRouter = createTRPCRouter({ }); emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status }); + void dispatchWebhooks(ctx.db, "vacation.approved", { + id: updated.id, + resourceId: updated.resourceId, + startDate: updated.startDate.toISOString(), + endDate: updated.endDate.toISOString(), + }); // Mark approval tasks as DONE await ctx.db.notification.updateMany({ diff --git a/packages/api/src/router/webhook.ts b/packages/api/src/router/webhook.ts new file mode 100644 index 0000000..96f3d76 --- /dev/null +++ b/packages/api/src/router/webhook.ts @@ -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 = { + "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); + } + }), +}); diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts index 4d7a9d0..8e643dd 100644 --- a/packages/api/src/trpc.ts +++ b/packages/api/src/trpc.ts @@ -2,6 +2,7 @@ import { prisma } from "@planarchy/db"; import { resolvePermissions, PermissionKey, SystemRole } from "@planarchy/shared"; import { initTRPC, TRPCError } from "@trpc/server"; import { ZodError } from "zod"; +import { loggingMiddleware } from "./middleware/logging.js"; // Minimal Session type to avoid next-auth peer-dep in this package interface Session { @@ -16,6 +17,7 @@ export interface TRPCContext { db: typeof prisma; dbUser: { id: string; systemRole: string; permissionOverrides: unknown } | null; roleDefaults: Record | null; + requestId?: string; } // Cache role defaults for 60 seconds to avoid DB hit on every request @@ -84,11 +86,14 @@ export const createCallerFactory = t.createCallerFactory; */ export const publicProcedure = t.procedure; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const withLogging = t.middleware(loggingMiddleware as any); + /** * Protected procedure — requires authenticated session AND a valid DB user record. * This prevents stale sessions from accessing data after the DB user is deleted. */ -export const protectedProcedure = t.procedure.use(({ ctx, next }) => { +export const protectedProcedure = t.procedure.use(withLogging).use(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Authentication required" }); } diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index dc83eef..6ffa41e 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -1523,3 +1523,18 @@ model AuditLog { @@index([createdAt]) @@map("audit_logs") } + +// ─── Webhook ────────────────────────────────────────────────────────────────── + +model Webhook { + id String @id @default(cuid()) + name String + url String + secret String? // HMAC signing secret + events String[] // ["allocation.created", "project.status_changed", etc.] + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("webhooks") +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69ebed0..f6876ad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -181,6 +181,9 @@ importers: openai: specifier: ^6.27.0 version: 6.27.0(zod@3.25.76) + pino: + specifier: ^10.3.1 + version: 10.3.1 zod: specifier: ^3.23.8 version: 3.25.76 @@ -1070,6 +1073,9 @@ packages: '@panva/hkdf@1.2.1': resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@playwright/test@1.58.2': resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} engines: {node: '>=18'} @@ -1584,6 +1590,10 @@ packages: async@3.2.6: 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: resolution: {integrity: sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==} engines: {node: ^10 || ^12 || >=14} @@ -2760,6 +2770,10 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} 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: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -2841,6 +2855,16 @@ packages: resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} 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: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} @@ -2939,6 +2963,9 @@ packages: process-nextick-args@2.0.1: 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: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -2952,6 +2979,9 @@ packages: queue@6.0.2: 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: resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} peerDependencies: @@ -3023,6 +3053,10 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} 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: resolution: {integrity: sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==} engines: {node: '>=18'} @@ -3115,6 +3149,10 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + saxes@5.0.1: resolution: {integrity: sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==} engines: {node: '>=10'} @@ -3183,10 +3221,17 @@ packages: simple-swizzle@0.2.4: 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: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + ssf@0.11.2: resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==} engines: {node: '>=0.8'} @@ -3278,6 +3323,10 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + thread-stream@4.0.0: + resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==} + engines: {node: '>=20'} + three-forcegraph@1.43.1: resolution: {integrity: sha512-lQnYPLvR31gb91mF5xHhU0jPHJgBPw9QB23R6poCk8Tgvz8sQtq7wTxwClcPdfKCBbHXsb7FSqK06Osiu1kQ5A==} engines: {node: '>=12'} @@ -4066,6 +4115,8 @@ snapshots: '@panva/hkdf@1.2.1': {} + '@pinojs/redact@0.4.0': {} + '@playwright/test@1.58.2': dependencies: playwright: 1.58.2 @@ -4664,6 +4715,8 @@ snapshots: async@3.2.6: {} + atomic-sleep@1.0.0: {} + autoprefixer@10.4.27(postcss@8.5.8): dependencies: browserslist: 4.28.1 @@ -5927,6 +5980,8 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + on-exit-leak-free@2.1.2: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -5988,6 +6043,26 @@ snapshots: 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: {} playwright-core@1.58.2: {} @@ -6066,6 +6141,8 @@ snapshots: process-nextick-args@2.0.1: {} + process-warning@5.0.0: {} + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -6080,6 +6157,8 @@ snapshots: dependencies: inherits: 2.0.4 + quick-format-unescaped@4.0.4: {} + react-dom@19.2.4(react@19.2.4): dependencies: react: 19.2.4 @@ -6163,6 +6242,8 @@ snapshots: dependencies: 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): 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) @@ -6297,6 +6378,8 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 + safe-stable-stringify@2.5.0: {} + saxes@5.0.1: dependencies: xmlchars: 2.2.0 @@ -6405,8 +6488,14 @@ snapshots: dependencies: is-arrayish: 0.3.4 + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + source-map-js@1.2.1: {} + split2@4.2.0: {} + ssf@0.11.2: dependencies: frac: 1.1.2 @@ -6526,6 +6615,10 @@ snapshots: dependencies: 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): dependencies: accessor-fn: 1.5.3