From 956928be4e623c635d762a85cc04cb3b25f2d91c Mon Sep 17 00:00:00 2001 From: sahithjagarlamudi Date: Tue, 19 May 2026 15:12:41 -0700 Subject: [PATCH 1/2] feat(typescript-recipes): add parallel-procurement-n8n recipe Continuous vendor risk monitoring built on the Parallel Task and Monitor APIs, orchestrated by n8n, with Slack-routed alerts and a cited audit log. - One combined n8n workflow (50 nodes) covering vendor sync, daily Deep Research via Task Groups, V1 event-stream monitors, deterministic risk scoring, Slack severity routing, and an /vendor-research slash command. - 46 Vitest suites covering services, models, workflow JSON structure, full pipeline integration, vendor lifecycle, error scenarios, and scale simulation (200 / 3000 vendors). - Optional Next.js + Supabase BYOK dashboard reimplementing the same business logic for users who would rather not run n8n. - Registered in README.md (Scheduled Research & Webhooks) and website/cookbook.json per CONTRIBUTING.md. Co-authored from the n8n-procurement folder of shapleyai/parallel-integrations-and-demos. --- README.md | 1 + .../parallel-procurement-n8n/.env.example | 42 + .../parallel-procurement-n8n/.gitignore | 10 + .../parallel-procurement-n8n/DEPLOY.md | 38 + .../parallel-procurement-n8n/LICENSE | 21 + .../parallel-procurement-n8n/README.md | 99 + .../parallel-procurement-n8n/SETUP.md | 286 ++ .../dashboard/.env.example | 39 + .../dashboard/.gitignore | 8 + .../dashboard/README.md | 138 + .../dashboard/app/api/auth/key/route.ts | 129 + .../dashboard/app/api/auth/logout/route.ts | 15 + .../app/api/cron/research-due/route.ts | 61 + .../dashboard/app/api/cron/sweep/route.ts | 41 + .../api/integrations/[integrationId]/route.ts | 101 + .../[integrationId]/test/route.ts | 107 + .../dashboard/app/api/integrations/route.ts | 84 + .../app/api/monitors/[monitorId]/route.ts | 25 + .../app/api/monitors/deploy/route.ts | 48 + .../app/api/onboarding/complete/route.ts | 64 + .../app/api/onboarding/profile/route.ts | 29 + .../research/groups/[taskGroupId]/route.ts | 53 + .../dashboard/app/api/research/run/route.ts | 44 + .../app/api/vendors/[vendorId]/route.ts | 43 + .../dashboard/app/api/vendors/import/route.ts | 46 + .../dashboard/app/api/vendors/route.ts | 35 + .../api/webhooks/parallel-monitor/route.ts | 177 + .../app/api/webhooks/parallel-task/route.ts | 148 + .../dashboard/app/attention/page.tsx | 24 + .../dashboard/app/feed/page.tsx | 24 + .../dashboard/app/globals.css | 2577 ++++++++++++ .../dashboard/app/layout.tsx | 37 + .../dashboard/app/observe/page.tsx | 28 + .../app/onboarding/profile/ProfileForm.tsx | 75 + .../dashboard/app/onboarding/profile/page.tsx | 26 + .../onboarding/research/ResearchKickoff.tsx | 197 + .../app/onboarding/research/page.tsx | 31 + .../app/onboarding/vendors/VendorsForm.tsx | 309 ++ .../dashboard/app/onboarding/vendors/page.tsx | 31 + .../dashboard/app/operations/page.tsx | 5 + .../dashboard/app/page.tsx | 35 + .../dashboard/app/portfolio/page.tsx | 47 + .../app/settings/keys/IntegrationsManager.tsx | 328 ++ .../dashboard/app/settings/keys/page.tsx | 34 + .../dashboard/app/signin/SignInForm.tsx | 74 + .../dashboard/app/signin/page.tsx | 58 + .../dashboard/app/vendors/[vendorId]/page.tsx | 54 + .../dashboard/components/AccountMenu.tsx | 60 + .../dashboard/components/OnboardingShell.tsx | 38 + .../components/PortfolioTableManager.tsx | 466 +++ .../dashboard/components/VendorActions.tsx | 87 + .../dashboard/components/dashboard-ui.tsx | 725 ++++ .../components/observe/ObserveCanvas.tsx | 93 + .../components/observe/ObserveLeftPanel.tsx | 95 + .../components/observe/ObserveNode.tsx | 56 + .../components/observe/ObserveRightPanel.tsx | 103 + .../components/observe/ObserveTimeline.tsx | 88 + .../components/observe/ObserveWorkspace.tsx | 171 + .../observe/observe-workspace.module.css | 480 +++ .../dashboard/lib/dashboard-data.ts | 23 + .../dashboard/lib/observe-adapters.ts | 288 ++ .../dashboard/lib/observe-mock-data.ts | 621 +++ .../dashboard/lib/observe-types.ts | 145 + .../dashboard/lib/parallel/monitor-client.ts | 135 + .../dashboard/lib/parallel/monitor-queries.ts | 122 + .../dashboard/lib/parallel/research-prompt.ts | 80 + .../dashboard/lib/parallel/risk-scorer.ts | 293 ++ .../dashboard/lib/parallel/severity.ts | 18 + .../dashboard/lib/parallel/task-client.ts | 182 + .../dashboard/lib/parallel/types.ts | 331 ++ .../dashboard/lib/server/account.ts | 66 + .../dashboard/lib/server/cron-auth.ts | 14 + .../dashboard/lib/server/crypto.ts | 115 + .../dashboard/lib/server/dashboard-queries.ts | 493 +++ .../dashboard/lib/server/db.ts | 18 + .../dashboard/lib/server/env.ts | 47 + .../dashboard/lib/server/integrations.ts | 338 ++ .../dashboard/lib/server/monitors.ts | 173 + .../dashboard/lib/server/notifications.ts | 187 + .../dashboard/lib/server/providers.ts | 254 ++ .../dashboard/lib/server/research.ts | 377 ++ .../dashboard/lib/server/session.ts | 62 + .../dashboard/lib/server/vendors.ts | 193 + .../dashboard/lib/server/webhook-token.ts | 75 + .../dashboard/lib/types/dashboard.ts | 139 + .../dashboard/middleware.ts | 64 + .../dashboard/next-env.d.ts | 6 + .../dashboard/next.config.ts | 11 + .../dashboard/package-lock.json | 1358 +++++++ .../dashboard/package.json | 39 + .../dashboard/supabase/README.md | 33 + .../20260519_monitors_status_cancelled.sql | 26 + .../dashboard/supabase/schema.sql | 306 ++ .../dashboard/tsconfig.check.json | 16 + .../dashboard/tsconfig.json | 41 + .../dashboard/vercel.json | 12 + .../docs/WORKFLOW_ORCHESTRATION.md | 530 +++ .../parallel-procurement-n8n/docs/cookbook.md | 152 + .../n8n-workflows/workflow-combined.json | 1570 ++++++++ .../n8n-workflows/workflow1-vendor-sync.json | 334 ++ .../workflow2-deep-research.json | 230 ++ .../n8n-workflows/workflow3-risk-scoring.json | 341 ++ .../n8n-workflows/workflow4-monitors.json | 260 ++ .../n8n-workflows/workflow5-adhoc.json | 233 ++ .../package-lock.json | 1662 ++++++++ .../parallel-procurement-n8n/package.json | 35 + .../parallel_procurement.md | 399 ++ .../sample-setup.excalidraw | 164 + .../parallel-procurement-n8n/sample-setup.md | 395 ++ .../self-healing-monitor-fleet.excalidraw | 1505 +++++++ .../src/config/index.ts | 60 + .../src/models/health-check.ts | 30 + .../src/models/monitor-api.ts | 304 ++ .../src/models/monitor-events.ts | 50 + .../src/models/monitor-query.ts | 66 + .../src/models/research-run.ts | 60 + .../src/models/risk-assessment.ts | 138 + .../src/models/slack-command.ts | 39 + .../src/models/slack.ts | 15 + .../src/models/task-api.ts | 191 + .../src/models/vendor-diff.ts | 40 + .../src/models/vendor.ts | 52 + .../src/services/audit-logger.ts | 40 + .../src/services/batch-planner.ts | 49 + .../src/services/event-dedup-cache.ts | 43 + .../src/services/monitor-event-handler.ts | 265 ++ .../src/services/monitor-health-checker.ts | 309 ++ .../src/services/monitor-portfolio-manager.ts | 173 + .../src/services/monitor-query-generator.ts | 102 + .../src/services/parallel-monitor-client.ts | 212 + .../src/services/parallel-task-client.ts | 273 ++ .../src/services/research-orchestrator.ts | 357 ++ .../src/services/research-prompt-builder.ts | 143 + .../src/services/risk-scorer.ts | 330 ++ .../src/services/slack-command-handler.ts | 188 + .../src/services/slack-delivery.ts | 182 + .../src/services/slack-formatter.ts | 300 ++ .../src/services/slack-ops-reporter.ts | 128 + .../src/services/vendor-ingestion.ts | 270 ++ .../src/utils/csv-parser.ts | 71 + .../src/workflows/generate-all.ts | 33 + .../src/workflows/generator-utils.ts | 541 +++ .../workflows/generators/workflow-combined.ts | 445 +++ .../generators/workflow1-vendor-sync.ts | 73 + .../generators/workflow2-deep-research.ts | 50 + .../generators/workflow3-risk-scoring.ts | 171 + .../generators/workflow4-monitors.ts | 93 + .../workflows/generators/workflow5-adhoc.ts | 134 + .../src/workflows/shared-code-blocks.ts | 273 ++ .../system-architecture.excalidraw | 186 + .../templates/audit-log-tab.csv | 1 + .../templates/monitors-tab.csv | 1 + .../templates/registry-tab.csv | 1 + .../templates/vendors-tab.csv | 16 + .../tests/config/config.test.ts | 116 + .../dashboard/app/api/auth/key-route.test.ts | 178 + .../integrations/integrationId-route.test.ts | 124 + .../app/api/webhooks/parallel-task.test.ts | 262 ++ .../lib/parallel/risk-scorer.test.ts | 91 + .../dashboard/lib/server/cron-auth.test.ts | 30 + .../dashboard/lib/server/monitors.test.ts | 138 + .../lib/server/notifications.test.ts | 215 + .../lib/server/providers.e2e.test.ts | 242 ++ .../dashboard/lib/server/providers.test.ts | 200 + .../lib/server/webhook-token.test.ts | 56 + .../tests/fixtures/deep-research-output.json | 49 + .../fixtures/monitor-webhook-payload.json | 15 + .../tests/fixtures/next-server-stub.ts | 29 + .../tests/fixtures/sample-vendors.csv | 11 + .../tests/fixtures/server-only-stub.ts | 4 + .../tests/fixtures/vendor-registry.json | 56 + .../tests/integration/error-scenarios.test.ts | 223 ++ .../tests/integration/full-pipeline.test.ts | 139 + .../integration/scale-simulation.test.ts | 146 + .../integration/vendor-lifecycle.test.ts | 140 + .../tests/models/monitor-api.test.ts | 394 ++ .../tests/models/monitor-query.test.ts | 139 + .../tests/models/risk-assessment.test.ts | 122 + .../tests/models/task-api.test.ts | 261 ++ .../tests/models/vendor.test.ts | 217 + .../tests/services/audit-logger.test.ts | 108 + .../tests/services/batch-planner.test.ts | 217 + .../tests/services/event-dedup-cache.test.ts | 146 + .../services/monitor-event-handler.test.ts | 420 ++ .../services/monitor-health-checker.test.ts | 453 +++ .../monitor-portfolio-manager.test.ts | 360 ++ .../services/monitor-query-generator.test.ts | 195 + .../services/parallel-monitor-client.test.ts | 339 ++ .../services/parallel-task-client.test.ts | 311 ++ .../services/research-orchestrator.test.ts | 534 +++ .../services/research-prompt-builder.test.ts | 191 + .../tests/services/risk-scorer.test.ts | 612 +++ .../services/slack-command-handler.test.ts | 271 ++ .../tests/services/slack-delivery.test.ts | 261 ++ .../tests/services/slack-formatter.test.ts | 504 +++ .../tests/services/slack-ops-reporter.test.ts | 221 ++ .../tests/services/vendor-ingestion.test.ts | 479 +++ .../tests/utils/csv-parser.test.ts | 49 + .../workflows/shared-code-blocks.test.ts | 78 + .../tests/workflows/workflow-combined.test.ts | 366 ++ .../workflows/workflow-generators.test.ts | 268 ++ .../parallel-procurement-n8n/tsconfig.json | 22 + .../vendor-intelligence-system.excalidraw | 3481 +++++++++++++++++ .../parallel-procurement-n8n/vitest.config.ts | 25 + .../vitest.workspace.ts | 44 + website/cookbook.json | 12 + 206 files changed, 42961 insertions(+) create mode 100644 typescript-recipes/parallel-procurement-n8n/.env.example create mode 100644 typescript-recipes/parallel-procurement-n8n/.gitignore create mode 100644 typescript-recipes/parallel-procurement-n8n/DEPLOY.md create mode 100644 typescript-recipes/parallel-procurement-n8n/LICENSE create mode 100644 typescript-recipes/parallel-procurement-n8n/README.md create mode 100644 typescript-recipes/parallel-procurement-n8n/SETUP.md create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/.env.example create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/.gitignore create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/README.md create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/app/api/auth/key/route.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/app/api/auth/logout/route.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/app/api/cron/research-due/route.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/app/api/cron/sweep/route.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/app/api/integrations/[integrationId]/route.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/app/api/integrations/[integrationId]/test/route.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/app/api/integrations/route.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/app/api/monitors/[monitorId]/route.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/app/api/monitors/deploy/route.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/app/api/onboarding/complete/route.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/app/api/onboarding/profile/route.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/app/api/research/groups/[taskGroupId]/route.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/app/api/research/run/route.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/app/api/vendors/[vendorId]/route.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/app/api/vendors/import/route.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/app/api/vendors/route.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/app/api/webhooks/parallel-monitor/route.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/app/api/webhooks/parallel-task/route.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/app/attention/page.tsx create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/app/feed/page.tsx create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/app/globals.css create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/app/layout.tsx create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/app/observe/page.tsx create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/app/onboarding/profile/ProfileForm.tsx create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/app/onboarding/profile/page.tsx create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/app/onboarding/research/ResearchKickoff.tsx create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/app/onboarding/research/page.tsx create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/app/onboarding/vendors/VendorsForm.tsx create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/app/onboarding/vendors/page.tsx create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/app/operations/page.tsx create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/app/page.tsx create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/app/portfolio/page.tsx create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/app/settings/keys/IntegrationsManager.tsx create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/app/settings/keys/page.tsx create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/app/signin/SignInForm.tsx create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/app/signin/page.tsx create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/app/vendors/[vendorId]/page.tsx create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/components/AccountMenu.tsx create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/components/OnboardingShell.tsx create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/components/PortfolioTableManager.tsx create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/components/VendorActions.tsx create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/components/dashboard-ui.tsx create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/components/observe/ObserveCanvas.tsx create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/components/observe/ObserveLeftPanel.tsx create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/components/observe/ObserveNode.tsx create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/components/observe/ObserveRightPanel.tsx create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/components/observe/ObserveTimeline.tsx create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/components/observe/ObserveWorkspace.tsx create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/components/observe/observe-workspace.module.css create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/lib/dashboard-data.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/lib/observe-adapters.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/lib/observe-mock-data.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/lib/observe-types.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/lib/parallel/monitor-client.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/lib/parallel/monitor-queries.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/lib/parallel/research-prompt.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/lib/parallel/risk-scorer.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/lib/parallel/severity.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/lib/parallel/task-client.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/lib/parallel/types.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/lib/server/account.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/lib/server/cron-auth.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/lib/server/crypto.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/lib/server/dashboard-queries.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/lib/server/db.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/lib/server/env.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/lib/server/integrations.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/lib/server/monitors.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/lib/server/notifications.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/lib/server/providers.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/lib/server/research.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/lib/server/session.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/lib/server/vendors.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/lib/server/webhook-token.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/lib/types/dashboard.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/middleware.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/next-env.d.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/next.config.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/package-lock.json create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/package.json create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/supabase/README.md create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/supabase/migrations/20260519_monitors_status_cancelled.sql create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/supabase/schema.sql create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/tsconfig.check.json create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/tsconfig.json create mode 100644 typescript-recipes/parallel-procurement-n8n/dashboard/vercel.json create mode 100644 typescript-recipes/parallel-procurement-n8n/docs/WORKFLOW_ORCHESTRATION.md create mode 100644 typescript-recipes/parallel-procurement-n8n/docs/cookbook.md create mode 100644 typescript-recipes/parallel-procurement-n8n/n8n-workflows/workflow-combined.json create mode 100644 typescript-recipes/parallel-procurement-n8n/n8n-workflows/workflow1-vendor-sync.json create mode 100644 typescript-recipes/parallel-procurement-n8n/n8n-workflows/workflow2-deep-research.json create mode 100644 typescript-recipes/parallel-procurement-n8n/n8n-workflows/workflow3-risk-scoring.json create mode 100644 typescript-recipes/parallel-procurement-n8n/n8n-workflows/workflow4-monitors.json create mode 100644 typescript-recipes/parallel-procurement-n8n/n8n-workflows/workflow5-adhoc.json create mode 100644 typescript-recipes/parallel-procurement-n8n/package-lock.json create mode 100644 typescript-recipes/parallel-procurement-n8n/package.json create mode 100644 typescript-recipes/parallel-procurement-n8n/parallel_procurement.md create mode 100644 typescript-recipes/parallel-procurement-n8n/sample-setup.excalidraw create mode 100644 typescript-recipes/parallel-procurement-n8n/sample-setup.md create mode 100644 typescript-recipes/parallel-procurement-n8n/self-healing-monitor-fleet.excalidraw create mode 100644 typescript-recipes/parallel-procurement-n8n/src/config/index.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/src/models/health-check.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/src/models/monitor-api.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/src/models/monitor-events.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/src/models/monitor-query.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/src/models/research-run.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/src/models/risk-assessment.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/src/models/slack-command.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/src/models/slack.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/src/models/task-api.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/src/models/vendor-diff.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/src/models/vendor.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/src/services/audit-logger.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/src/services/batch-planner.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/src/services/event-dedup-cache.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/src/services/monitor-event-handler.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/src/services/monitor-health-checker.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/src/services/monitor-portfolio-manager.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/src/services/monitor-query-generator.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/src/services/parallel-monitor-client.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/src/services/parallel-task-client.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/src/services/research-orchestrator.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/src/services/research-prompt-builder.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/src/services/risk-scorer.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/src/services/slack-command-handler.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/src/services/slack-delivery.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/src/services/slack-formatter.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/src/services/slack-ops-reporter.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/src/services/vendor-ingestion.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/src/utils/csv-parser.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/src/workflows/generate-all.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/src/workflows/generator-utils.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/src/workflows/generators/workflow-combined.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/src/workflows/generators/workflow1-vendor-sync.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/src/workflows/generators/workflow2-deep-research.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/src/workflows/generators/workflow3-risk-scoring.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/src/workflows/generators/workflow4-monitors.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/src/workflows/generators/workflow5-adhoc.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/src/workflows/shared-code-blocks.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/system-architecture.excalidraw create mode 100644 typescript-recipes/parallel-procurement-n8n/templates/audit-log-tab.csv create mode 100644 typescript-recipes/parallel-procurement-n8n/templates/monitors-tab.csv create mode 100644 typescript-recipes/parallel-procurement-n8n/templates/registry-tab.csv create mode 100644 typescript-recipes/parallel-procurement-n8n/templates/vendors-tab.csv create mode 100644 typescript-recipes/parallel-procurement-n8n/tests/config/config.test.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/tests/dashboard/app/api/auth/key-route.test.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/tests/dashboard/app/api/integrations/integrationId-route.test.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/tests/dashboard/app/api/webhooks/parallel-task.test.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/tests/dashboard/lib/parallel/risk-scorer.test.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/tests/dashboard/lib/server/cron-auth.test.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/tests/dashboard/lib/server/monitors.test.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/tests/dashboard/lib/server/notifications.test.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/tests/dashboard/lib/server/providers.e2e.test.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/tests/dashboard/lib/server/providers.test.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/tests/dashboard/lib/server/webhook-token.test.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/tests/fixtures/deep-research-output.json create mode 100644 typescript-recipes/parallel-procurement-n8n/tests/fixtures/monitor-webhook-payload.json create mode 100644 typescript-recipes/parallel-procurement-n8n/tests/fixtures/next-server-stub.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/tests/fixtures/sample-vendors.csv create mode 100644 typescript-recipes/parallel-procurement-n8n/tests/fixtures/server-only-stub.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/tests/fixtures/vendor-registry.json create mode 100644 typescript-recipes/parallel-procurement-n8n/tests/integration/error-scenarios.test.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/tests/integration/full-pipeline.test.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/tests/integration/scale-simulation.test.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/tests/integration/vendor-lifecycle.test.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/tests/models/monitor-api.test.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/tests/models/monitor-query.test.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/tests/models/risk-assessment.test.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/tests/models/task-api.test.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/tests/models/vendor.test.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/tests/services/audit-logger.test.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/tests/services/batch-planner.test.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/tests/services/event-dedup-cache.test.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/tests/services/monitor-event-handler.test.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/tests/services/monitor-health-checker.test.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/tests/services/monitor-portfolio-manager.test.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/tests/services/monitor-query-generator.test.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/tests/services/parallel-monitor-client.test.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/tests/services/parallel-task-client.test.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/tests/services/research-orchestrator.test.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/tests/services/research-prompt-builder.test.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/tests/services/risk-scorer.test.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/tests/services/slack-command-handler.test.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/tests/services/slack-delivery.test.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/tests/services/slack-formatter.test.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/tests/services/slack-ops-reporter.test.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/tests/services/vendor-ingestion.test.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/tests/utils/csv-parser.test.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/tests/workflows/shared-code-blocks.test.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/tests/workflows/workflow-combined.test.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/tests/workflows/workflow-generators.test.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/tsconfig.json create mode 100644 typescript-recipes/parallel-procurement-n8n/vendor-intelligence-system.excalidraw create mode 100644 typescript-recipes/parallel-procurement-n8n/vitest.config.ts create mode 100644 typescript-recipes/parallel-procurement-n8n/vitest.workspace.ts diff --git a/README.md b/README.md index 2f25da5..8d2fb49 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,7 @@ Recurring research, cron jobs, and webhook delivery. | Recipe | Description | APIs | Stack | Demo | | --- | --- | --- | --- | --- | | [**Daily Insights**](typescript-recipes/parallel-daily-insights) | Cron-triggered daily research feed — runs Tasks on a schedule, persists to KV, publishes a public data feed. Includes a `SPEC.md` showing the task spec used. | `Task` `Webhooks` `Cron` | Cloudflare Workers · KV | – | +| [**Procurement Vendor Risk (n8n)**](typescript-recipes/parallel-procurement-n8n) | n8n-orchestrated daily Deep Research + V1 event-stream monitors across six risk dimensions per vendor, with deterministic scoring, cited audit log, and severity routing into Slack. Ships an optional Next.js + Supabase BYOK dashboard. | `Task` `Deep Research` `Monitors` `Webhooks` | n8n · TypeScript · Next.js · Supabase | – | ### Deep Research & Notebooks diff --git a/typescript-recipes/parallel-procurement-n8n/.env.example b/typescript-recipes/parallel-procurement-n8n/.env.example new file mode 100644 index 0000000..1fbaadd --- /dev/null +++ b/typescript-recipes/parallel-procurement-n8n/.env.example @@ -0,0 +1,42 @@ +# ── Required ──────────────────────────────────────────────────────────────── + +# Parallel AI API key (https://platform.parallel.ai) +PARALLEL_API_KEY=your-parallel-api-key-here + +# Google Sheets document ID for vendor registry +GOOGLE_SHEET_ID=your-google-sheet-id-here + +# Slack incoming webhook URL for alert delivery +SLACK_WEBHOOK_URL=https://hooks.slack.com/services/your/webhook/url + +# Shared token gating the dashboard-snapshot webhook +# (workflow-combined.json region 7). Without this, anyone who knows the URL +# can dump the entire vendor registry. Set the same value here, as an n8n +# workflow variable, and as a Vercel env var on the dashboard. +PROCUREMENT_SNAPSHOT_TOKEN=replace-with-a-random-32+char-string + +# ── Optional (defaults shown) ────────────────────────────────────────────── + +# Parallel API base URL +# PARALLEL_BASE_URL=https://api.parallel.ai + +# Cron schedules (UTC) +# RESEARCH_CRON=0 6 * * * +# SYNC_CRON=0 0 * * * + +# Processing +# BATCH_SIZE=50 +# RESEARCH_PROCESSOR=ultra8x + +# Monitor cadence by priority +# MONITOR_CADENCE_HIGH=daily +# MONITOR_CADENCE_STD=weekly + +# Monitors per vendor by priority +# MONITORS_PER_VENDOR_HIGH=5 +# MONITORS_PER_VENDOR_STD=2 + +# Slack channel routing (optional — uses webhook default if unset) +# SLACK_CHANNEL_CRITICAL=#procurement-critical +# SLACK_CHANNEL_ALERT=#procurement-alerts +# SLACK_CHANNEL_DIGEST=#procurement-digest diff --git a/typescript-recipes/parallel-procurement-n8n/.gitignore b/typescript-recipes/parallel-procurement-n8n/.gitignore new file mode 100644 index 0000000..c5d9b9c --- /dev/null +++ b/typescript-recipes/parallel-procurement-n8n/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +dist/ +coverage/ +.next/ +.vercel/ +.env +.env.local +*.local +*.tsbuildinfo +.DS_Store diff --git a/typescript-recipes/parallel-procurement-n8n/DEPLOY.md b/typescript-recipes/parallel-procurement-n8n/DEPLOY.md new file mode 100644 index 0000000..6461f30 --- /dev/null +++ b/typescript-recipes/parallel-procurement-n8n/DEPLOY.md @@ -0,0 +1,38 @@ +# Deploying the dashboard + +The recipe ships two deployable pieces: + +1. The **n8n workflow JSON** in [`n8n-workflows/`](n8n-workflows/) — import into n8n Cloud (or a self-hosted instance). The full walkthrough is in [SETUP.md](SETUP.md). +2. The optional **Next.js + Supabase dashboard** in [`dashboard/`](dashboard/) — a multi-tenant BYOK control plane that uses the same Parallel APIs the n8n flow does. + +This document covers the dashboard. For the n8n side, see SETUP.md. + +## One-click Vercel deploy + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fparallel-web%2Fparallel-cookbook%2Ftree%2Fmain%2Ftypescript-recipes%2Fparallel-procurement-n8n%2Fdashboard&project-name=parallel-procurement-dashboard&repository-name=parallel-procurement-dashboard) + +The Vercel project should target the `dashboard/` subdirectory. The full step-by-step (Supabase provisioning, env vars, cron schedule, smoke test) lives in [`dashboard/README.md`](dashboard/README.md#deploying-to-vercel). + +## Required environment variables + +All values live in Vercel **Project Settings → Env Vars** (Production + Preview). No Parallel, Slack, or Resend keys are configured at the platform level — every user brings their own at sign-in. + +| Variable | How to generate | +|---|---| +| `NEXT_PUBLIC_APP_URL` | Public URL of the deployment | +| `SUPABASE_URL` / `SUPABASE_SERVICE_ROLE_KEY` | Supabase **Project Settings → API** | +| `SESSION_SECRET` | `openssl rand -base64 32` | +| `APP_ENCRYPTION_KEY` | `openssl rand -hex 32` (must decode to 32 bytes) | +| `PARALLEL_WEBHOOK_SECRET` | Any 32+ char random string | +| `CRON_SECRET` | Bearer auth required by `/api/cron/*` endpoints | + +See [`dashboard/.env.example`](dashboard/.env.example) for the full list and inline notes. + +## What the cron jobs do + +`dashboard/vercel.json` schedules: + +- `/api/cron/sweep` (daily 05:00 UTC) — reconciles in-flight task groups whose webhooks were dropped. +- `/api/cron/research-due` (daily 06:00 UTC) — runs deep research for any vendor whose `next_research_date` has elapsed, using each account's BYOK Parallel key. + +Vercel Hobby is limited to one cron per day per project; upgrade to Pro if you want the hourly sweep cadence noted in the dashboard README. diff --git a/typescript-recipes/parallel-procurement-n8n/LICENSE b/typescript-recipes/parallel-procurement-n8n/LICENSE new file mode 100644 index 0000000..98c9650 --- /dev/null +++ b/typescript-recipes/parallel-procurement-n8n/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Shapley AI, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/typescript-recipes/parallel-procurement-n8n/README.md b/typescript-recipes/parallel-procurement-n8n/README.md new file mode 100644 index 0000000..9e72a95 --- /dev/null +++ b/typescript-recipes/parallel-procurement-n8n/README.md @@ -0,0 +1,99 @@ +# Procurement Vendor Risk Monitor (n8n) + +Continuous vendor risk monitoring built on [Parallel](https://parallel.ai)'s Task and Monitor APIs, orchestrated by n8n, with Slack-routed alerts and a cited audit log. Live demo: _video walkthrough coming with the public release_. + +See [DEPLOY.md](DEPLOY.md) for the optional Next.js + Supabase dashboard that ships with the recipe. + +## What it shows + +- Driving the Parallel **Task Group** API from an n8n cron flow to research every vendor across six risk dimensions on a daily cadence. +- Deploying a portfolio of **V1 event-stream monitors** per vendor (sized to priority) and resolving events via `client.monitor.events`. +- Wiring monitor webhooks back into the same scoring chain that handles deep-research output, so live events and daily research share a code path. +- Lifting the top citation from `output.basis` so every alert and audit row carries a source URL. +- Severity-based routing into Slack (`#procurement-critical` / `#alerts` / `#digest`) plus a `/vendor-research` slash command for ad-hoc reports. + +## Architecture + +``` +Google Sheets (Vendors tab) + | + Vendor Sync (every 6h) -----> Deploy Monitors (V1 event_stream) + | | + Deep Research (daily 2 AM) Monitor Events (real-time webhooks) + | | + +--------> Risk Scoring <-------+ + | + Route by severity + / | | \ + CRITICAL HIGH MEDIUM LOW + | | | | + #critical #alerts #digest (log only) + | + Audit Log (with top_citation_url, confidence) +``` + +One combined n8n workflow contains all five flows (50 nodes total). Every Parallel API call — daily Task Group, per-vendor monitor create, event resolution — runs on the official [`parallel-web`](https://www.npmjs.com/package/parallel-web) SDK against the V1 surfaces. + +## Quick Start + +1. Clone the cookbook and enter the recipe. + ```bash + git clone https://github.com/parallel-web/parallel-cookbook.git + cd parallel-cookbook/typescript-recipes/parallel-procurement-n8n + ``` +2. Install dependencies. + ```bash + npm ci + ``` +3. Copy `.env.example` to `.env` and fill in `PARALLEL_API_KEY`, `GOOGLE_SHEET_ID`, `SLACK_WEBHOOK_URL`, and `PROCUREMENT_SNAPSHOT_TOKEN`. +4. Run the test suite to verify the local install. + ```bash + npm test + ``` +5. Generate the importable n8n workflow JSON. + ```bash + npx tsx src/workflows/generate-all.ts ./n8n-workflows + ``` +6. Import `n8n-workflows/workflow-combined.json` into n8n Cloud, wire your Google Sheets and Slack credentials, and set `NODE_FUNCTION_ALLOW_EXTERNAL=parallel-web` so the Code nodes can `require()` the SDK at runtime. The full walkthrough is in [SETUP.md](SETUP.md). + +Optional: the [`dashboard/`](dashboard/) folder is a Next.js + Supabase BYOK app that consumes the same Parallel APIs. See [DEPLOY.md](DEPLOY.md) for Vercel + Supabase setup. + +## How it works + +**Vendor sync diffs the source of truth, not the world.** The cron flow reads the Google Sheet, compares it to the persisted registry, and only deploys or cancels monitors where the row actually changed — so a re-run is cheap and idempotent. Monitor cancellation is irreversible per the V1 contract, so the self-healing path recreates a fresh monitor with the same settings rather than mutating in place. + +**Daily research uses Task Groups, monitors stream events.** Each batch of due vendors becomes a Task Group of six per-dimension specs (financial, legal/regulatory, cybersecurity, leadership, ESG, adverse events). The same vendors also carry persistent V1 monitors (`type: "event_stream"`, nested `settings`, `processor: "lite" | "base"`) sized to priority: HIGH gets 5 monitors at `1d` cadence, MEDIUM gets 3 at `1d`, LOW gets 2 at `7d`. Cyber and legal monitors on HIGH vendors use the `base` processor for higher recall. + +**Scoring is deterministic, citations are not.** The cascade is fixed (any CRITICAL → CRITICAL; any HIGH → HIGH; 3+ MEDIUM across 2+ categories → MEDIUM-adverse; otherwise MEDIUM or LOW), with overrides for active breaches, government litigation, and a spreadsheet `risk_tier_override` floor. The interesting part is what comes back from `output.basis`: every assessment carries reasoning, confidence, and citation URLs per field. The scorer groups basis entries by dimension, picks the highest-confidence citation per triggered dimension, and writes them as `top_citation_url` / `top_citation_title` / `confidence` on the audit row and as a `Sources:` block in the Slack alert. + +**One workflow, five flows.** Combining the flows in a single n8n workflow means the monitor webhook and the daily Task Group fan into the exact same scoring chain — there is one risk-scoring path, not two that can drift. The five entry points (vendor sync cron, deep-research cron, monitor-deploy webhook, monitor-event webhook, Slack slash command) all share `src/services/risk-scorer.ts`. + +For the data model, processor selection rationale, and a full walkthrough of the scoring overrides, see [`parallel_procurement.md`](parallel_procurement.md). For a 15-vendor sample run with screenshots, see [`sample-setup.md`](sample-setup.md). + +## Configuration + +| Variable | Required | Default | Notes | +|---|---|---|---| +| `PARALLEL_API_KEY` | Yes | – | Parallel API key | +| `GOOGLE_SHEET_ID` | Yes | – | Google Sheets document ID | +| `SLACK_WEBHOOK_URL` | Yes | – | Slack incoming webhook | +| `PROCUREMENT_SNAPSHOT_TOKEN` | Yes | – | Bearer token for the snapshot endpoint (any 32+ char random string) | +| `N8N_WEBHOOK_BASE_URL` | Yes | – | Your n8n instance URL | +| `BATCH_SIZE` | No | `50` | Vendors per Task Group batch | +| `RESEARCH_PROCESSOR` | No | `ultra8x` | Parallel processor tier | +| `MONITOR_CADENCE_HIGH` / `_STD` | No | `1d` / `7d` | Override the default monitor cadences | +| `SLACK_CHANNEL_CRITICAL` / `_ALERT` / `_DIGEST` | No | see `.env.example` | Per-severity Slack routing | + +The dashboard takes additional Supabase / encryption / webhook secrets; see [`dashboard/.env.example`](dashboard/.env.example) and [DEPLOY.md](DEPLOY.md). + +## Tests + +```bash +npm test +``` + +46 Vitest suites covering every service and model, n8n workflow JSON structure (combined + per-flow generators), full pipeline integration (research → score → route → audit), vendor lifecycle across sync cycles, nine error scenarios, and a scale simulation (200 vendors / 4 batches, 3000 vendors / 15-day rotation). + +## License + +MIT — see [LICENSE](LICENSE). Copyright (c) 2026 Shapley AI, Inc. diff --git a/typescript-recipes/parallel-procurement-n8n/SETUP.md b/typescript-recipes/parallel-procurement-n8n/SETUP.md new file mode 100644 index 0000000..6a5a7de --- /dev/null +++ b/typescript-recipes/parallel-procurement-n8n/SETUP.md @@ -0,0 +1,286 @@ +# Vendor Risk Monitoring System -- Setup Guide + +Go from zero to a running vendor risk monitoring pipeline in ~30 minutes. + +--- + +## 1. Prerequisites + +You need three things before starting: + +| # | What | Where to get it | +|---|------|-----------------| +| 1 | **Parallel AI API key** | [platform.parallel.ai/settings](https://platform.parallel.ai/settings) | +| 2 | **Google account** | Any Google account with Google Sheets access | +| 3 | **Slack workspace admin access** | Needed to create channels and a Slack app | + +--- + +## 2. Google Sheets Setup (~5 minutes) + +### Create the Sheet + +1. Go to [sheets.google.com](https://sheets.google.com) and create a new spreadsheet +2. Name it **"Vendor Risk Registry"** (or any name you prefer) + +### Import the CSV templates + +For each CSV file in `templates/`, import it as a separate tab: + +| File | Tab name | +|------|----------| +| `vendors-tab.csv` | **Vendors** | +| `registry-tab.csv` | **Registry** | +| `audit-log-tab.csv` | **Audit Log** | +| `monitors-tab.csv` | **Monitors** | + +**How to import each CSV:** +1. In Google Sheets, click **File > Import** +2. Select **Upload** and choose the CSV file +3. Set **Import location** to **"Insert new sheet"** +4. Click **Import data** +5. Rename the tab to the name listed above + +### Copy the Sheet ID + +From the URL `https://docs.google.com/spreadsheets/d/SHEET_ID_HERE/edit`, copy the `SHEET_ID_HERE` portion. You'll need this later. + +### (Optional) Edit seed vendors + +The **Vendors** tab comes pre-populated with 15 companies across technology, financial services, healthcare, manufacturing, and professional services. Edit these to match your actual vendor portfolio, or keep them to test the system first. + +--- + +## 3. Slack Setup (~10 minutes) + +### Create channels + +Create these four channels in your Slack workspace: + +| Channel | Purpose | +|---------|---------| +| `#procurement-critical` | CRITICAL/HIGH risk alerts (immediate) | +| `#procurement-alerts` | Standard risk notifications | +| `#procurement-digest` | Weekly digest summaries | +| `#vendor-risk-ops` | Ops notifications: health checks, run summaries, errors | + +### Create a Slack App + +1. Go to [api.slack.com/apps](https://api.slack.com/apps) and click **Create New App** +2. Choose **From scratch**, name it **"Vendor Risk Bot"**, select your workspace +3. Under **OAuth & Permissions**, add these Bot Token Scopes: + - `chat:write` + - `chat:write.public` + - `commands` + - `incoming-webhook` +4. Click **Install to Workspace** and authorize +5. Copy the **Bot User OAuth Token** (`xoxb-...`) + +### Add the slash command + +1. In your Slack App settings, go to **Slash Commands** +2. Click **Create New Command**: + - **Command:** `/vendor-research` + - **Request URL:** `https://YOUR_N8N_URL/webhook/vendor-research` (you'll fill this in after n8n setup) + - **Short Description:** "Run ad-hoc vendor research" + - **Usage Hint:** `[vendor name or domain]` +3. Click **Save** + +### Copy credentials + +Save these for the next step: +- **Bot User OAuth Token** (`xoxb-...`) +- **Webhook URL** for `#procurement-critical` (from Incoming Webhooks section) + +--- + +## 4. n8n Setup (~10 minutes) + +### Sign up or self-host + +- **Cloud:** Sign up at [n8n.io](https://n8n.io) (free tier available) +- **Self-hosted:** Follow the [n8n self-hosting guide](https://docs.n8n.io/hosting/) + +### Import workflows + +1. In n8n, go to **Workflows** +2. For each JSON file in `n8n-workflows/`, click **Add Workflow > Import from File**: + - `workflow1-vendor-sync.json` -- Vendor Sync (reads Vendors tab, writes to Registry) + - `workflow2-deep-research.json` -- Deep Research (runs Parallel AI research on due vendors) + - `workflow3-risk-scoring.json` -- Risk Scoring (scores research, routes to Slack, logs to Audit) + - `workflow4-monitors.json` -- Monitors (manages Parallel AI monitor portfolio, handles events) + - `workflow5-adhoc.json` -- Ad-Hoc Research (Slack `/vendor-research` command handler) + +### Configure credentials + +Set up these credential types in n8n (**Settings > Credentials**): + +**Google Sheets (OAuth2):** +1. Click **Add Credential > Google Sheets OAuth2 API** +2. Follow the OAuth2 flow to connect your Google account +3. All Google Sheets nodes will use this credential + +**HTTP Header Auth (for Parallel AI):** +1. Click **Add Credential > Header Auth** +2. Set **Name** to `x-api-key` +3. Set **Value** to your Parallel AI API key + +**Slack (Bot Token):** +1. Click **Add Credential > Slack API** +2. Paste your Bot User OAuth Token (`xoxb-...`) + +### Set environment variables + +In n8n, go to **Settings > Variables** and add: + +| Variable | Value | Description | +|----------|-------|-------------| +| `PARALLEL_API_KEY` | `your-api-key` | Parallel AI API key | +| `GOOGLE_SHEET_ID` | `your-sheet-id` | Google Sheet ID from step 2 | +| `SLACK_WEBHOOK_URL` | `https://hooks.slack.com/...` | Slack incoming webhook URL | +| `N8N_WEBHOOK_BASE_URL` | `https://your-n8n.app.n8n.cloud` | Your n8n instance base URL | +| `SLACK_ALERT_TARGET` | `#procurement-critical` (optional) | Overrides the hardcoded Slack channel in the combined workflow | +| `RESEARCH_PROCESSOR` | `ultra8x` (optional) | Task API processor tier for daily research + ad-hoc runs | +| `PROCUREMENT_SNAPSHOT_TOKEN` | Required for the combined workflow | Shared token verified by the Snapshot region's Verify Token Function node. Without this, the `GET /webhook/procurement-dashboard-snapshot` endpoint refuses to run. Generate any random 32+ char string; the dashboard / any external caller must pass it as `?t=` or in the `x-procurement-token` header. | + +### Allow the parallel-web SDK in Code nodes (one-time) + +Every Parallel call in the combined workflow is a `Code` node that does +`require('parallel-web')` against the official TypeScript SDK. n8n's Code +node sandbox blocks external `require()` calls by default — you have to +allowlist the module. + +- **Self-hosted (Docker / bare metal):** set the env var on the n8n process, + then restart: + + ```bash + export NODE_FUNCTION_ALLOW_EXTERNAL=parallel-web + ``` + + If you already use this env var for other modules, append: + `NODE_FUNCTION_ALLOW_EXTERNAL=parallel-web,axios,...`. + +- **n8n Cloud:** the procurement template ships with `parallel-web` + pre-allowlisted on the workspace. If you're importing into a workspace + that hasn't been provisioned yet, contact n8n support to add it. + +The TS reference services in `src/services/` use the same SDK via a normal +`import` — no special config needed for those (they don't run inside n8n). + +### Wire Execute Workflow nodes + +Some workflows call other workflows. After importing, update the **Execute Workflow** nodes: + +1. In **Workflow 2 (Deep Research)**, find the Execute Workflow node that calls Risk Scoring -- set it to reference **Workflow 3**'s ID +2. In **Workflow 4 (Monitors)**, find the Execute Workflow node that calls Risk Scoring -- set it to reference **Workflow 3**'s ID + +To find a workflow's ID: open it in n8n, and copy the ID from the URL (`https://your-n8n.app.n8n.cloud/workflow/WORKFLOW_ID`). + +### Update Slack slash command URL + +1. Copy the webhook URL from **Workflow 5 (Ad-Hoc)** -- visible when you activate it +2. Go back to your [Slack App settings](https://api.slack.com/apps) > **Slash Commands** +3. Edit `/vendor-research` and set the **Request URL** to the n8n webhook URL + +--- + +## 5. Activate & Test (~5 minutes) + +### Step-by-step activation + +**1. Test Vendor Sync (Workflow 1)** +- Open Workflow 1 and click **Execute Workflow** (manual run) +- Check that the **Registry** tab in Google Sheets now has rows matching your Vendors tab +- Activate the workflow (toggle on) + +**2. Test Deep Research (Workflow 2)** +- Open Workflow 2 and click **Execute Workflow** +- Watch the Parallel AI dashboard for task activity +- After completion, check `#procurement-alerts` or `#procurement-critical` in Slack for alerts +- Check the **Audit Log** tab in Google Sheets for entries +- Activate the workflow + +**3. Test Monitors (Workflow 4)** +- Open Workflow 4 and trigger manually +- Check the Parallel AI dashboard -- monitors should appear for your vendors +- Check the **Monitors** tab in Google Sheets for tracking entries +- Activate the workflow + +**4. Test Ad-Hoc Research (Workflow 5)** +- Activate Workflow 5 +- In Slack, type `/vendor-research microsoft.com` +- You should receive a research report back in the channel + +**5. Activate remaining workflows** +- Activate Workflow 3 (Risk Scoring) -- this is called by other workflows, not on a cron +- Verify all 5 workflows show as active + +### Default schedules + +| Workflow | Schedule | +|----------|----------| +| 1. Vendor Sync | Every 6 hours | +| 2. Deep Research | Daily at 2:00 AM UTC | +| 4. Monitors | Cron: health check daily at 6:00 AM UTC | +| 5. Ad-Hoc | Webhook (always listening) | + +--- + +## 6. Configuration Reference + +### Environment variables + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `PARALLEL_API_KEY` | Yes | -- | Your Parallel AI API key | +| `GOOGLE_SHEET_ID` | Yes | -- | ID of the Google Sheet with all tabs | +| `SLACK_WEBHOOK_URL` | Yes | -- | Slack incoming webhook for critical alerts | +| `N8N_WEBHOOK_BASE_URL` | Yes | -- | Base URL of your n8n instance | +| `RESEARCH_CYCLE_DAYS` | No | `7` | Days between research runs per vendor | +| `BATCH_SIZE` | No | `10` | Vendors per Parallel AI task group | +| `POLL_INTERVAL_MS` | No | `60000` | Task group polling interval (ms) | +| `POLL_TIMEOUT_MS` | No | `3600000` | Task group polling timeout (ms) | + +### Monitor portfolio strategy (V1) + +Each vendor gets `type: "event_stream"` monitors across these risk +dimensions. The V1 `frequency` field replaces the old "daily"/"weekly" +cadence labels. + +| Dimension | Monitor Category | Frequency | +|-----------|-----------------|-----------| +| Financial Health | `Financial Health` | `1d` (HIGH/MEDIUM) / `7d` (LOW) | +| Legal & Regulatory | `Legal & Regulatory` | `1d` (HIGH/MEDIUM) / `7d` (LOW) | +| Cybersecurity | `Cybersecurity` | `1d` (HIGH/MEDIUM) | +| Leadership & Governance | `Leadership & Governance` | `1d` (HIGH only) | +| ESG & Reputation | `ESG & Reputation` | `1d` (HIGH only) | + +**Priority-based allocation:** +- **High priority** vendors: All 5 dimensions monitored, daily. +- **Medium priority** vendors: Legal, Cyber, Financial (3 dimensions), daily. +- **Low priority** vendors: Legal, Financial (2 dimensions), weekly. + +**Processor tier:** the workflow uses `processor: "base"` for HIGH-priority +Cyber and Legal monitors (where higher recall earns its cost) and +`processor: "lite"` everywhere else (cheaper, faster, fine for the +remaining queries). See the V1 +[Monitor Migration Guide](https://docs.parallel.ai/monitor-api/monitor-migration-guide) +for the full processor schedule. + +### Risk scoring rules + +| Risk Level | Trigger | Slack Channel | +|------------|---------|---------------| +| **CRITICAL** | Any critical-severity finding OR risk_tier_override = CRITICAL | `#procurement-critical` (immediate) | +| **HIGH** | 2+ high-severity findings OR any adverse event | `#procurement-critical` (immediate) | +| **MEDIUM** | 1 high-severity OR 3+ medium-severity findings | `#procurement-digest` (batched) | +| **LOW** | All other cases | No alert (logged only) | + +### Slack channel routing + +| Channel | What gets posted | +|---------|-----------------| +| `#procurement-critical` | CRITICAL and HIGH risk alerts with full detail | +| `#procurement-alerts` | Standard notifications and status updates | +| `#procurement-digest` | Weekly aggregated risk digest | +| `#vendor-risk-ops` | Health check reports, run summaries, error notifications | diff --git a/typescript-recipes/parallel-procurement-n8n/dashboard/.env.example b/typescript-recipes/parallel-procurement-n8n/dashboard/.env.example new file mode 100644 index 0000000..6344f06 --- /dev/null +++ b/typescript-recipes/parallel-procurement-n8n/dashboard/.env.example @@ -0,0 +1,39 @@ +# ────────────────────────────────────────────────────────────────────────── +# Parallel Procurement Dashboard — BYOK (Bring Your Own Keys) Gateway +# Copy to .env.local for development; set the same vars on Vercel. +# +# The dashboard never holds a managed Parallel / Slack / Resend key. +# Each user pastes their own at /signin and on /settings/keys; those keys +# are AES-GCM encrypted at rest in Supabase. The vars below are platform +# configuration only. +# ────────────────────────────────────────────────────────────────────────── + +# Public URL of this app. Used as the base for webhook callback URLs and +# email/Slack alert links. MUST match how users reach the deployment. +NEXT_PUBLIC_APP_URL=http://localhost:3000 + +# Supabase project (used as a Postgres database, not for auth). +SUPABASE_URL=https://YOUR-PROJECT.supabase.co +SUPABASE_SERVICE_ROLE_KEY=your-service-role-key + +# 32-byte random secret for signing the session cookie (HS256). +# Generate with: openssl rand -base64 32 +SESSION_SECRET=replace-me-with-32-bytes-base64 + +# 32-byte hex secret used to AES-GCM encrypt every BYOK secret at rest +# (Parallel API key, Slack bot token, Resend key). Generate with: +# openssl rand -hex 32 +APP_ENCRYPTION_KEY=replace-me-with-64-hex-chars + +# HMAC secret used to sign Parallel webhook URLs (we add a short query- +# string token because Parallel does not currently sign requests). +# Any random 32+ char string is fine. +PARALLEL_WEBHOOK_SECRET=replace-me-with-32-chars + +# Bearer secret required for cron job triggers. Public callers can spoof +# x-vercel-cron, so cron routes require Authorization: Bearer . +CRON_SECRET=replace-me-with-32-chars + +# Optional overrides +# PARALLEL_BASE_URL=https://api.parallel.ai +# PARALLEL_RESEARCH_PROCESSOR=ultra8x diff --git a/typescript-recipes/parallel-procurement-n8n/dashboard/.gitignore b/typescript-recipes/parallel-procurement-n8n/dashboard/.gitignore new file mode 100644 index 0000000..08f3095 --- /dev/null +++ b/typescript-recipes/parallel-procurement-n8n/dashboard/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +.next/ +.vercel/ +.env +.env.local +*.local +*.tsbuildinfo +.DS_Store diff --git a/typescript-recipes/parallel-procurement-n8n/dashboard/README.md b/typescript-recipes/parallel-procurement-n8n/dashboard/README.md new file mode 100644 index 0000000..add3bef --- /dev/null +++ b/typescript-recipes/parallel-procurement-n8n/dashboard/README.md @@ -0,0 +1,138 @@ +# Parallel Procurement Dashboard + +Multi-tenant **BYOK gateway** for continuous vendor risk monitoring on top of +the [Parallel.ai](https://parallel.ai) Task and Monitor APIs. + +The dashboard never holds a managed Parallel / Slack / Resend key. Each user +brings their own at sign-in (and on **Settings → API keys**); those keys are +AES-GCM encrypted at rest in Supabase and only ever decrypted server-side at +the moment of use. + +- **Sign in** is paste-key only: provide an email + your Parallel API key. + We validate the key against the Parallel API before creating the account. +- **Slack alerts** (HIGH / CRITICAL) use a Slack bot token you provide. +- **Email alerts** use a Resend API key you provide. +- **Multi-tenancy**: Postgres RLS keyed to a per-request `app.account_id` GUC. + +## Local development + +1. **Install deps** + + ```bash + cd typescript-recipes/parallel-procurement-n8n/dashboard + npm install + ``` + +2. **Provision Supabase** + + Create a Supabase project, then apply the schema (idempotent): + + ```bash + psql "$SUPABASE_DB_URL" -f supabase/schema.sql + ``` + +3. **Configure env vars** + + Copy `.env.example` to `.env.local` and fill in values: + + - `NEXT_PUBLIC_APP_URL` — public URL of the deployment. + - `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY` — from Project Settings → API. + - `SESSION_SECRET` — `openssl rand -base64 32`. + - `APP_ENCRYPTION_KEY` — `openssl rand -hex 32` (must decode to 32 bytes). + - `PARALLEL_WEBHOOK_SECRET` — any 32+ char random string. + - `CRON_SECRET` — bearer auth required for scheduled and manual cron triggers. + + No Parallel / Slack / Resend keys are configured at the platform level. + Every user provides their own. + +4. **Run** + + ```bash + npm run dev + ``` + + Open `http://localhost:3000` → `/signin` → paste email + Parallel API key + → land on `/onboarding/profile`. + +## Deploying to Vercel + +1. Push the repo and import this `dashboard/` directory as a Vercel project + (or use `vercel --cwd typescript-recipes/parallel-procurement-n8n/dashboard`). +2. Set every env var from the list above in Vercel **Project Settings → Env + Vars** (Production + Preview). +3. Vercel will read `vercel.json` and schedule: + - `/api/cron/sweep` daily at 05:00 UTC — refreshes any in-flight task + groups and reconciles runs that finished without delivering a webhook. + (Hobby tier is limited to one cron per day; upgrade to Pro to switch + this back to hourly `0 * * * *`.) + - `/api/cron/research-due` daily at 06:00 UTC — kicks off fresh research + for any vendor whose `next_research_date` has elapsed, using each + account's BYOK Parallel key. + +## Smoke test + +After deploying: + +1. Visit `/signin` → paste email + Parallel API key → continue. +2. Onboarding step 1: enter a display name. +3. Onboarding step 2: paste two vendors, e.g. + + ``` + Microsoft, microsoft.com, technology, high + Oracle, oracle.com, technology, medium + ``` + +4. Onboarding step 3: hit **Start research**. Progress polls + `/api/research/groups/` every 5 s. Once done, monitors deploy and you + land on `/`. +5. Visit **Settings → API keys**: + - Parallel shows your initial key, marked default. + - Add a Slack bot token (with `chat:write` scope) and a channel. + - Add a Resend API key and a verified `from` address. + - Use **Validate** to confirm each key, **Send test** to fire a real + test message / email. +6. Trigger a HIGH or CRITICAL assessment (manually re-run research on a + vendor known to have adverse signals) and watch Slack + email fire. + +## How it fits together + +``` +Browser ──► Next.js (App Router) ──► /api/auth/key (paste key) + │ │ + │ ▼ + ├──► Supabase Postgres (RLS, integrations.encrypted_secret) + │ + ├──► api.parallel.ai (using user's Parallel key) + ├──► slack.com/api/* (using user's Slack token) + └──► api.resend.com/* (using user's Resend key) + │ + ▼ + Webhook ──► /api/webhooks/parallel-task + ──► /api/webhooks/parallel-monitor + └──► notifyAssessment fans out + to Slack + email integrations +``` + +- `lib/server/db.ts` — service-role Supabase client (server-only). +- `lib/server/account.ts` — `requireAccount` / `requireAccountWithKey`. +- `lib/server/integrations.ts` — BYOK CRUD + decrypt + audit. +- `lib/server/providers.ts` — per-provider validate / test / send helpers + (Parallel, Slack, Resend). +- `lib/server/notifications.ts` — Slack + email alert fan-out. +- `lib/server/research.ts` — kicks off Parallel deep research, persists + pending `risk_assessments`, scores results, then notifies on HIGH+. +- `lib/server/monitors.ts` — deploys per-priority monitor portfolios via + the Parallel Monitor API. +- `lib/parallel/*` — pure Parallel API clients, prompt + monitor query + generators, and risk scorer. +- `app/settings/keys/` — full settings UI (list, add, validate, send test, + rotate, delete) for all three providers. +- `app/api/integrations/*` — REST surface backing the settings UI. + +## Where the n8n project still fits + +The sibling [`../src/`](../src/) and [`../n8n-workflows/`](../n8n-workflows/) +directories are the original n8n deploy path. Nothing in this dashboard +depends on n8n at runtime; the pure parts of the integration are +re-implemented in TypeScript so they run inside Next.js. Run either side +independently, or run both against the same Parallel account. diff --git a/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/auth/key/route.ts b/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/auth/key/route.ts new file mode 100644 index 0000000..5052a30 --- /dev/null +++ b/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/auth/key/route.ts @@ -0,0 +1,129 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { db } from "@/lib/server/db"; +import { sha256Hex } from "@/lib/server/crypto"; +import { setSessionCookie } from "@/lib/server/session"; +import { addIntegration } from "@/lib/server/integrations"; +import { testParallelKey } from "@/lib/server/providers"; + +export const runtime = "nodejs"; + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export async function POST(request: NextRequest) { + let payload: { email?: string; apiKey?: string }; + try { + payload = (await request.json()) as { email?: string; apiKey?: string }; + } catch { + return NextResponse.json({ ok: false, error: "Invalid JSON body" }, { status: 400 }); + } + + const email = (payload.email ?? "").trim().toLowerCase(); + const apiKey = (payload.apiKey ?? "").trim(); + + if (!email || !EMAIL_RE.test(email)) { + return NextResponse.json({ ok: false, error: "Enter a valid email address" }, { status: 400 }); + } + if (!apiKey || apiKey.length < 12) { + return NextResponse.json({ ok: false, error: "API key looks too short" }, { status: 400 }); + } + + // Validate against Parallel before we persist anything. This proves the + // presented key is usable, but it is not identity proof for an existing + // account; that check happens below by matching the stored secret hash. + const test = await testParallelKey(apiKey); + if (!test.ok) { + return NextResponse.json( + { ok: false, error: test.error ?? "Parallel rejected this key" }, + { status: 401 }, + ); + } + + const emailHash = await sha256Hex(email); + + // Upsert account by email_hash. + const { data: existing, error: lookupErr } = await db() + .from("accounts") + .select("id, onboarded_at") + .eq("email_hash", emailHash) + .maybeSingle(); + + if (lookupErr) { + console.error("[auth/key] lookup failed", lookupErr); + return NextResponse.json( + { ok: false, error: "Account lookup failed" }, + { status: 500 }, + ); + } + + let accountId: string; + let onboarded = false; + + if (existing) { + accountId = existing.id; + onboarded = !!existing.onboarded_at; + + const apiKeyHash = await sha256Hex(apiKey); + const { data: matchingIntegration, error: integrationLookupErr } = await db() + .from("integrations") + .select("id") + .eq("account_id", accountId) + .eq("provider", "parallel") + .eq("secret_hash", apiKeyHash) + .eq("status", "active") + .maybeSingle(); + + if (integrationLookupErr) { + console.error("[auth/key] integration lookup failed", integrationLookupErr); + return NextResponse.json( + { ok: false, error: "Account lookup failed" }, + { status: 500 }, + ); + } + + if (!matchingIntegration) { + return NextResponse.json( + { ok: false, error: "Email or Parallel API key is incorrect" }, + { status: 401 }, + ); + } + + await db().from("accounts").update({ email }).eq("id", accountId); + } else { + const { data: created, error: createErr } = await db() + .from("accounts") + .insert({ email, email_hash: emailHash }) + .select("id") + .single(); + if (createErr || !created) { + console.error("[auth/key] create failed", createErr); + return NextResponse.json( + { ok: false, error: "Could not create account" }, + { status: 500 }, + ); + } + accountId = created.id; + try { + await addIntegration({ + accountId, + provider: "parallel", + secret: apiKey, + label: "default", + makeDefault: true, + actor: "system", + }); + } catch (err) { + console.error("[auth/key] failed to store parallel integration", err); + return NextResponse.json( + { ok: false, error: "Could not store API key" }, + { status: 500 }, + ); + } + } + + await setSessionCookie({ accountId }); + + return NextResponse.json({ + ok: true, + next: onboarded ? "/" : "/onboarding/profile", + }); +} diff --git a/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/auth/logout/route.ts b/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/auth/logout/route.ts new file mode 100644 index 0000000..9660070 --- /dev/null +++ b/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/auth/logout/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from "next/server"; +import { clearSessionCookie } from "@/lib/server/session"; +import { env } from "@/lib/server/env"; + +export const runtime = "nodejs"; + +export async function POST() { + await clearSessionCookie(); + return NextResponse.json({ ok: true }); +} + +export async function GET() { + await clearSessionCookie(); + return NextResponse.redirect(`${env().APP_URL}/signin`, { status: 302 }); +} diff --git a/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/cron/research-due/route.ts b/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/cron/research-due/route.ts new file mode 100644 index 0000000..9077f9a --- /dev/null +++ b/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/cron/research-due/route.ts @@ -0,0 +1,61 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { db } from "@/lib/server/db"; +import { getActiveIntegration } from "@/lib/server/integrations"; +import { runResearchForVendors } from "@/lib/server/research"; +import { isCronAuthorized } from "@/lib/server/cron-auth"; +import type { VendorRow } from "@/lib/server/vendors"; + +export const runtime = "nodejs"; +export const maxDuration = 300; + +/** + * Daily 06:00 UTC sweep: + * - For every vendor whose next_research_date <= today, queue a fresh + * research run, batched per-account so each account uses its own API key. + */ +export async function GET(request: NextRequest) { + if (!isCronAuthorized(request)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const today = new Date().toISOString().slice(0, 10); + + // Treat null next_research_date as "never researched → due now" — matches + // BatchPlanner.getVendorsDueForResearch. Postgres `lte` ignores nulls, + // so we explicitly OR them in. + const { data: dueVendors, error } = await db() + .from("vendors") + .select("*") + .or(`next_research_date.is.null,next_research_date.lte.${today}`); + if (error) throw error; + if (!dueVendors?.length) return NextResponse.json({ ok: true, scheduled: 0 }); + + const byAccount = new Map(); + for (const v of dueVendors as VendorRow[]) { + const arr = byAccount.get(v.account_id) ?? []; + arr.push(v); + byAccount.set(v.account_id, arr); + } + + let totalScheduled = 0; + for (const [accountId, vendors] of byAccount) { + const { data: account } = await db() + .from("accounts") + .select("onboarded_at") + .eq("id", accountId) + .maybeSingle(); + if (!account?.onboarded_at) continue; + + const integration = await getActiveIntegration(accountId, "parallel"); + if (!integration) continue; + + try { + const result = await runResearchForVendors(accountId, integration.secret, vendors); + totalScheduled += result.total; + } catch (err) { + console.error("[cron/research-due] failed for account", accountId, err); + } + } + + return NextResponse.json({ ok: true, scheduled: totalScheduled }); +} diff --git a/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/cron/sweep/route.ts b/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/cron/sweep/route.ts new file mode 100644 index 0000000..f6a21f4 --- /dev/null +++ b/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/cron/sweep/route.ts @@ -0,0 +1,41 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { db } from "@/lib/server/db"; +import { getActiveIntegration } from "@/lib/server/integrations"; +import { refreshTaskGroupStatus } from "@/lib/server/research"; +import { isCronAuthorized } from "@/lib/server/cron-auth"; + +export const runtime = "nodejs"; +export const maxDuration = 300; + +/** + * Hourly sweep: + * - For every task_groups row in `running` state, ask Parallel for the + * latest counts, persist them, and reconcile any runs that completed + * without delivering a webhook. + */ +export async function GET(request: NextRequest) { + if (!isCronAuthorized(request)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { data: groups } = await db() + .from("task_groups") + .select("account_id, task_group_id") + .eq("status", "running"); + + if (!groups?.length) return NextResponse.json({ ok: true, swept: 0 }); + + const swept: string[] = []; + for (const g of groups) { + const integration = await getActiveIntegration(g.account_id, "parallel"); + if (!integration) continue; + try { + await refreshTaskGroupStatus(g.account_id, integration.secret, g.task_group_id); + swept.push(g.task_group_id); + } catch (err) { + console.error("[cron/sweep] failed for", g.task_group_id, err); + } + } + + return NextResponse.json({ ok: true, swept: swept.length }); +} diff --git a/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/integrations/[integrationId]/route.ts b/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/integrations/[integrationId]/route.ts new file mode 100644 index 0000000..5d5ebcb --- /dev/null +++ b/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/integrations/[integrationId]/route.ts @@ -0,0 +1,101 @@ +import { NextResponse } from "next/server"; +import { HttpError, requireAccount } from "@/lib/server/account"; +import { + deleteIntegration, + rotateIntegration, + updateIntegrationMetadata, + type IntegrationProvider, +} from "@/lib/server/integrations"; +import { db } from "@/lib/server/db"; +import { testProviderKey } from "@/lib/server/providers"; + +export const runtime = "nodejs"; + +export async function DELETE( + _request: Request, + context: { params: Promise<{ integrationId: string }> }, +) { + try { + const account = await requireAccount(); + const { integrationId } = await context.params; + await deleteIntegration(account.id, integrationId); + return NextResponse.json({ ok: true }); + } catch (err) { + return errorResponse(err); + } +} + +export async function PATCH( + request: Request, + context: { params: Promise<{ integrationId: string }> }, +) { + try { + const account = await requireAccount(); + const { integrationId } = await context.params; + const body = (await request.json().catch(() => ({}))) as { + secret?: string; + metadata?: Record; + validate?: boolean; + }; + + if (typeof body.secret === "string" && body.secret.trim().length > 0) { + const secret = body.secret.trim(); + // Look up the provider so we can validate the new secret BEFORE + // persisting. Mirrors POST behavior — a bogus rotated key fails fast + // with 422 instead of silently breaking the next cron/webhook tick. + const { data: row } = await db() + .from("integrations") + .select("provider") + .eq("id", integrationId) + .eq("account_id", account.id) + .maybeSingle(); + if (!row) { + return NextResponse.json({ error: "Integration not found" }, { status: 404 }); + } + + // `validate=false` lets the caller opt out (e.g. if they're rotating + // to a key the test endpoint can't reach), matching POST semantics. + if (body.validate !== false) { + const test = await testProviderKey(row.provider as IntegrationProvider, secret); + if (!test.ok) { + return NextResponse.json( + { error: test.error ?? "Validation failed" }, + { status: 422 }, + ); + } + } + + const updated = await rotateIntegration({ + accountId: account.id, + integrationId, + secret, + }); + return NextResponse.json({ integration: updated }); + } + + if (body.metadata && typeof body.metadata === "object") { + const updated = await updateIntegrationMetadata( + account.id, + integrationId, + body.metadata, + ); + return NextResponse.json({ integration: updated }); + } + + return NextResponse.json( + { error: "Provide either `secret` (to rotate) or `metadata` (to update)" }, + { status: 400 }, + ); + } catch (err) { + return errorResponse(err); + } +} + +function errorResponse(err: unknown): NextResponse { + if (err instanceof HttpError) { + return NextResponse.json({ error: err.message }, { status: err.status }); + } + console.error("[api/integrations/:id]", err); + const message = err instanceof Error ? err.message : "Internal error"; + return NextResponse.json({ error: message }, { status: 500 }); +} diff --git a/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/integrations/[integrationId]/test/route.ts b/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/integrations/[integrationId]/test/route.ts new file mode 100644 index 0000000..a16d138 --- /dev/null +++ b/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/integrations/[integrationId]/test/route.ts @@ -0,0 +1,107 @@ +import { NextResponse } from "next/server"; +import { db } from "@/lib/server/db"; +import { byteaToBytes, decryptApiKey } from "@/lib/server/crypto"; +import { HttpError, requireAccount } from "@/lib/server/account"; +import { recordTestResult } from "@/lib/server/integrations"; +import { + testParallelKey, + testResendKey, + testSlackToken, + postSlackMessage, + sendResendEmail, +} from "@/lib/server/providers"; +import type { IntegrationProvider } from "@/lib/server/integrations"; + +export const runtime = "nodejs"; + +export async function POST( + request: Request, + context: { params: Promise<{ integrationId: string }> }, +) { + try { + const account = await requireAccount(); + const { integrationId } = await context.params; + const body = (await request.json().catch(() => ({}))) as { + mode?: "validate" | "send"; + }; + const mode = body.mode === "send" ? "send" : "validate"; + + const { data, error } = await db() + .from("integrations") + .select("id, provider, encrypted_secret, metadata") + .eq("id", integrationId) + .eq("account_id", account.id) + .maybeSingle(); + if (error || !data) { + return NextResponse.json({ error: "Integration not found" }, { status: 404 }); + } + + const provider = data.provider as IntegrationProvider; + const secret = await decryptApiKey(byteaToBytes(data.encrypted_secret)); + const metadata = (data.metadata ?? {}) as Record; + + let result; + if (mode === "send") { + result = await sendTestPayload(provider, secret, metadata, account.email); + } else { + result = await validate(provider, secret); + } + + await recordTestResult(account.id, integrationId, result.ok, result.error ?? null); + + return NextResponse.json(result); + } catch (err) { + if (err instanceof HttpError) { + return NextResponse.json({ error: err.message }, { status: err.status }); + } + console.error("[api/integrations/:id/test]", err); + const message = err instanceof Error ? err.message : "Internal error"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} + +async function validate(provider: IntegrationProvider, secret: string) { + switch (provider) { + case "parallel": + return await testParallelKey(secret); + case "slack": + return await testSlackToken(secret); + case "email": + return await testResendKey(secret); + } +} + +async function sendTestPayload( + provider: IntegrationProvider, + secret: string, + metadata: Record, + recipientEmail: string | null, +) { + switch (provider) { + case "parallel": + return await testParallelKey(secret); + case "slack": { + const channel = (metadata.channel as string | undefined) ?? "#general"; + return await postSlackMessage({ + token: secret, + channel, + text: ":test_tube: Test message from Parallel Procurement — your Slack integration is working.", + }); + } + case "email": { + if (!recipientEmail) { + return { ok: false, error: "No email on this account to send a test to." }; + } + const from = + (metadata.from as string | undefined) ?? + "Procurement Risk "; + return await sendResendEmail({ + apiKey: secret, + from, + to: recipientEmail, + subject: "Parallel Procurement test email", + html: "

Your Resend integration is connected. You'll receive HIGH and CRITICAL alerts at this address.

", + }); + } + } +} diff --git a/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/integrations/route.ts b/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/integrations/route.ts new file mode 100644 index 0000000..3e6fc3b --- /dev/null +++ b/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/integrations/route.ts @@ -0,0 +1,84 @@ +import { NextResponse } from "next/server"; +import { HttpError, requireAccount } from "@/lib/server/account"; +import { + addIntegration, + listIntegrations, + type IntegrationProvider, +} from "@/lib/server/integrations"; +import { testProviderKey } from "@/lib/server/providers"; + +export const runtime = "nodejs"; + +const PROVIDERS: ReadonlySet = new Set([ + "parallel", + "slack", + "email", +]); + +export async function GET() { + try { + const account = await requireAccount(); + const integrations = await listIntegrations(account.id); + return NextResponse.json({ integrations }); + } catch (err) { + return errorResponse(err); + } +} + +export async function POST(request: Request) { + try { + const account = await requireAccount(); + const body = (await request.json().catch(() => ({}))) as { + provider?: string; + secret?: string; + label?: string; + metadata?: Record; + validate?: boolean; + }; + + const provider = body.provider as IntegrationProvider; + const secret = (body.secret ?? "").trim(); + + if (!provider || !PROVIDERS.has(provider)) { + return NextResponse.json( + { error: "Provider must be one of: parallel, slack, email" }, + { status: 400 }, + ); + } + if (!secret) { + return NextResponse.json({ error: "Secret is required" }, { status: 400 }); + } + + if (body.validate !== false) { + const test = await testProviderKey(provider, secret); + if (!test.ok) { + return NextResponse.json( + { error: test.error ?? "Validation failed" }, + { status: 422 }, + ); + } + } + + const integration = await addIntegration({ + accountId: account.id, + provider, + secret, + label: body.label, + metadata: body.metadata, + makeDefault: true, + }); + + return NextResponse.json({ integration }, { status: 201 }); + } catch (err) { + return errorResponse(err); + } +} + +function errorResponse(err: unknown): NextResponse { + if (err instanceof HttpError) { + return NextResponse.json({ error: err.message }, { status: err.status }); + } + console.error("[api/integrations]", err); + const message = err instanceof Error ? err.message : "Internal error"; + return NextResponse.json({ error: message }, { status: 500 }); +} diff --git a/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/monitors/[monitorId]/route.ts b/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/monitors/[monitorId]/route.ts new file mode 100644 index 0000000..ae2969b --- /dev/null +++ b/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/monitors/[monitorId]/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import { HttpError, requireAccountWithKey } from "@/lib/server/account"; +import { deleteMonitor } from "@/lib/server/monitors"; + +export const runtime = "nodejs"; +export const maxDuration = 60; + +export async function DELETE( + _request: Request, + context: { params: Promise<{ monitorId: string }> }, +) { + try { + const { monitorId } = await context.params; + const account = await requireAccountWithKey(); + await deleteMonitor(account.id, account.parallelApiKey, monitorId); + return NextResponse.json({ ok: true }); + } catch (err) { + if (err instanceof HttpError) { + return NextResponse.json({ error: err.message }, { status: err.status }); + } + console.error("[api/monitors/[id]] error", err); + const message = err instanceof Error ? err.message : "Internal error"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/monitors/deploy/route.ts b/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/monitors/deploy/route.ts new file mode 100644 index 0000000..4585e36 --- /dev/null +++ b/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/monitors/deploy/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from "next/server"; +import { HttpError, requireAccountWithKey } from "@/lib/server/account"; +import { db } from "@/lib/server/db"; +import { deployMonitorsForVendor } from "@/lib/server/monitors"; +import type { VendorRow } from "@/lib/server/vendors"; + +export const runtime = "nodejs"; +export const maxDuration = 60; + +export async function POST(request: Request) { + try { + const account = await requireAccountWithKey(); + const body = await request.json().catch(() => ({})); + const requested: string[] = Array.isArray(body?.vendorIds) + ? body.vendorIds + : body?.vendorId + ? [body.vendorId] + : []; + + let query = db().from("vendors").select("*").eq("account_id", account.id); + if (requested.length > 0) { + query = query.in("id", requested); + } + const { data: vendors, error } = await query; + if (error) throw error; + if (!vendors?.length) { + return NextResponse.json({ error: "No vendors to deploy" }, { status: 400 }); + } + + const results = []; + for (const vendor of vendors as VendorRow[]) { + const created = await deployMonitorsForVendor( + account.id, + account.parallelApiKey, + vendor, + ); + results.push({ vendorId: vendor.id, count: created.length, monitors: created }); + } + return NextResponse.json({ results }, { status: 201 }); + } catch (err) { + if (err instanceof HttpError) { + return NextResponse.json({ error: err.message }, { status: err.status }); + } + console.error("[api/monitors/deploy] error", err); + const message = err instanceof Error ? err.message : "Internal error"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/onboarding/complete/route.ts b/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/onboarding/complete/route.ts new file mode 100644 index 0000000..b3bc940 --- /dev/null +++ b/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/onboarding/complete/route.ts @@ -0,0 +1,64 @@ +import { NextResponse } from "next/server"; +import { HttpError, requireAccountWithKey } from "@/lib/server/account"; +import { db } from "@/lib/server/db"; +import { deployMonitorsForVendor } from "@/lib/server/monitors"; +import type { VendorRow } from "@/lib/server/vendors"; + +export const runtime = "nodejs"; +export const maxDuration = 60; + +/** + * Final step of onboarding: deploy the per-priority monitor portfolio for + * every vendor, then mark the account onboarded so the middleware stops + * forcing the user back to /onboarding/*. + */ +export async function POST() { + try { + const account = await requireAccountWithKey(); + + const { data: vendors, error } = await db() + .from("vendors") + .select("*") + .eq("account_id", account.id); + if (error) throw error; + + const summary = { + monitorsCreated: 0, + vendorsCovered: 0, + }; + + for (const vendor of (vendors ?? []) as VendorRow[]) { + try { + const created = await deployMonitorsForVendor( + account.id, + account.parallelApiKey, + vendor, + ); + summary.monitorsCreated += created.length; + if (created.length > 0) summary.vendorsCovered += 1; + } catch (err) { + console.error( + "[onboarding/complete] failed to deploy monitors for", + vendor.vendor_name, + err, + ); + } + } + + await db() + .from("accounts") + .update({ onboarded_at: new Date().toISOString() }) + .eq("id", account.id); + + return NextResponse.json({ ok: true, summary }); + } catch (err) { + if (err instanceof HttpError) { + return NextResponse.json({ error: err.message }, { status: err.status }); + } + console.error("[api/onboarding/complete]", err); + return NextResponse.json( + { error: err instanceof Error ? err.message : "Internal error" }, + { status: 500 }, + ); + } +} diff --git a/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/onboarding/profile/route.ts b/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/onboarding/profile/route.ts new file mode 100644 index 0000000..d5c313a --- /dev/null +++ b/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/onboarding/profile/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server"; +import { HttpError, requireAccount } from "@/lib/server/account"; +import { db } from "@/lib/server/db"; + +export const runtime = "nodejs"; + +export async function POST(request: Request) { + try { + const account = await requireAccount(); + const body = await request.json(); + const display_name = String(body?.displayName ?? "").trim() || null; + const email = String(body?.email ?? "").trim() || null; + const { error } = await db() + .from("accounts") + .update({ display_name, email }) + .eq("id", account.id); + if (error) throw error; + return NextResponse.json({ ok: true }); + } catch (err) { + if (err instanceof HttpError) { + return NextResponse.json({ error: err.message }, { status: err.status }); + } + console.error("[api/onboarding/profile]", err); + return NextResponse.json( + { error: err instanceof Error ? err.message : "Internal error" }, + { status: 400 }, + ); + } +} diff --git a/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/research/groups/[taskGroupId]/route.ts b/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/research/groups/[taskGroupId]/route.ts new file mode 100644 index 0000000..5714284 --- /dev/null +++ b/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/research/groups/[taskGroupId]/route.ts @@ -0,0 +1,53 @@ +import { NextResponse } from "next/server"; +import { HttpError, requireAccountWithKey } from "@/lib/server/account"; +import { db } from "@/lib/server/db"; +import { refreshTaskGroupStatus } from "@/lib/server/research"; + +export const runtime = "nodejs"; +export const maxDuration = 60; + +export async function GET( + _request: Request, + context: { params: Promise<{ taskGroupId: string }> }, +) { + try { + const { taskGroupId } = await context.params; + const account = await requireAccountWithKey(); + + const { data: row } = await db() + .from("task_groups") + .select("task_group_id, total_runs, completed_runs, failed_runs, status, kind") + .eq("account_id", account.id) + .eq("task_group_id", taskGroupId) + .maybeSingle(); + if (!row) { + return NextResponse.json({ error: "Task group not found" }, { status: 404 }); + } + + const live = await refreshTaskGroupStatus( + account.id, + account.parallelApiKey, + taskGroupId, + ); + + return NextResponse.json({ + taskGroupId, + total: live.total, + completed: live.completed, + failed: live.failed, + isActive: live.isActive, + status: live.isActive + ? "running" + : live.failed === live.total + ? "failed" + : "completed", + }); + } catch (err) { + if (err instanceof HttpError) { + return NextResponse.json({ error: err.message }, { status: err.status }); + } + console.error("[api/research/groups] error", err); + const message = err instanceof Error ? err.message : "Internal error"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/research/run/route.ts b/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/research/run/route.ts new file mode 100644 index 0000000..c18a304 --- /dev/null +++ b/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/research/run/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from "next/server"; +import { HttpError, requireAccountWithKey } from "@/lib/server/account"; +import { db } from "@/lib/server/db"; +import { runResearchForVendors } from "@/lib/server/research"; +import type { VendorRow } from "@/lib/server/vendors"; + +export const runtime = "nodejs"; +// Research kickoff occasionally takes >10s if Parallel is slow. +export const maxDuration = 60; + +export async function POST(request: Request) { + try { + const account = await requireAccountWithKey(); + const body = await request.json().catch(() => ({})); + const requested: string[] = Array.isArray(body?.vendorIds) ? body.vendorIds : []; + + let query = db().from("vendors").select("*").eq("account_id", account.id); + if (requested.length > 0) { + query = query.in("id", requested); + } + const { data: vendors, error } = await query; + if (error) throw error; + if (!vendors?.length) { + return NextResponse.json( + { error: "No vendors found to research" }, + { status: 400 }, + ); + } + + const result = await runResearchForVendors( + account.id, + account.parallelApiKey, + vendors as VendorRow[], + ); + return NextResponse.json(result, { status: 202 }); + } catch (err) { + if (err instanceof HttpError) { + return NextResponse.json({ error: err.message }, { status: err.status }); + } + console.error("[api/research/run] error", err); + const message = err instanceof Error ? err.message : "Internal error"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/vendors/[vendorId]/route.ts b/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/vendors/[vendorId]/route.ts new file mode 100644 index 0000000..9ea2147 --- /dev/null +++ b/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/vendors/[vendorId]/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server"; +import { HttpError, requireAccount } from "@/lib/server/account"; +import { deleteVendor, updateVendor } from "@/lib/server/vendors"; + +export const runtime = "nodejs"; + +export async function PATCH( + request: Request, + context: { params: Promise<{ vendorId: string }> }, +) { + try { + const { vendorId } = await context.params; + const account = await requireAccount(); + const body = await request.json(); + const vendor = await updateVendor(account.id, vendorId, body); + return NextResponse.json({ vendor }); + } catch (err) { + return errorResponse(err); + } +} + +export async function DELETE( + _request: Request, + context: { params: Promise<{ vendorId: string }> }, +) { + try { + const { vendorId } = await context.params; + const account = await requireAccount(); + await deleteVendor(account.id, vendorId); + return NextResponse.json({ ok: true }); + } catch (err) { + return errorResponse(err); + } +} + +function errorResponse(err: unknown) { + if (err instanceof HttpError) { + return NextResponse.json({ error: err.message }, { status: err.status }); + } + console.error("[api/vendors/[id]] error", err); + const message = err instanceof Error ? err.message : "Internal error"; + return NextResponse.json({ error: message }, { status: 400 }); +} diff --git a/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/vendors/import/route.ts b/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/vendors/import/route.ts new file mode 100644 index 0000000..d0ea42b --- /dev/null +++ b/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/vendors/import/route.ts @@ -0,0 +1,46 @@ +import { NextResponse } from "next/server"; +import { HttpError, requireAccount } from "@/lib/server/account"; +import { insertVendor, parseVendorList } from "@/lib/server/vendors"; + +export const runtime = "nodejs"; + +/** + * POST a JSON body { text: "csv or paste-list contents" } OR + * an array { vendors: [VendorInput, ...] } and we will upsert each. + */ +export async function POST(request: Request) { + try { + const account = await requireAccount(); + const body = await request.json(); + const inputs = Array.isArray(body?.vendors) + ? body.vendors + : parseVendorList(String(body?.text ?? "")); + if (!inputs.length) { + return NextResponse.json( + { error: "No vendors found in input" }, + { status: 400 }, + ); + } + const inserted = []; + const errors: Array<{ vendorName: string; error: string }> = []; + for (const input of inputs) { + try { + const row = await insertVendor(account.id, input); + inserted.push(row); + } catch (err) { + errors.push({ + vendorName: String(input?.vendorName ?? "(unknown)"), + error: err instanceof Error ? err.message : String(err), + }); + } + } + return NextResponse.json({ inserted, errors }); + } catch (err) { + if (err instanceof HttpError) { + return NextResponse.json({ error: err.message }, { status: err.status }); + } + console.error("[api/vendors/import] error", err); + const message = err instanceof Error ? err.message : "Internal error"; + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/vendors/route.ts b/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/vendors/route.ts new file mode 100644 index 0000000..8e82c1d --- /dev/null +++ b/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/vendors/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from "next/server"; +import { requireAccount, HttpError } from "@/lib/server/account"; +import { insertVendor, listVendorsByAccount } from "@/lib/server/vendors"; + +export const runtime = "nodejs"; + +export async function GET() { + try { + const account = await requireAccount(); + const vendors = await listVendorsByAccount(account.id); + return NextResponse.json({ vendors }); + } catch (err) { + return errorResponse(err); + } +} + +export async function POST(request: Request) { + try { + const account = await requireAccount(); + const body = await request.json(); + const vendor = await insertVendor(account.id, body); + return NextResponse.json({ vendor }, { status: 201 }); + } catch (err) { + return errorResponse(err); + } +} + +function errorResponse(err: unknown) { + if (err instanceof HttpError) { + return NextResponse.json({ error: err.message }, { status: err.status }); + } + console.error("[api/vendors] error", err); + const message = err instanceof Error ? err.message : "Internal error"; + return NextResponse.json({ error: message }, { status: 400 }); +} diff --git a/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/webhooks/parallel-monitor/route.ts b/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/webhooks/parallel-monitor/route.ts new file mode 100644 index 0000000..2d97650 --- /dev/null +++ b/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/webhooks/parallel-monitor/route.ts @@ -0,0 +1,177 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { db } from "@/lib/server/db"; +import { env } from "@/lib/server/env"; +import { ParallelMonitorClient } from "@/lib/parallel/monitor-client"; +import { verifyToken } from "@/lib/server/webhook-token"; +import { getActiveIntegration, markIntegrationUsed } from "@/lib/server/integrations"; +import { notifyAssessment } from "@/lib/server/notifications"; +import { normalizeSeverity } from "@/lib/parallel/severity"; + +export const runtime = "nodejs"; +export const maxDuration = 60; + +interface MonitorWebhookPayload { + type?: string; + data?: { + monitor_id?: string; + event?: { event_group_id?: string }; + metadata?: Record; + }; +} + +interface MonitorEventOutput { + event_summary?: string; + severity?: string; + adverse?: boolean; + event_type?: string; +} + +// normalizeSeverity now lives in @/lib/parallel/severity so the route, the +// risk scorer, and the src-side scorer all share one off-enum collapse +// rule (finding 11). + +export async function POST(request: NextRequest) { + const token = request.nextUrl.searchParams.get("t"); + if (!(await verifyToken("monitor", token))) { + return NextResponse.json({ error: "Invalid token" }, { status: 401 }); + } + + let payload: MonitorWebhookPayload; + try { + payload = (await request.json()) as MonitorWebhookPayload; + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const monitorId = payload.data?.monitor_id; + const eventGroupId = payload.data?.event?.event_group_id; + if (!monitorId || !eventGroupId) { + return NextResponse.json({ error: "Missing monitor_id or event_group_id" }, { status: 400 }); + } + + const { data: monitorRow } = await db() + .from("monitors") + .select("id, account_id, vendor_id, dimension") + .eq("parallel_monitor_id", monitorId) + .maybeSingle(); + + if (!monitorRow) { + return NextResponse.json({ ok: true, ignored: true }); + } + + const integration = await getActiveIntegration(monitorRow.account_id, "parallel"); + if (!integration) { + return NextResponse.json({ ok: true, ignored: true }); + } + await markIntegrationUsed(monitorRow.account_id, integration.id); + + const client = new ParallelMonitorClient({ + apiKey: integration.secret, + baseUrl: env().PARALLEL_BASE_URL, + }); + + // V1: unified /events endpoint filtered by `event_group_id` replaces + // the alpha `/event_groups/{id}` GET. Returns at most one event_stream + // event per execution (plus optional completion/error events). + let page; + try { + page = await client.listEvents(monitorId, { + event_group_id: eventGroupId, + include_completions: false, + }); + } catch (err) { + console.error("[webhook/monitor] failed to fetch monitor events", err); + return NextResponse.json({ ok: true, error: "fetch_failed" }); + } + + const events = Array.isArray(page?.events) ? page.events : []; + const inserted: string[] = []; + + for (const evt of events) { + const e = evt as unknown as Record; + // V1 event_stream events always carry a stable `event_id`. Snapshot + // and completion events are skipped via the type guard below. + if (e.event_type && e.event_type !== "event_stream") continue; + const eventId = + (e.event_id as string | undefined) ?? + `${monitorId}:${eventGroupId}:${(e.event_date as string | undefined) ?? Date.now()}`; + + // V1: `output` is always a typed object `{ type, content, basis }` + // — never a top-level string anymore. Content holds our flat + // monitor schema (`event_summary`, `severity`, `adverse`, + // `event_type`); basis carries per-field citations. + const rawOutput = e.output as + | { type?: string; content?: unknown; basis?: Array<{ citations?: Array<{ url?: string }> }> } + | undefined; + const content = + rawOutput && typeof rawOutput.content === "object" && rawOutput.content !== null + ? (rawOutput.content as MonitorEventOutput) + : ({} as MonitorEventOutput); + + // Pull the first available citation URL out of basis to keep our + // existing `source_url` column populated for the dashboard's links. + const firstCitation = (rawOutput?.basis ?? []) + .flatMap((entry) => entry.citations ?? []) + .find((c) => typeof c.url === "string")?.url; + + const detail = content.event_summary ?? ""; + const title = detail.slice(0, 140); + const severity = normalizeSeverity(content.severity); + + const { error: insertErr } = await db().from("monitor_events").upsert( + { + account_id: monitorRow.account_id, + vendor_id: monitorRow.vendor_id, + monitor_id: monitorRow.id, + parallel_event_id: eventId, + parallel_event_group_id: eventGroupId, + parallel_monitor_id: monitorId, + severity, + dimension: monitorRow.dimension, + title: title || "Monitor event", + detail: detail || null, + source_url: firstCitation ?? null, + raw_payload: evt as object, + }, + { onConflict: "parallel_event_id" }, + ); + if (insertErr) { + console.error("[webhook/monitor] failed to insert event", insertErr); + continue; + } + inserted.push(eventId); + } + + await db() + .from("monitors") + .update({ last_event_at: new Date().toISOString() }) + .eq("id", monitorRow.id); + + // Fan an alert out to Slack + email for any HIGH or CRITICAL events. + // V1 events nest the procurement-flat shape under `output.content`. + const severeEvents = events.filter((evt) => { + const out = (evt as { output?: { content?: MonitorEventOutput } }).output ?? {}; + const sev = normalizeSeverity(out.content?.severity); + return sev === "HIGH" || sev === "CRITICAL"; + }); + if (severeEvents.length > 0) { + const { data: vendor } = await db() + .from("vendors") + .select("vendor_name, vendor_domain") + .eq("id", monitorRow.vendor_id) + .maybeSingle(); + const top = severeEvents[0] as { output?: { content?: MonitorEventOutput } }; + const content = top.output?.content ?? {}; + await notifyAssessment({ + accountId: monitorRow.account_id, + vendorName: vendor?.vendor_name ?? "Unknown vendor", + vendorDomain: vendor?.vendor_domain ?? null, + riskLevel: normalizeSeverity(content.severity), + summary: content.event_summary ?? "Monitor flagged a new event", + source: "monitor_event", + url: `${env().APP_URL}/feed`, + }); + } + + return NextResponse.json({ ok: true, inserted: inserted.length }); +} diff --git a/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/webhooks/parallel-task/route.ts b/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/webhooks/parallel-task/route.ts new file mode 100644 index 0000000..b159f3f --- /dev/null +++ b/typescript-recipes/parallel-procurement-n8n/dashboard/app/api/webhooks/parallel-task/route.ts @@ -0,0 +1,148 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { db } from "@/lib/server/db"; +import { env } from "@/lib/server/env"; +import { ParallelTaskClient } from "@/lib/parallel/task-client"; +import { persistAssessmentForRun } from "@/lib/server/research"; +import type { BasisEntry, DeepResearchOutput } from "@/lib/parallel/types"; +import { verifyToken } from "@/lib/server/webhook-token"; +import { getActiveIntegration, markIntegrationUsed } from "@/lib/server/integrations"; + +export const runtime = "nodejs"; +export const maxDuration = 60; + +interface TaskWebhookPayload { + type?: string; + data?: { + run_id?: string; + status?: string; + metadata?: Record; + }; + // Some webhook flavors place fields at the top level. + run_id?: string; + status?: string; + metadata?: Record; +} + +export async function POST(request: NextRequest) { + const token = request.nextUrl.searchParams.get("t"); + if (!(await verifyToken("research", token))) { + return NextResponse.json({ error: "Invalid token" }, { status: 401 }); + } + + let payload: TaskWebhookPayload; + try { + payload = (await request.json()) as TaskWebhookPayload; + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const runId = payload.data?.run_id ?? payload.run_id; + const status = payload.data?.status ?? payload.status; + if (!runId) { + return NextResponse.json({ error: "Missing run_id" }, { status: 400 }); + } + + const { data: assessment } = await db() + .from("risk_assessments") + .select("id, account_id, vendor_id, status") + .eq("parallel_run_id", runId) + .maybeSingle(); + + if (!assessment) { + // Race: the webhook can arrive before runResearchForVendors finishes + // inserting the pending risk_assessments row (Parallel sometimes + // delivers a task_run.status webhook within ms of taskGroup.addRuns + // returning). Returning 503 with Retry-After tells Parallel to redeliver + // after the local DB insert has had time to commit, instead of dropping + // the assessment on the floor. + return NextResponse.json( + { ok: false, retry: true, reason: "assessment_not_yet_persisted" }, + { status: 503, headers: { "Retry-After": "30" } }, + ); + } + + if (status && status !== "completed" && status !== "failed" && status !== "cancelled") { + // queued / running — store progress and bail. + await db() + .from("risk_assessments") + .update({ status: "running" }) + .eq("id", assessment.id); + return NextResponse.json({ ok: true }); + } + + if (status === "failed" || status === "cancelled") { + await db() + .from("risk_assessments") + .update({ status: "failed", summary: `Run ${status}` }) + .eq("id", assessment.id); + return NextResponse.json({ ok: true }); + } + + // status === "completed": pull the actual output from Parallel using the + // owning account's BYOK Parallel integration. + const integration = await getActiveIntegration(assessment.account_id, "parallel"); + if (!integration) { + console.error("[webhook/task] no active parallel integration for", assessment.account_id); + return NextResponse.json({ ok: true, ignored: true }); + } + await markIntegrationUsed(assessment.account_id, integration.id); + + const client = new ParallelTaskClient({ + apiKey: integration.secret, + baseUrl: env().PARALLEL_BASE_URL, + }); + + // Get the actual run output (the webhook only carries status by default). + // V1 results carry `output.basis` alongside `output.content`; we forward + // both so the scorer can emit top_citations into the audit row. + // + // `getRunResult` returns null on a 404 — that can mean the result store + // hasn't materialized the row yet (the webhook fires before result + // storage finishes in some Parallel deployments). Treat it as transient + // and leave the assessment in `status: "running"` so cron/sweep → + // reconcileTaskGroupResults reconciles it on the next tick. + let output: DeepResearchOutput | null = null; + let basis: BasisEntry[] = []; + let fetchErrored = false; + let resultMissing = false; + try { + const result = await client.getRunResult(runId); + if (result === null) { + resultMissing = true; + } else { + output = (result.output?.content as unknown as DeepResearchOutput) ?? null; + basis = (result.output?.basis ?? []) as BasisEntry[]; + } + } catch (err) { + fetchErrored = true; + console.error("[webhook/task] failed to fetch result", runId, err); + } + + if (!output) { + if (resultMissing || fetchErrored) { + // Transient — leave row running so cron/sweep can retry. + await db() + .from("risk_assessments") + .update({ status: "running" }) + .eq("id", assessment.id); + return NextResponse.json({ ok: true, deferred: true }); + } + // Status was "completed" but the result payload was empty/unknown shape + // — this is a real failure, not a transient miss. + await db() + .from("risk_assessments") + .update({ status: "failed", summary: "Result fetch failed" }) + .eq("id", assessment.id); + return NextResponse.json({ ok: true }); + } + + await persistAssessmentForRun({ + accountId: assessment.account_id, + runId, + vendorId: assessment.vendor_id, + output, + basis, + }); + + return NextResponse.json({ ok: true }); +} diff --git a/typescript-recipes/parallel-procurement-n8n/dashboard/app/attention/page.tsx b/typescript-recipes/parallel-procurement-n8n/dashboard/app/attention/page.tsx new file mode 100644 index 0000000..a611e87 --- /dev/null +++ b/typescript-recipes/parallel-procurement-n8n/dashboard/app/attention/page.tsx @@ -0,0 +1,24 @@ +import { redirect } from "next/navigation"; +import { AttentionQueuePage, DashboardShell } from "@/components/dashboard-ui"; +import { requireAccount } from "@/lib/server/account"; +import { getDashboardSnapshot } from "@/lib/server/dashboard-queries"; + +export const dynamic = "force-dynamic"; + +export default async function AttentionPage() { + const account = await requireAccount(); + if (!account.onboarded_at) redirect("/onboarding/profile"); + const data = await getDashboardSnapshot(account.id); + + return ( + + + + ); +} diff --git a/typescript-recipes/parallel-procurement-n8n/dashboard/app/feed/page.tsx b/typescript-recipes/parallel-procurement-n8n/dashboard/app/feed/page.tsx new file mode 100644 index 0000000..83bb560 --- /dev/null +++ b/typescript-recipes/parallel-procurement-n8n/dashboard/app/feed/page.tsx @@ -0,0 +1,24 @@ +import { redirect } from "next/navigation"; +import { DashboardShell, FeedPagePanels } from "@/components/dashboard-ui"; +import { requireAccount } from "@/lib/server/account"; +import { getDashboardSnapshot } from "@/lib/server/dashboard-queries"; + +export const dynamic = "force-dynamic"; + +export default async function FeedPage() { + const account = await requireAccount(); + if (!account.onboarded_at) redirect("/onboarding/profile"); + const data = await getDashboardSnapshot(account.id); + + return ( + + + + ); +} diff --git a/typescript-recipes/parallel-procurement-n8n/dashboard/app/globals.css b/typescript-recipes/parallel-procurement-n8n/dashboard/app/globals.css new file mode 100644 index 0000000..92a08be --- /dev/null +++ b/typescript-recipes/parallel-procurement-n8n/dashboard/app/globals.css @@ -0,0 +1,2577 @@ +:root { + --bg: #ffffff; + --surface: #ffffff; + --surface-2: #f7f6f3; + --line: #ddd8d0; + --line-strong: #cfc8bf; + --text: #171614; + --muted: #57524b; + --muted-2: #716a62; + --low: #3e7d5a; + --medium: #8c6a2d; + --high: #a64b2a; + --critical: #c62828; + --radius: 20px; +} + +* { + box-sizing: border-box; +} + +html { + color-scheme: light; +} + +body { + margin: 0; + min-height: 100vh; + background: var(--bg); + color: var(--text); + font-family: var(--font-sans), sans-serif; +} + +a { + color: inherit; + text-decoration: none; +} + +button, +input, +textarea, +select { + font: inherit; +} + +.dashboard-shell { + width: min(1540px, calc(100vw - 64px)); + margin: 0 auto; + padding: 40px 0 48px; +} + +.topbar-label, +.eyebrow, +.detail-label, +.metric-card-label, +.meta-label { + display: inline-block; + margin-bottom: 8px; + color: var(--muted-2); + font-family: var(--font-mono), monospace; + font-size: 0.72rem; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.topbar { + display: grid; + grid-template-columns: minmax(0, 1fr) 280px; + gap: 24px; + align-items: start; + padding-bottom: 24px; + border-bottom: 1px solid var(--line); +} + +.priority-notes { + padding-right: 12px; +} + +.priority-notes h2 { + margin: 0 0 8px; + font-family: var(--font-serif), serif; + font-size: 1.75rem; + font-weight: 500; + letter-spacing: -0.03em; +} + +.priority-context { + display: flex; + flex-wrap: wrap; + gap: 10px 18px; + margin-bottom: 14px; + color: var(--muted); + font-size: 0.88rem; +} + +.priority-list { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.priority-item { + padding: 13px 14px; + border: 1px solid var(--line); + border-radius: 16px; + background: var(--surface); + text-align: left; +} + +.priority-item-top { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; + margin-bottom: 8px; +} + +.priority-item strong { + font-size: 0.96rem; + font-weight: 600; +} + +.priority-item-meta, +.priority-item p { + color: var(--muted); + font-size: 0.875rem; + line-height: 1.42; +} + +.priority-item-meta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; + margin-bottom: 0; +} + +.priority-deadline { + color: var(--critical); + font-weight: 600; +} + +.priority-item p { + margin: 0; +} + +.topbar-meta { + display: flex; + flex-direction: column; + align-self: start; +} + +.attention-button { + min-height: 88px; + padding: 14px 16px; + border: 1px solid #efb9b9; + border-radius: 16px; + background: var(--surface); + text-align: left; +} + +.topbar-meta strong { + display: block; + font-family: var(--font-serif), serif; + font-size: 1.1rem; + font-weight: 500; + letter-spacing: -0.02em; +} + +.attention-button-copy { + display: inline-flex; + margin-top: 10px; + padding: 8px 12px; + border-radius: 999px; + background: var(--critical); + color: #fff; + font-size: 0.875rem; + font-weight: 600; +} + +.summary-band { + display: grid; + grid-template-columns: minmax(0, 1.6fr) minmax(280px, 0.8fr); + gap: 40px; + padding: 28px 0 32px; + align-items: start; +} + +.summary-metrics { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 22px; +} + +.metric-card { + min-height: 98px; + padding: 0 22px 0 0; + border-right: 1px solid var(--line); +} + +.metric-card:last-child { + border-right: none; +} + +.metric-card-value { + display: block; + margin-top: 6px; + font-family: var(--font-serif), serif; + font-size: 2.2rem; + font-weight: 500; + line-height: 1.05; + letter-spacing: -0.03em; +} + +.metric-card-value.stacked { + display: grid; + gap: 2px; +} + +.metric-card-trend, +.summary-note p, +.vendor-summary, +.dimension-card p, +.event-card p, +.evidence-item p, +.queue-item p, +.monitor-row p, +.feed-item p, +.feed-item small { + color: var(--muted); +} + +.metric-card-trend, +.summary-note p, +.queue-item p, +.monitor-row p, +.feed-item p, +.feed-item small, +.dimension-card p, +.event-card p, +.evidence-item p { + line-height: 1.65; + font-size: 0.875rem; +} + +.summary-note { + padding-left: 32px; + border-left: 1px solid var(--line); +} + +.summary-note p { + margin: 0; + font-size: 1.02rem; +} + +.main-grid { + display: block; +} + +.panel { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 24px; +} + +.panel-header { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: start; + margin-bottom: 18px; +} + +.panel-header.compact { + margin-bottom: 18px; +} + +.panel-header h2, +.roster-header h3, +.section-title-row h3 { + margin: 0; + font-family: var(--font-serif), serif; + font-size: 1.75rem; + font-weight: 500; + line-height: 1; + letter-spacing: -0.03em; +} + +.distribution-list { + display: flex; + gap: 14px; + flex-wrap: wrap; +} + +.distribution-item { + display: inline-flex; + gap: 10px; + align-items: center; +} + +.distribution-item strong { + font-size: 0.95rem; +} + +.matrix-table, +.roster-table, +.dimension-stack, +.event-list, +.evidence-list, +.queue-list, +.monitor-stack, +.feed-list, +.detail-sections { + display: grid; + gap: 10px; +} + +.feed-list { + gap: 0; +} + +.feed-stream-page { + background: transparent; +} + +.row, +.matrix-head, +.matrix-row, +.roster-head, +.roster-row { + display: grid; + align-items: center; +} + +.matrix-head, +.matrix-row { + grid-template-columns: minmax(210px, 1.45fr) repeat(5, minmax(96px, 0.62fr)) minmax(78px, 0.45fr); + gap: 10px; +} + +.matrix-head, +.roster-head { + padding: 0 2px 6px; + color: var(--muted-2); + font-family: var(--font-mono), monospace; + font-size: 0.68rem; + letter-spacing: 0.12em; + text-transform: uppercase; + text-align: left; +} + +.matrix-head span, +.roster-head span { + justify-self: start; +} + +.matrix-row, +.roster-row { + padding: 12px 14px; + border: 1px solid transparent; + border-radius: 16px; + background: transparent; + color: var(--text); + text-align: left; + cursor: pointer; + transition: background 160ms ease, border-color 160ms ease; +} + +.matrix-row:hover, +.roster-row:hover { + background: var(--surface-2); + border-color: var(--line); +} + +.matrix-row.selected, +.roster-row.selected { + background: #fff5f5; + border-color: #efb9b9; +} + +.matrix-row.critical-row { + background: #fff5f5; + border-color: #f0d0d0; +} + +.matrix-vendor-name, +.roster-row strong, +.dimension-card strong, +.event-card strong, +.evidence-item strong, +.queue-item strong, +.monitor-row strong, +.feed-item strong, +.priority-item strong { + font-size: 0.96rem; + font-weight: 600; +} + +.matrix-vendor, +.roster-vendor, +.score-cell { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + min-width: 0; +} + +.matrix-vendor-owner, +.roster-vendor small { + display: block; + color: var(--muted); + font-size: 0.875rem; + line-height: 1.4; +} + +.matrix-vendor-meta, +.roster-row small, +.movement-cell small, +.event-card-top span, +.feed-item-top span, +.evidence-item small, +.detail-topline span, +.monitor-row small, +.queue-item-meta { + color: var(--muted); + font-size: 0.875rem; + line-height: 1.45; +} + +.severity-cell { + display: inline-flex; + align-items: center; + gap: 6px; + justify-content: flex-start; + min-height: 28px; + justify-self: start; +} + +.severity-label { + font-size: 0.82rem; + text-transform: capitalize; +} + +.severity-shape { + width: 12px; + display: inline-flex; + justify-content: center; + font-size: 0.8rem; +} + +.severity-cell.low, +.monitor-status.active { + color: var(--low); +} + +.severity-cell.medium, +.risk-badge.medium { + color: var(--medium); +} + +.severity-cell.high, +.risk-badge.high { + color: var(--high); +} + +.severity-cell.critical, +.risk-badge.critical, +.monitor-status.needs_review { + color: var(--critical); +} + +.monitor-status.watching { + color: var(--muted); +} + +.risk-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 4px 7px; + border-radius: 999px; + border: 1px solid transparent; + font-size: 0.68rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #fff; +} + +.risk-badge.low { + background: var(--low); +} + +.risk-badge.medium { + background: var(--medium); +} + +.risk-badge.high { + background: var(--high); +} + +.risk-badge.critical { + background: var(--critical); +} + +.risk-signal { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.risk-signal-dot { + width: 6px; + height: 6px; + border-radius: 999px; + background: currentColor; +} + +.risk-signal.low { + color: var(--low); +} + +.risk-signal.medium { + color: var(--medium); +} + +.risk-signal.high { + color: var(--high); +} + +.risk-signal.critical { + color: var(--critical); +} + +.matrix-action { + display: flex; + justify-content: flex-start; +} + +.roster-block { + margin-top: 28px; + padding-top: 22px; + border-top: 1px solid var(--line); +} + +.roster-header { + margin-bottom: 16px; +} + +.roster-head, +.roster-row { + grid-template-columns: minmax(180px, 1.15fr) minmax(120px, 0.8fr) minmax(72px, 0.32fr) minmax(110px, 0.46fr) minmax(90px, 0.42fr) minmax(110px, 0.44fr); + gap: 12px; +} + +.score-cell { + font-family: var(--font-serif), serif; +} + +.score-cell strong { + font-size: 1.5rem; + line-height: 1; + letter-spacing: -0.03em; +} + +.movement-cell { + display: flex; + flex-direction: column; + gap: 4px; +} + +.movement-cell strong { + font-size: 1rem; + line-height: 1; +} + +.movement-cell.up strong { + color: var(--low); +} + +.movement-cell.down strong { + color: var(--text); +} + +.risk-cell { + justify-self: start; +} + +.open-link { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 6px; + color: var(--muted); + font-size: 0.82rem; +} + +.bottom-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 24px; + margin-top: 24px; +} + +.ops-block { + margin-top: 28px; + padding-top: 24px; + border-top: 1px solid var(--line); +} + +.queue-item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 16px; + align-items: start; +} + +.queue-item-main, +.queue-item-meta { + display: flex; + flex-direction: column; + gap: 6px; +} + +.queue-item-top, +.event-card-top, +.evidence-item-top, +.monitor-row-head, +.feed-item-top, +.detail-modal-header { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; +} + +.feed-source { + display: inline-flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.feed-source span { + color: var(--muted); + font-size: 0.82rem; + white-space: nowrap; +} + +.queue-item-meta { + align-items: flex-end; + text-align: right; +} + +.ops-summary { + display: grid; + gap: 12px; + margin-top: 12px; +} + +.ops-line { + display: flex; + align-items: center; + gap: 16px; + padding-bottom: 10px; + border-bottom: 1px solid var(--line); +} + +.ops-line::after { + content: ""; + flex: 1; + border-bottom: 1px dotted var(--line-strong); +} + +.ops-line:last-child { + border-bottom: none; + padding-bottom: 0; +} + +.dimension-card, +.event-card, +.evidence-item, +.queue-item, +.monitor-row, +.empty-card { + padding: 16px; + border: 1px solid var(--line); + border-radius: 16px; + background: var(--surface); +} + +.feed-item { + display: grid; + gap: 4px; + padding: 10px 0; + border-bottom: 1px solid var(--line); + transition: background 160ms ease, color 160ms ease; +} + +.feed-item:last-child { + border-bottom: none; +} + +.feed-item:hover { + background: transparent; +} + +.feed-list.stream-only .feed-item { + padding: 12px 14px; +} + +.feed-list.stream-only .feed-item:nth-child(odd) { + background: #ffffff; +} + +.feed-list.stream-only .feed-item:nth-child(even) { + background: #faf8f4; +} + +.feed-list.stream-only .feed-item:hover { + background: #f3efea; +} + +.feed-share-panel { + display: grid; + gap: 14px; +} + +.feed-share-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.feed-share-card { + border: 1px solid var(--line); + border-radius: 16px; + padding: 14px; + background: #fcfbf8; + display: grid; + gap: 10px; +} + +.feed-share-head { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: baseline; +} + +.feed-share-head strong { + font-size: 0.94rem; +} + +.feed-share-head span { + color: var(--muted-2); + font-family: var(--font-mono), monospace; + font-size: 0.68rem; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.feed-share-card p { + margin: 0; + color: var(--muted); + font-size: 0.86rem; + line-height: 1.5; +} + +.feed-share-button { + width: fit-content; + border: none; + border-radius: 999px; + background: var(--text); + color: #fff; + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 34px; + padding: 7px 12px; + cursor: pointer; + font-size: 0.82rem; + font-weight: 600; +} + +.feed-share-button.secondary { + background: #efebe4; + color: var(--text); +} + +.feed-share-note { + margin: 0; + color: var(--muted); + font-size: 0.82rem; +} + +.feed-log-line { + display: flex; + align-items: baseline; + gap: 10px; + min-width: 0; +} + +.feed-log-time { + color: var(--muted-2); + font-family: var(--font-mono), monospace; + font-size: 0.76rem; + white-space: nowrap; +} + +.feed-log-title { + min-width: 0; + color: var(--text); + font-size: 0.92rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.feed-item small { + color: var(--muted); + font-size: 0.82rem; + line-height: 1.35; +} + +.dimension-card-top { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; + margin-bottom: 12px; +} + +.dimension-card-heading { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; +} + +.dimension-title { + display: block; + font-size: 0.94rem; + font-weight: 700; + letter-spacing: 0.01em; +} + +.detail-status { + display: inline-flex; + align-items: center; + padding: 3px 8px; + border-radius: 999px; + background: var(--surface-2); + color: var(--muted); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.event-link { + display: inline-flex; + align-items: center; + gap: 6px; + margin-top: 10px; + color: var(--muted); + font-size: 0.84rem; +} + +.footer-note { + margin-top: 28px; + padding-top: 18px; + border-top: 1px solid var(--line); + color: var(--muted); + font-size: 0.9rem; +} + +.detail-modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.08); + display: flex; + justify-content: flex-end; + padding: 18px; + z-index: 40; +} + +.detail-modal { + width: min(700px, calc(100vw - 36px)); + height: calc(100vh - 36px); + overflow-y: auto; + background: #fff; + border: 1px solid var(--line); + border-radius: 22px; + padding: 24px; + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.08); +} + +.detail-modal-header h2, +.section-title-row h3 { + margin: 0; + font-family: var(--font-serif), serif; + font-size: 1.55rem; + font-weight: 500; + letter-spacing: -0.03em; +} + +.detail-title-row { + display: flex; + align-items: center; + gap: 10px; +} + +.close-button { + height: 36px; + padding: 0 12px; + border: 1px solid var(--line); + border-radius: 999px; + background: #fff; + color: var(--text); + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + cursor: pointer; +} + +.detail-topline { + display: flex; + flex-wrap: wrap; + gap: 18px; + padding-bottom: 22px; + border-bottom: 1px solid var(--line); +} + +.domain-link { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.vendor-summary { + max-width: 62ch; + margin: 20px 0 0; + font-size: 1.04rem; +} + +.detail-kpis { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px 20px; + padding: 20px 0; + border-bottom: 1px solid var(--line); +} + +.detail-kpis strong { + display: block; + margin-top: 4px; + font-size: 1rem; + font-weight: 600; +} + +.recommendation-card strong { + color: var(--critical); + font-size: 1.08rem; + text-transform: capitalize; +} + +.recommendation-card { + padding: 14px 16px; + border: 1px solid #efb9b9; + border-radius: 16px; + background: #fff5f5; +} + +.close-button span { + font-size: 0.84rem; + font-weight: 600; +} + +.detail-sections { + margin-top: 24px; + gap: 24px; +} + +.detail-section + .detail-section { + padding-top: 24px; + border-top: 1px solid var(--line); +} + +.section-title-row { + margin-bottom: 18px; +} + +.empty-card { + display: flex; + align-items: center; + min-height: 120px; + color: var(--muted); +} + +.app-shell { + display: grid; + gap: 24px; +} + +.app-header { + display: grid; + gap: 20px; +} + +.app-header-bar { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: center; +} + +.app-brand { + font-size: 0.96rem; + font-weight: 700; + letter-spacing: -0.01em; +} + +.app-nav { + display: inline-flex; + gap: 8px; + flex-wrap: wrap; +} + +.app-nav-link { + padding: 8px 12px; + border: 1px solid transparent; + border-radius: 999px; + color: var(--muted); + font-size: 0.9rem; + transition: border-color 160ms ease, color 160ms ease, background 160ms ease; +} + +.app-nav-link:hover, +.app-nav-link.active { + border-color: var(--line); + background: var(--surface-2); + color: var(--text); +} + +.page-header { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 24px; + align-items: start; + padding-bottom: 20px; + border-bottom: 1px solid var(--line); +} + +.page-header.has-aside { + grid-template-columns: minmax(0, 1fr) 280px; +} + +.page-header-copy h1 { + margin: 0 0 10px; + font-family: var(--font-serif), serif; + font-size: 2.2rem; + font-weight: 500; + letter-spacing: -0.04em; + line-height: 0.98; +} + +.page-header-copy p { + max-width: 72ch; + margin: 0; + color: var(--muted); + font-size: 0.98rem; + line-height: 1.6; +} + +.page-meta { + display: flex; + flex-wrap: wrap; + gap: 10px 18px; + margin-top: 6px; + color: var(--muted); + font-size: 0.88rem; +} + +.page-breadcrumb { + display: inline-flex; + align-items: center; + gap: 8px; + margin-bottom: 10px; + color: var(--muted); + font-family: var(--font-mono), monospace; + font-size: 0.76rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.page-breadcrumb-link { + color: var(--text); +} + +.vendor-meta-bar { + gap: 10px 14px; + font-family: var(--font-mono), monospace; + font-size: 0.76rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.page-content { + display: grid; + gap: 24px; +} + +.page-header-aside { + display: flex; + justify-content: flex-end; +} + +.surface-panel { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 22px; +} + +.action-card { + display: flex; + min-height: 92px; + padding: 14px 16px; + border: 1px solid #efb9b9; + border-radius: 16px; + background: #fff; + flex-direction: column; + align-items: flex-start; + text-align: left; +} + +.action-card strong { + display: block; + margin-top: 2px; + font-family: var(--font-serif), serif; + font-size: 1.05rem; + font-weight: 500; + letter-spacing: -0.02em; +} + +.action-card-button { + display: inline-flex; + margin-top: 10px; + padding: 8px 12px; + border-radius: 999px; + background: var(--critical); + color: #fff; + font-size: 0.84rem; + font-weight: 600; +} + +.section-heading { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: start; + margin-bottom: 10px; +} + +.section-heading h2 { + margin: 0; + font-family: var(--font-serif), serif; + font-size: 1.65rem; + font-weight: 500; + letter-spacing: -0.03em; +} + +.text-link { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--muted); + font-size: 0.88rem; +} + +.watchlist-table, +.attention-table { + display: grid; + gap: 8px; +} + +.watchlist-head, +.watchlist-row, +.attention-head, +.attention-row { + display: grid; + align-items: center; +} + +.watchlist-head, +.attention-head { + padding: 0 2px 6px; + color: var(--muted-2); + font-family: var(--font-mono), monospace; + font-size: 0.68rem; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.watchlist-head, +.watchlist-row { + grid-template-columns: minmax(180px, 1.15fr) minmax(110px, 0.62fr) minmax(88px, 0.38fr) minmax(170px, 0.88fr) minmax(68px, 0.28fr) minmax(70px, 0.28fr) minmax(68px, 0.26fr); + gap: 10px; +} + +.attention-head, +.attention-row { + grid-template-columns: minmax(180px, 0.9fr) minmax(140px, 0.75fr) minmax(96px, 0.45fr) minmax(94px, 0.42fr) minmax(0, 1.8fr); + gap: 14px; +} + +.watchlist-row, +.attention-row { + padding: 12px 14px; + border: 1px solid transparent; + border-radius: 16px; + transition: background 160ms ease, border-color 160ms ease; +} + +.watchlist-row:hover, +.attention-row:hover { + background: var(--surface-2); + border-color: var(--line); +} + +.attention-vendor { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; +} + +.driver-stack { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 6px; +} + +.severity-tag { + display: inline-flex; + align-items: center; + padding: 3px 8px; + border-radius: 999px; + font-size: 0.76rem; + font-weight: 600; + line-height: 1.2; +} + +.severity-tag.low { + background: rgba(62, 125, 90, 0.12); + color: var(--low); +} + +.severity-tag.medium { + background: rgba(140, 106, 45, 0.12); + color: var(--medium); +} + +.severity-tag.high { + background: rgba(166, 75, 42, 0.12); + color: var(--high); +} + +.severity-tag.critical { + background: rgba(198, 40, 40, 0.12); + color: var(--critical); +} + +.operations-layout { + display: grid; + grid-template-columns: minmax(320px, 0.75fr) minmax(0, 1.25fr); + gap: 24px; +} + +.vendor-detail-layout { + display: grid; + gap: 24px; +} + +.vendor-overview-grid { + display: grid; + grid-template-columns: minmax(0, 1.15fr) minmax(280px, 0.85fr); + gap: 32px; + align-items: start; +} + +.vendor-abstract { + display: grid; + gap: 12px; +} + +.detail-topline.compact { + gap: 12px; + padding-bottom: 0; + border-bottom: none; +} + +.vendor-stat-block { + display: grid; + gap: 14px; + padding-left: 24px; + border-left: 1px solid var(--line); +} + +.stat-row { + display: grid; + gap: 4px; +} + +.stat-number { + font-family: var(--font-serif), serif; + font-size: 3rem; + line-height: 0.95; + letter-spacing: -0.05em; +} + +.stat-trend { + font-size: 1.15rem; + font-weight: 700; +} + +.stat-trend.up { + color: var(--low); +} + +.stat-trend.down { + color: var(--critical); +} + +.verdict-block { + display: grid; + gap: 8px; + padding-left: 16px; + border-left: 3px solid var(--critical); +} + +.verdict-block strong { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--critical); + font-family: var(--font-mono), monospace; + font-size: 0.9rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.vendor-analysis-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 32px; +} + +.vendor-analysis-column + .vendor-analysis-column { + padding-left: 32px; + border-left: 1px solid var(--line); +} + +.dimension-lines, +.intelligence-list { + display: grid; +} + +.dimension-line, +.intelligence-row { + padding: 12px 0; + border-bottom: 1px solid var(--line); +} + +.dimension-line:first-child, +.intelligence-row:first-child { + padding-top: 0; +} + +.dimension-line:last-child, +.intelligence-row:last-child { + border-bottom: none; +} + +.dimension-line-head, +.intelligence-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 16px; +} + +.dimension-line-head strong, +.intelligence-head strong { + font-size: 0.96rem; + font-weight: 600; +} + +.dimension-line p, +.intelligence-row p { + margin: 8px 0 0; + color: var(--muted); + font-size: 0.9rem; + line-height: 1.45; +} + +.intelligence-head { + justify-content: flex-start; +} + +.intelligence-date { + color: var(--muted-2); + font-family: var(--font-mono), monospace; + font-size: 0.76rem; + white-space: nowrap; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.portfolio-table-panel { + padding: 18px 18px 14px; +} + +.table-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 14px; +} + +.manage-menu-wrap, +.row-menu-wrap { + position: relative; +} + +.manage-menu-button, +.row-menu-button { + border: 1px solid var(--line); + border-radius: 999px; + background: #fff; + color: var(--text); + display: inline-flex; + align-items: center; + gap: 8px; + cursor: pointer; +} + +.manage-menu-button:hover, +.row-menu-button:hover { + background: var(--surface-2); +} + +.manage-menu-button { + padding: 8px 12px; + font-size: 0.88rem; +} + +.row-menu-button { + width: 32px; + height: 32px; + justify-content: center; +} + +.manage-menu, +.row-menu { + position: absolute; + right: 0; + top: calc(100% + 8px); + min-width: 180px; + padding: 6px; + border: 1px solid var(--line); + border-radius: 14px; + background: #fff; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.06); + z-index: 10; +} + +.manage-menu button, +.row-menu button { + width: 100%; + border: none; + border-radius: 10px; + background: transparent; + color: var(--text); + display: flex; + align-items: center; + gap: 8px; + padding: 10px 10px; + cursor: pointer; +} + +.manage-menu button:hover, +.row-menu button:hover { + background: var(--surface-2); +} + +.vendor-form { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + padding: 14px; + margin-bottom: 14px; + border: 1px solid var(--line); + border-radius: 16px; + background: #fcfbf8; +} + +.vendor-form input, +.vendor-form select { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 10px; + background: #fff; + color: var(--text); +} + +.vendor-form-actions { + display: flex; + gap: 8px; + grid-column: 1 / -1; +} + +.vendor-form-actions button { + border: none; + border-radius: 999px; + padding: 9px 14px; + background: var(--text); + color: #fff; + cursor: pointer; +} + +.vendor-form-actions .secondary { + background: #efebe4; + color: var(--text); +} + +.portfolio-table { + display: grid; + gap: 4px; +} + +.portfolio-table-head, +.portfolio-table-row { + display: grid; + grid-template-columns: minmax(220px, 1.35fr) minmax(110px, 0.7fr) minmax(120px, 0.8fr) minmax(100px, 0.65fr) minmax(110px, 0.7fr) minmax(72px, 0.34fr) minmax(80px, 0.36fr) 48px; + gap: 10px; + align-items: center; +} + +.portfolio-table-head { + padding: 0 8px 8px; + color: var(--muted-2); + font-family: var(--font-mono), monospace; + font-size: 0.68rem; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.portfolio-table-row { + padding: 10px 8px; + border-top: 1px solid var(--line); + cursor: pointer; + transition: background 160ms ease, color 160ms ease; +} + +.portfolio-table-row:nth-child(even) { + background: #ffffff; +} + +.portfolio-table-row:nth-child(odd) { + background: #faf8f4; +} + +.portfolio-table-row:hover { + background: #f3efea; +} + +.portfolio-vendor-cell { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; +} + +.portfolio-vendor-cell strong { + font-size: 0.96rem; + font-weight: 600; +} + +.portfolio-vendor-cell small { + color: var(--muted); + font-size: 0.82rem; +} + +.portfolio-score { + font-family: var(--font-serif), serif; + font-size: 1.3rem; + line-height: 1; +} + +.portfolio-helper { + margin-top: 12px; + color: var(--muted); + font-size: 0.82rem; +} + +.portfolio-helper code { + font-family: var(--font-mono), monospace; + font-size: 0.78rem; +} + +a:focus-visible, +button:focus-visible { + outline: 2px solid var(--text); + outline-offset: 3px; +} + +@media (max-width: 1320px) { + .topbar, + .summary-band, + .bottom-grid, + .priority-list { + grid-template-columns: 1fr; + } + + .summary-metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .page-header, + .operations-layout, + .vendor-overview-grid, + .vendor-analysis-grid, + .vendor-form { + grid-template-columns: 1fr; + } +} + +@media (max-width: 900px) { + .dashboard-shell { + width: min(100vw - 24px, 1540px); + padding-top: 28px; + } + + .topbar-meta, + .detail-kpis, + .summary-metrics { + grid-template-columns: 1fr; + } + + .app-header-bar, + .section-heading { + flex-direction: column; + align-items: flex-start; + } + + .page-header-aside { + justify-content: flex-start; + } + + .matrix-head, + .matrix-row, + .roster-head, + .roster-row { + grid-template-columns: 1fr; + } + + .matrix-head span:not(:first-child), + .roster-head span:not(:first-child), + .watchlist-head span:not(:first-child), + .attention-head span:not(:first-child) { + display: none; + } + + .watchlist-head, + .watchlist-row, + .attention-head, + .attention-row, + .feed-share-grid, + .portfolio-table-head, + .portfolio-table-row { + grid-template-columns: 1fr; + } + + .portfolio-table-head span:not(:first-child) { + display: none; + } + + .table-toolbar { + flex-direction: column; + align-items: flex-start; + } + + .matrix-action, + .risk-cell { + justify-content: flex-start; + } + + .queue-item { + grid-template-columns: 1fr; + } + + .queue-item-meta { + align-items: flex-start; + text-align: left; + } + + .detail-modal { + width: 100%; + height: 100%; + border-radius: 0; + } + + .detail-modal-backdrop { + padding: 0; + } +} + +@media (max-width: 640px) { + .panel, + .detail-modal { + padding: 18px; + } +} + +/* ────────────────────────────────────────────────────────────────────── + Onboarding & auth UI added in the SaaS conversion + ────────────────────────────────────────────────────────────────────── */ + +.signin-shell { + min-height: 100vh; + display: grid; + place-items: center; + background: + radial-gradient(circle at 20% 0%, rgba(255, 220, 180, 0.35) 0%, transparent 40%), + radial-gradient(circle at 80% 100%, rgba(180, 200, 255, 0.32) 0%, transparent 45%), + var(--bg); + padding: 48px 24px; +} + +.signin-card { + width: min(520px, 100%); + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 40px 36px; + box-shadow: 0 24px 60px -32px rgba(23, 22, 20, 0.18); +} + +.signin-eyebrow { + font-family: var(--font-mono), monospace; + font-size: 0.7rem; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--muted-2); + margin-bottom: 16px; +} + +.signin-title { + font-family: var(--font-serif), serif; + font-weight: 500; + font-size: 2rem; + line-height: 1.2; + margin: 0 0 16px; +} + +.signin-copy { + margin: 0 0 24px; + color: var(--muted); + line-height: 1.55; +} + +.signin-error { + border: 1px solid color-mix(in srgb, var(--critical) 30%, transparent); + background: color-mix(in srgb, var(--critical) 8%, transparent); + color: var(--critical); + padding: 12px 14px; + border-radius: 12px; + margin-bottom: 20px; + font-size: 0.92rem; +} + +.signin-cta { + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + padding: 14px 18px; + background: var(--text); + color: var(--bg); + border-radius: 14px; + font-weight: 600; + letter-spacing: 0.01em; + transition: transform 0.18s ease, box-shadow 0.18s ease; +} + +.signin-cta:hover { + transform: translateY(-1px); + box-shadow: 0 14px 28px -16px rgba(23, 22, 20, 0.4); +} + +.signin-fineprint { + margin: 24px 0 0; + font-size: 0.85rem; + color: var(--muted-2); + line-height: 1.5; +} + +.signin-fineprint a { + color: var(--text); + border-bottom: 1px solid var(--line-strong); +} + +/* ── Onboarding shell (used by /onboarding/*) ────────────────────────── */ + +.onboarding-shell { + width: min(880px, calc(100vw - 48px)); + margin: 0 auto; + padding: 56px 0 80px; +} + +.onboarding-header { + margin-bottom: 32px; +} + +.onboarding-step-badge { + display: inline-flex; + align-items: center; + gap: 10px; + font-family: var(--font-mono), monospace; + font-size: 0.74rem; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--muted-2); + margin-bottom: 12px; +} + +.onboarding-step-progress { + display: inline-flex; + gap: 6px; +} + +.onboarding-step-dot { + width: 22px; + height: 4px; + background: var(--line); + border-radius: 2px; +} + +.onboarding-step-dot.active { + background: var(--text); +} + +.onboarding-title { + font-family: var(--font-serif), serif; + font-weight: 500; + font-size: 2.4rem; + line-height: 1.18; + margin: 0 0 12px; +} + +.onboarding-subtitle { + margin: 0 0 24px; + color: var(--muted); + line-height: 1.55; + max-width: 60ch; +} + +.onboarding-card { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 32px; + display: grid; + gap: 18px; +} + +.onboarding-input-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} + +.onboarding-input-row > label { + display: grid; + gap: 8px; +} + +.onboarding-input-row input, +.onboarding-card textarea, +.onboarding-card input[type="text"], +.onboarding-card input[type="email"] { + border: 1px solid var(--line); + border-radius: 12px; + padding: 12px 14px; + background: var(--surface-2); + outline: none; + transition: border-color 0.18s ease, background 0.18s ease; +} + +.onboarding-input-row input:focus, +.onboarding-card textarea:focus, +.onboarding-card input:focus { + border-color: var(--text); + background: var(--surface); +} + +.onboarding-input-row label small { + color: var(--muted-2); + font-size: 0.78rem; + font-family: var(--font-mono), monospace; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.onboarding-cta { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 12px 22px; + background: var(--text); + color: var(--bg); + border: none; + border-radius: 12px; + font-weight: 600; + cursor: pointer; + transition: transform 0.18s ease, box-shadow 0.18s ease, background 0.18s ease; +} + +.onboarding-cta:hover { + transform: translateY(-1px); +} + +.onboarding-cta:disabled { + opacity: 0.55; + cursor: not-allowed; + transform: none; +} + +.onboarding-cta.secondary { + background: var(--surface); + color: var(--text); + border: 1px solid var(--line-strong); +} + +.onboarding-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + margin-top: 8px; +} + +.onboarding-mode-tabs { + display: inline-flex; + background: var(--surface-2); + border: 1px solid var(--line); + border-radius: 12px; + padding: 4px; + gap: 2px; +} + +.onboarding-mode-tab { + border: none; + background: transparent; + padding: 8px 14px; + border-radius: 10px; + font-size: 0.92rem; + cursor: pointer; + color: var(--muted); +} + +.onboarding-mode-tab.active { + background: var(--surface); + color: var(--text); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); +} + +.onboarding-textarea { + min-height: 180px; + font-family: var(--font-mono), monospace; + font-size: 0.9rem; + line-height: 1.5; +} + +.onboarding-vendor-list { + display: grid; + gap: 8px; + margin-top: 12px; +} + +.onboarding-vendor-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + background: var(--surface-2); + border-radius: 10px; + font-size: 0.95rem; +} + +.onboarding-vendor-row strong { + font-weight: 500; +} + +.onboarding-vendor-row small { + color: var(--muted-2); + margin-left: 12px; +} + +.onboarding-vendor-row .delete { + border: none; + background: transparent; + color: var(--muted-2); + cursor: pointer; + padding: 4px 8px; + border-radius: 8px; +} + +.onboarding-vendor-row .delete:hover { + background: var(--bg); + color: var(--critical); +} + +.onboarding-helper { + color: var(--muted-2); + font-size: 0.85rem; + line-height: 1.5; +} + +.onboarding-error { + border: 1px solid color-mix(in srgb, var(--critical) 30%, transparent); + background: color-mix(in srgb, var(--critical) 8%, transparent); + color: var(--critical); + padding: 10px 12px; + border-radius: 10px; + font-size: 0.9rem; +} + +/* Research progress UI */ +.research-progress { + display: grid; + gap: 12px; +} + +.research-progress-bar { + width: 100%; + height: 8px; + background: var(--surface-2); + border-radius: 999px; + overflow: hidden; + border: 1px solid var(--line); +} + +.research-progress-bar > span { + display: block; + height: 100%; + background: var(--text); + transition: width 0.4s ease; +} + +.research-progress-stats { + display: flex; + justify-content: space-between; + color: var(--muted); + font-size: 0.92rem; + font-family: var(--font-mono), monospace; +} + +.research-progress-tip { + color: var(--muted-2); + font-size: 0.86rem; + line-height: 1.5; +} + +/* ── Account menu in dashboard header ────────────────────────────────── */ + +.app-header-account { + margin-left: auto; +} + +.account-menu-wrap { + position: relative; +} + +.account-menu-button { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: transparent; + border: 1px solid var(--line); + border-radius: 10px; + color: var(--text); + cursor: pointer; + font-size: 0.9rem; + transition: border-color 0.18s ease, background 0.18s ease; +} + +.account-menu-button:hover { + background: var(--surface-2); +} + +.account-menu { + position: absolute; + top: calc(100% + 6px); + right: 0; + min-width: 220px; + background: var(--surface); + border: 1px solid var(--line); + border-radius: 12px; + box-shadow: 0 12px 28px -16px rgba(23, 22, 20, 0.25); + padding: 6px; + display: grid; + gap: 2px; + z-index: 30; +} + +.account-menu-meta { + padding: 10px 12px 6px; + font-size: 0.8rem; + color: var(--muted-2); + border-bottom: 1px solid var(--line); + margin-bottom: 4px; + word-break: break-all; +} + +.account-menu button { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border: none; + background: transparent; + border-radius: 8px; + cursor: pointer; + text-align: left; + font-size: 0.92rem; + color: var(--text); +} + +.account-menu button:hover { + background: var(--surface-2); +} + +/* ── Empty states ────────────────────────────────────────────────────── */ + +.empty-state-panel { + display: grid; + place-items: center; +} + +.empty-state { + display: grid; + gap: 8px; + padding: 32px 24px; + text-align: center; + color: var(--muted); + max-width: 520px; +} + +.empty-state strong { + font-family: var(--font-serif), serif; + font-weight: 500; + font-size: 1.2rem; + color: var(--text); +} + +.empty-state p { + margin: 0; + line-height: 1.55; +} + +.empty-state a { + margin-top: 8px; +} + +/* ── Vendor detail action buttons ────────────────────────────────────── */ + +.vendor-actions { + display: flex; + flex-direction: column; + gap: 10px; + align-items: flex-end; +} + +.vendor-action-button { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + background: var(--text); + color: var(--bg); + border: none; + border-radius: 10px; + cursor: pointer; + font-size: 0.9rem; + transition: transform 0.18s ease; +} + +.vendor-action-button.secondary { + background: var(--surface); + color: var(--text); + border: 1px solid var(--line-strong); +} + +.vendor-action-button:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.vendor-action-error { + font-size: 0.82rem; + color: var(--critical); + text-align: right; + max-width: 280px; +} + +/* Pending risk signal style (used when no completed assessment yet). */ +.risk-signal.pending { + color: var(--muted-2); +} +.risk-signal.pending .risk-signal-dot { + background: var(--line-strong); +} + +/* Portfolio table inline error */ +.portfolio-error { + margin: 0 0 12px; + border: 1px solid color-mix(in srgb, var(--critical) 30%, transparent); + background: color-mix(in srgb, var(--critical) 8%, transparent); + color: var(--critical); + padding: 10px 12px; + border-radius: 10px; + font-size: 0.9rem; +} + +/* Observe banner */ +.observe-banner { + margin-bottom: 16px; + padding: 10px 14px; + background: var(--surface-2); + border: 1px dashed var(--line-strong); + border-radius: 10px; + color: var(--muted); + font-size: 0.88rem; +} + +/* ── Sign-in paste-key form ─────────────────────────────────────────── */ + +.signin-form { + display: grid; + gap: 14px; + margin: 18px 0 8px; +} + +.signin-field { + display: grid; + gap: 6px; +} + +.signin-field span { + font-size: 0.82rem; + color: var(--muted); + font-weight: 500; +} + +.signin-field input { + padding: 11px 14px; + border: 1px solid var(--line-strong); + border-radius: 10px; + background: var(--bg); + color: var(--text); + font-size: 0.95rem; + font-family: inherit; + transition: border-color 0.15s ease; +} + +.signin-field input:focus { + outline: none; + border-color: var(--text); +} + +.signin-field input:disabled { + opacity: 0.6; +} + +button.signin-cta { + border: none; + cursor: pointer; + font: inherit; +} + +button.signin-cta:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +/* ── Settings shell + integrations cards ────────────────────────────── */ + +.settings-shell { + max-width: 880px; + margin: 48px auto; + padding: 0 24px 64px; +} + +.settings-back { + font-size: 0.85rem; + color: var(--muted); + text-decoration: none; + margin-bottom: 12px; + display: inline-block; +} + +.settings-back:hover { + color: var(--text); +} + +.settings-header h1 { + font-family: var(--font-serif), serif; + font-weight: 500; + font-size: 2rem; + margin: 4px 0 8px; +} + +.settings-header p { + margin: 0 0 24px; + color: var(--muted); + line-height: 1.6; + max-width: 720px; +} + +.settings-keys { + display: grid; + gap: 24px; +} + +.integration-card { + background: var(--surface); + border: 1px solid var(--line); + border-radius: 16px; + padding: 24px; +} + +.integration-card header { + margin-bottom: 16px; +} + +.integration-card-title { + display: flex; + align-items: center; + gap: 10px; +} + +.integration-card-title h2 { + margin: 0; + font-family: var(--font-serif), serif; + font-weight: 500; + font-size: 1.25rem; +} + +.integration-card header p { + margin: 8px 0 0; + color: var(--muted); + font-size: 0.9rem; + line-height: 1.5; +} + +.badge-on, +.badge-off, +.badge-default { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 3px 8px; + border-radius: 999px; +} + +.badge-on { + background: color-mix(in srgb, var(--ok, #10b981) 14%, transparent); + color: var(--ok, #10b981); +} + +.badge-off { + background: var(--surface-2); + color: var(--muted); +} + +.badge-default { + margin-left: 8px; + background: var(--surface-2); + color: var(--muted); +} + +.integration-list { + list-style: none; + padding: 0; + margin: 0 0 18px; + display: grid; + gap: 12px; +} + +.integration-list li { + display: grid; + grid-template-columns: 1fr auto; + gap: 12px; + align-items: center; + padding: 14px 16px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--bg); +} + +.integration-row-main { + display: flex; + gap: 10px; + align-items: flex-start; + min-width: 0; +} + +.integration-label { + font-weight: 500; + font-size: 0.95rem; + display: flex; + align-items: center; + gap: 8px; +} + +.integration-meta { + font-size: 0.8rem; + color: var(--muted); + margin-top: 2px; +} + +.integration-meta code { + font-family: var(--font-mono, ui-monospace), monospace; + background: var(--surface-2); + padding: 1px 5px; + border-radius: 4px; +} + +.integration-test { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.78rem; + margin-top: 4px; +} + +.integration-test.ok { + color: var(--ok, #10b981); +} + +.integration-test.fail { + color: var(--critical); +} + +.integration-row-actions { + display: flex; + gap: 6px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.integration-row-actions button { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 6px 10px; + font-size: 0.8rem; + border: 1px solid var(--line-strong); + background: var(--surface); + color: var(--text); + border-radius: 8px; + cursor: pointer; +} + +.integration-row-actions button:hover:not(:disabled) { + background: var(--surface-2); +} + +.integration-row-actions button:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.integration-row-actions button.danger { + border-color: color-mix(in srgb, var(--critical) 30%, transparent); + color: var(--critical); +} + +.integration-add { + display: grid; + gap: 12px; + padding: 16px; + border: 1px dashed var(--line-strong); + border-radius: 12px; + background: var(--surface-2); +} + +.integration-add label { + display: grid; + gap: 6px; + font-size: 0.82rem; + color: var(--muted); +} + +.integration-add input { + padding: 9px 12px; + border: 1px solid var(--line-strong); + border-radius: 8px; + background: var(--bg); + color: var(--text); + font: inherit; + font-size: 0.9rem; +} + +.integration-add input:focus { + outline: none; + border-color: var(--text); +} + +.integration-add button[type="submit"] { + justify-self: start; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 9px 14px; + background: var(--text); + color: var(--bg); + border: none; + border-radius: 10px; + cursor: pointer; + font-size: 0.9rem; +} + +.integration-add button[type="submit"]:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +/* AccountMenu link styling for the new Settings link */ +.account-menu a { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + text-decoration: none; + color: var(--text); + font-size: 0.9rem; +} + +.account-menu a:hover { + background: var(--surface-2); +} diff --git a/typescript-recipes/parallel-procurement-n8n/dashboard/app/layout.tsx b/typescript-recipes/parallel-procurement-n8n/dashboard/app/layout.tsx new file mode 100644 index 0000000..6d3749c --- /dev/null +++ b/typescript-recipes/parallel-procurement-n8n/dashboard/app/layout.tsx @@ -0,0 +1,37 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; +import { IBM_Plex_Mono, Manrope, Newsreader } from "next/font/google"; +import "./globals.css"; + +const sans = Manrope({ + subsets: ["latin"], + variable: "--font-sans", +}); + +const serif = Newsreader({ + subsets: ["latin"], + variable: "--font-serif", +}); + +const mono = IBM_Plex_Mono({ + subsets: ["latin"], + variable: "--font-mono", + weight: ["400", "500", "600"], +}); + +export const metadata: Metadata = { + title: "Parallel Procurement Dashboard", + description: "Production-grade vendor risk dashboard for the n8n procurement workflow.", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: ReactNode; +}>) { + return ( + + {children} + + ); +} diff --git a/typescript-recipes/parallel-procurement-n8n/dashboard/app/observe/page.tsx b/typescript-recipes/parallel-procurement-n8n/dashboard/app/observe/page.tsx new file mode 100644 index 0000000..b1b10ae --- /dev/null +++ b/typescript-recipes/parallel-procurement-n8n/dashboard/app/observe/page.tsx @@ -0,0 +1,28 @@ +import { redirect } from "next/navigation"; +import { DashboardShell } from "@/components/dashboard-ui"; +import { ObserveWorkspace } from "@/components/observe/ObserveWorkspace"; +import { requireAccount } from "@/lib/server/account"; +import { getDashboardSnapshot } from "@/lib/server/dashboard-queries"; + +export const dynamic = "force-dynamic"; + +export default async function ObservePage() { + const account = await requireAccount(); + if (!account.onboarded_at) redirect("/onboarding/profile"); + const data = await getDashboardSnapshot(account.id); + + return ( + +
+ Observe is a preview surface — populated from your real task graph in a later release. +
+ +
+ ); +} diff --git a/typescript-recipes/parallel-procurement-n8n/dashboard/app/onboarding/profile/ProfileForm.tsx b/typescript-recipes/parallel-procurement-n8n/dashboard/app/onboarding/profile/ProfileForm.tsx new file mode 100644 index 0000000..19460f3 --- /dev/null +++ b/typescript-recipes/parallel-procurement-n8n/dashboard/app/onboarding/profile/ProfileForm.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { useState, type FormEvent } from "react"; +import { useRouter } from "next/navigation"; + +export function ProfileForm({ + initial, +}: { + initial: { displayName: string; email: string }; +}) { + const router = useRouter(); + const [displayName, setDisplayName] = useState(initial.displayName); + const [email, setEmail] = useState(initial.email); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + async function onSubmit(event: FormEvent) { + event.preventDefault(); + setBusy(true); + setError(null); + try { + const res = await fetch("/api/onboarding/profile", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ displayName, email }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body?.error ?? `Failed (${res.status})`); + } + router.push("/onboarding/vendors"); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + setBusy(false); + } + } + + return ( +
+
+ + +
+ + {error ?
{error}
: null} + +
+

+ You can change these later in your account menu. +

+ +
+
+ ); +} diff --git a/typescript-recipes/parallel-procurement-n8n/dashboard/app/onboarding/profile/page.tsx b/typescript-recipes/parallel-procurement-n8n/dashboard/app/onboarding/profile/page.tsx new file mode 100644 index 0000000..6bea70e --- /dev/null +++ b/typescript-recipes/parallel-procurement-n8n/dashboard/app/onboarding/profile/page.tsx @@ -0,0 +1,26 @@ +import { redirect } from "next/navigation"; +import { OnboardingShell } from "@/components/OnboardingShell"; +import { ProfileForm } from "./ProfileForm"; +import { requireAccount } from "@/lib/server/account"; + +export const dynamic = "force-dynamic"; + +export default async function OnboardingProfilePage() { + const account = await requireAccount(); + if (account.onboarded_at) redirect("/"); + + return ( + + + + ); +} diff --git a/typescript-recipes/parallel-procurement-n8n/dashboard/app/onboarding/research/ResearchKickoff.tsx b/typescript-recipes/parallel-procurement-n8n/dashboard/app/onboarding/research/ResearchKickoff.tsx new file mode 100644 index 0000000..da5deb2 --- /dev/null +++ b/typescript-recipes/parallel-procurement-n8n/dashboard/app/onboarding/research/ResearchKickoff.tsx @@ -0,0 +1,197 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import type { MonitoringPriority } from "@/lib/types/dashboard"; + +interface VendorSummary { + id: string; + vendor_name: string; + monitoring_priority: MonitoringPriority; +} + +interface Progress { + total: number; + completed: number; + failed: number; + isActive: boolean; +} + +const MONITORS_PER_PRIORITY: Record = { + high: 5, + medium: 3, + low: 2, +}; + +export function ResearchKickoff({ vendors }: { vendors: VendorSummary[] }) { + const router = useRouter(); + const [taskGroupId, setTaskGroupId] = useState(null); + const [progress, setProgress] = useState(null); + const [phase, setPhase] = useState< + "idle" | "starting" | "researching" | "deploying" | "complete" | "error" + >("idle"); + const [error, setError] = useState(null); + const pollRef = useRef | null>(null); + + const monitorTotal = vendors.reduce( + (sum, v) => sum + (MONITORS_PER_PRIORITY[v.monitoring_priority] ?? 3), + 0, + ); + + useEffect(() => { + return () => { + if (pollRef.current) clearInterval(pollRef.current); + }; + }, []); + + async function startResearch() { + setPhase("starting"); + setError(null); + try { + const res = await fetch("/api/research/run", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body?.error ?? `Failed (${res.status})`); + } + const json = (await res.json()) as { taskGroupId: string; total: number }; + setTaskGroupId(json.taskGroupId); + setProgress({ + total: json.total, + completed: 0, + failed: 0, + isActive: true, + }); + setPhase("researching"); + pollRef.current = setInterval(() => poll(json.taskGroupId), 5000); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + setPhase("error"); + } + } + + async function poll(groupId: string) { + try { + const res = await fetch(`/api/research/groups/${groupId}`, { + cache: "no-store", + }); + if (!res.ok) return; + const json = (await res.json()) as Progress & { status: string }; + setProgress({ + total: json.total, + completed: json.completed, + failed: json.failed, + isActive: json.isActive, + }); + if (!json.isActive) { + if (pollRef.current) clearInterval(pollRef.current); + await completeOnboarding(); + } + } catch (err) { + console.error("[onboarding/research] poll failed", err); + } + } + + async function completeOnboarding() { + setPhase("deploying"); + try { + const res = await fetch("/api/onboarding/complete", { method: "POST" }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body?.error ?? `Failed (${res.status})`); + } + setPhase("complete"); + setTimeout(() => router.push("/"), 1200); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + setPhase("error"); + } + } + + function skipToDashboard() { + router.push("/"); + } + + const completed = progress?.completed ?? 0; + const total = progress?.total ?? vendors.length; + const failed = progress?.failed ?? 0; + const percent = total > 0 ? Math.round(((completed + failed) / total) * 100) : 0; + + return ( +
+
+
+ + {completed} / {total} vendors researched + + {failed > 0 ? {failed} failed : null} + {percent}% +
+
+ +
+

+ We will deploy {monitorTotal} continuous monitors after research completes, + using a high/medium/low portfolio matched to each vendor's priority. +

+
+ + {phase === "idle" ? ( +
+ + Back + + +
+ ) : null} + + {phase === "starting" ? ( +
Submitting task group to Parallel…
+ ) : null} + + {phase === "researching" ? ( +
+

+ Research is running on the Parallel Task API. You can leave this page; + we'll continue in the background and finalize when results land. +

+
+ +
+
+ ) : null} + + {phase === "deploying" ? ( +
+ Deploying continuous monitors and finalizing your workspace… +
+ ) : null} + + {phase === "complete" ? ( +
All set. Loading your dashboard…
+ ) : null} + + {phase === "error" && error ? ( +
{error}
+ ) : null} + + {taskGroupId ? ( +
+ Task group {taskGroupId} + Tracking via /api/research/groups/{taskGroupId}. +
+ ) : null} +
+ ); +} diff --git a/typescript-recipes/parallel-procurement-n8n/dashboard/app/onboarding/research/page.tsx b/typescript-recipes/parallel-procurement-n8n/dashboard/app/onboarding/research/page.tsx new file mode 100644 index 0000000..c8b5ce9 --- /dev/null +++ b/typescript-recipes/parallel-procurement-n8n/dashboard/app/onboarding/research/page.tsx @@ -0,0 +1,31 @@ +import { redirect } from "next/navigation"; +import { OnboardingShell } from "@/components/OnboardingShell"; +import { ResearchKickoff } from "./ResearchKickoff"; +import { requireAccount } from "@/lib/server/account"; +import { listVendorsByAccount } from "@/lib/server/vendors"; + +export const dynamic = "force-dynamic"; + +export default async function OnboardingResearchPage() { + const account = await requireAccount(); + if (account.onboarded_at) redirect("/"); + + const vendors = await listVendorsByAccount(account.id); + if (vendors.length === 0) redirect("/onboarding/vendors"); + + return ( + + ({ + id: v.id, + vendor_name: v.vendor_name, + monitoring_priority: v.monitoring_priority, + }))} + /> + + ); +} diff --git a/typescript-recipes/parallel-procurement-n8n/dashboard/app/onboarding/vendors/VendorsForm.tsx b/typescript-recipes/parallel-procurement-n8n/dashboard/app/onboarding/vendors/VendorsForm.tsx new file mode 100644 index 0000000..6b4d182 --- /dev/null +++ b/typescript-recipes/parallel-procurement-n8n/dashboard/app/onboarding/vendors/VendorsForm.tsx @@ -0,0 +1,309 @@ +"use client"; + +import { useState, type FormEvent } from "react"; +import { useRouter } from "next/navigation"; +import { Trash2 } from "lucide-react"; +import type { MonitoringPriority } from "@/lib/types/dashboard"; + +interface VendorEntry { + id: string; + vendor_name: string; + vendor_domain: string; + vendor_category: string; + monitoring_priority: MonitoringPriority; +} + +type Mode = "manual" | "paste" | "upload"; + +const DEMO_SET = `Acme Corp,acme.com,technology,high +GlobalTech Solutions,globaltech.io,technology,high +FinServ Partners,finserv.com,financial_services,medium +BluePeak Logistics,bluepeaklogistics.com,professional_services,medium +Precision Manufacturing,precisionmfg.com,manufacturing,low`; + +export function VendorsForm({ initial }: { initial: VendorEntry[] }) { + const router = useRouter(); + const [vendors, setVendors] = useState(initial); + const [mode, setMode] = useState("manual"); + const [name, setName] = useState(""); + const [domain, setDomain] = useState(""); + const [category, setCategory] = useState("technology"); + const [priority, setPriority] = useState("medium"); + const [pasteText, setPasteText] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + async function refresh() { + const res = await fetch("/api/vendors", { cache: "no-store" }); + if (!res.ok) return; + const json = (await res.json()) as { vendors: VendorEntry[] }; + setVendors(json.vendors ?? []); + } + + async function addManual(event: FormEvent) { + event.preventDefault(); + setBusy(true); + setError(null); + try { + const res = await fetch("/api/vendors", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + vendorName: name, + vendorDomain: domain, + vendorCategory: category, + monitoringPriority: priority, + }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body?.error ?? `Failed (${res.status})`); + } + setName(""); + setDomain(""); + await refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + } + + async function importPaste() { + if (!pasteText.trim()) return; + setBusy(true); + setError(null); + try { + const res = await fetch("/api/vendors/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text: pasteText }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body?.error ?? `Failed (${res.status})`); + } + setPasteText(""); + await refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + } + + async function loadDemoSet() { + setPasteText(DEMO_SET); + setMode("paste"); + } + + async function uploadCsv(file: File) { + const text = await file.text(); + setBusy(true); + setError(null); + try { + const res = await fetch("/api/vendors/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body?.error ?? `Failed (${res.status})`); + } + await refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + } + + async function removeVendor(id: string) { + setBusy(true); + try { + await fetch(`/api/vendors/${id}`, { method: "DELETE" }); + setVendors((current) => current.filter((v) => v.id !== id)); + } finally { + setBusy(false); + } + } + + function continueToResearch() { + if (vendors.length === 0) { + setError("Add at least one vendor to continue."); + return; + } + router.push("/onboarding/research"); + } + + return ( +
+
+ + + +
+ + {mode === "manual" ? ( +
+ + + + +
+

+ You can edit the priority and other fields later from the Portfolio page. +

+ +
+
+ ) : null} + + {mode === "paste" ? ( +
+