diff --git a/apps/web/public/icon-192.png b/apps/web/public/icon-192.png new file mode 100644 index 0000000..a3ae1e1 Binary files /dev/null and b/apps/web/public/icon-192.png differ diff --git a/apps/web/public/icon-512.png b/apps/web/public/icon-512.png new file mode 100644 index 0000000..4d234ff Binary files /dev/null and b/apps/web/public/icon-512.png differ 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