diff --git a/README.md b/README.md index 937a058..1e5cde8 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 | – | +| [**n8n Vendor Risk Monitoring**](typescript-recipes/parallel-n8n-procurement) | Procurement workflow that researches vendors, deploys monitors, scores risk, routes Slack alerts, and logs an audit trail. | `Task` `Monitors` `Webhooks` | n8n · TypeScript · Google Sheets · Slack | – | ### Deep Research & Notebooks diff --git a/typescript-recipes/parallel-n8n-procurement/.env.example b/typescript-recipes/parallel-n8n-procurement/.env.example new file mode 100644 index 0000000..abbc530 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/.env.example @@ -0,0 +1,48 @@ +# 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 + +# Public base URL for your n8n instance, without a trailing slash +N8N_WEBHOOK_BASE_URL=https://your-workspace.app.n8n.cloud + +# Dashboard app snapshot endpoint +PROCUREMENT_DASHBOARD_SNAPSHOT_URL=https://your-workspace.app.n8n.cloud/webhook/procurement-dashboard-snapshot + +# Dashboard app mutation endpoint for portfolio add/upload/reset write-back +PROCUREMENT_DASHBOARD_MUTATION_URL=https://your-workspace.app.n8n.cloud/webhook/procurement-portfolio-mutation + +# Shared secret set in both the dashboard runtime and n8n variables +PROCUREMENT_DASHBOARD_WRITE_TOKEN=replace-with-a-long-random-secret + +# 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-n8n-procurement/.gitignore b/typescript-recipes/parallel-n8n-procurement/.gitignore new file mode 100644 index 0000000..b3adf45 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +.env +coverage/ +*.tsbuildinfo +.DS_Store diff --git a/typescript-recipes/parallel-n8n-procurement/README.md b/typescript-recipes/parallel-n8n-procurement/README.md new file mode 100644 index 0000000..591f4af --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/README.md @@ -0,0 +1,175 @@ +# Parallel n8n Procurement + +Vendor risk monitoring with Parallel Tasks, Parallel Monitors, n8n, Google Sheets, and Slack. + +This recipe turns a vendor spreadsheet into an automated procurement intelligence pipeline. It researches vendors on a schedule, deploys persistent monitors for breaking events, scores findings with deterministic rules, routes alerts to Slack, and writes an audit trail back to Google Sheets. + +## What It Demonstrates + +- Parallel Task API for scheduled vendor due diligence +- Parallel Monitor API for ongoing vendor event detection +- A single importable n8n workflow with no cross-workflow ID wiring +- Google Sheets as the vendor registry and audit log +- Slack alerts, digests, operations reports, and ad-hoc slash-command research +- A live Next.js dashboard for portfolio triage, add/upload/reset write-back, feeds, and Observe topology review +- TypeScript workflow generators and tests for repeatable n8n JSON output + +## Quick Start + +```bash +npm ci +npm run check +npm test +npm run generate:workflows +``` + +Import `n8n-workflows/workflow-combined.json` into n8n. Then point the dashboard app at the combined workflow's `procurement-dashboard-snapshot` and `procurement-portfolio-mutation` webhooks. See [SETUP.md](SETUP.md) for the full setup path. + +## How It Works + +```text +Google Sheets Vendors tab + | + v +Vendor Sync every 6h -----> Deploy vendor monitors + | | + v v +Deep Research daily 2 AM Monitor event webhooks + | | + +-----------> Risk Scoring <---+ + | + v + Slack routing + Audit Log + | + v + Dashboard snapshot webhook + +Dashboard portfolio add/upload/reset + | + v +Portfolio mutation webhook + | + v +Google Sheets Vendors + Registry tabs +``` + +The combined n8n workflow contains 56 nodes, 49 connections, 6 webhook triggers, 2 schedule triggers, and zero `executeWorkflow` or `executeWorkflowTrigger` nodes. That means it can be imported as one workflow without manually wiring workflow IDs after import. + +### Vendor Sync + +The workflow reads the `Vendors` and `Registry` tabs from Google Sheets, computes additions, removals, and priority changes, then creates or deletes monitor records as needed. + +### Deep Research + +Scheduled research builds vendor risk prompts and submits Parallel research tasks. Results are normalized, scored, routed, and logged. + +### Continuous Monitoring + +Each active vendor gets monitors based on priority. High-priority vendors get broader daily coverage; lower-priority vendors get a smaller set of monitor queries. Monitor events are enriched, deduplicated, scored, and routed through the same alerting path as scheduled research. + +### Ad-Hoc Research + +A Slack slash command can trigger a one-off vendor assessment. The workflow acknowledges the command immediately, starts a Parallel research task, then posts the scored result back to Slack when the callback arrives. + +### Dashboard + +The `dashboard/` directory contains the full Next.js procurement dashboard. It is live-data only: runtime pages require `PROCUREMENT_DASHBOARD_SNAPSHOT_URL`, which should point to the combined workflow's `procurement-dashboard-snapshot` webhook. Portfolio add/upload/reset requires `PROCUREMENT_DASHBOARD_MUTATION_URL` plus `PROCUREMENT_DASHBOARD_WRITE_TOKEN`; the dashboard sends the token to n8n as `x-procurement-dashboard-token`, and n8n validates it against its `PROCUREMENT_DASHBOARD_WRITE_TOKEN` variable before writing to Google Sheets. The dashboard includes overview, attention queue, portfolio manager, feed, Observe topology, and vendor detail pages. + +## Primary Workflow + +Use this file for normal deployments: + +| File | Purpose | +| --- | --- | +| `n8n-workflows/workflow-combined.json` | Canonical single-import workflow for n8n Cloud or self-hosted n8n | + +The individual workflow files are included as advanced references for teams that want to inspect or split the pipeline: + +| File | Purpose | +| --- | --- | +| `workflow1-vendor-sync.json` | Vendor registry diff and monitor lifecycle | +| `workflow2-deep-research.json` | Scheduled vendor research | +| `workflow3-risk-scoring.json` | Shared scoring and routing logic | +| `workflow4-monitors.json` | Monitor deployment and event handling | +| `workflow5-adhoc.json` | Slack slash-command research | + +## Required Inputs + +Set the n8n variables and dashboard runtime env vars as applicable: + +| Variable | Description | +| --- | --- | +| `PARALLEL_API_KEY` | Parallel API key | +| `GOOGLE_SHEET_ID` | Google Sheet ID that contains the vendor registry tabs | +| `SLACK_WEBHOOK_URL` | Slack incoming webhook URL | +| `N8N_WEBHOOK_BASE_URL` | Public base URL for the n8n instance | +| `PROCUREMENT_DASHBOARD_SNAPSHOT_URL` | Dashboard runtime URL for the n8n `procurement-dashboard-snapshot` webhook | +| `PROCUREMENT_DASHBOARD_MUTATION_URL` | Dashboard runtime URL for the n8n `procurement-portfolio-mutation` webhook | +| `PROCUREMENT_DASHBOARD_WRITE_TOKEN` | Shared secret set in both dashboard runtime and n8n variables for portfolio write-back | + +Optional settings are documented in [.env.example](.env.example). + +## Google Sheets Tabs + +Import the CSV files in `templates/` as these tabs: + +| CSV | Tab name | +| --- | --- | +| `vendors-tab.csv` | `Vendors` | +| `registry-tab.csv` | `Registry` | +| `audit-log-tab.csv` | `Audit Log` | +| `monitors-tab.csv` | `Monitors` | + +The seed vendor file includes 15 sample vendors so the pipeline can be tested immediately. + +## Project Structure + +```text +parallel-n8n-procurement/ + dashboard/ Live Next.js portfolio and Observe dashboard + n8n-workflows/ Importable n8n workflow JSON + templates/ Google Sheets CSV tab templates + src/ + config/ Environment validation + models/ TypeScript models and API shapes + services/ Risk scoring, research orchestration, Slack, monitors + workflows/ n8n workflow generators + tests/ Unit, workflow, integration, and scale tests + SETUP.md Step-by-step deployment guide + parallel_procurement.md + sample-setup.md +``` + +## Validation + +The recipe includes tests for the service layer, model validation, workflow generation, integration scenarios, and scale simulations. + +```bash +npm run check +npm test +npm run generate:workflows +``` + +Dashboard validation: + +```bash +cd dashboard +npm ci +npm run check +npm run build +npm run test:e2e +``` + +Expected current baseline: + +- `npm run check` passes with `tsc --noEmit` +- `npm test` passes with 566 tests +- `npm run generate:workflows` regenerates the committed workflow JSON files +- Dashboard `npm run check`, `npm run build`, and `npm run test:e2e` pass with mocked snapshot and mutation endpoints + +## Notes + +- The workflow JSON uses placeholder credentials and n8n variables. Do not commit real API keys, Slack tokens, webhook secrets, or Google credentials. +- `workflow-combined.json` is the supported import path. The split workflow files are for reference and advanced customization. +- The dashboard uses local mocked snapshot and mutation endpoints only in Playwright tests. Runtime app code has no mock fallback. +- Portfolio add/upload/reset writes to `Vendors` and `Registry`. Monitor creation and deletion still happen when the existing sync path runs. diff --git a/typescript-recipes/parallel-n8n-procurement/SETUP.md b/typescript-recipes/parallel-n8n-procurement/SETUP.md new file mode 100644 index 0000000..0fa7987 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/SETUP.md @@ -0,0 +1,210 @@ +# Parallel n8n Procurement Setup + +This guide gets the combined vendor-risk workflow running in n8n with Google Sheets and Slack. +It also covers the live Next.js dashboard included in `dashboard/`. + +## 1. Prerequisites + +You need: + +| Requirement | Purpose | +| --- | --- | +| Parallel API key | Runs research tasks and monitor operations | +| n8n Cloud or self-hosted n8n | Hosts the workflow | +| Google account | Stores vendor registry, monitor records, and audit logs | +| Slack workspace admin access | Creates alert channels and the slash command | +| Node.js 20+ | Validates and regenerates workflow JSON locally | + +## 2. Validate the Recipe Locally + +```bash +npm ci +npm run check +npm test +npm run generate:workflows +``` + +`npm run generate:workflows` rebuilds the JSON files in `n8n-workflows/` from the TypeScript workflow generators. + +## 3. Create the Google Sheet + +Create a spreadsheet named `Vendor Risk Registry`, then import each file in `templates/` 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` | + +Copy the spreadsheet ID from the URL: + +```text +https://docs.google.com/spreadsheets/d/SHEET_ID_HERE/edit +``` + +The `Vendors` tab includes sample vendors. Replace them with your own vendors or keep them for a first test run. + +## 4. Prepare Slack + +Create these channels: + +| Channel | Purpose | +| --- | --- | +| `#procurement-critical` | Critical and high-severity alerts | +| `#procurement-alerts` | Standard monitor and research notifications | +| `#procurement-digest` | Batched medium-severity findings | +| `#vendor-risk-ops` | Workflow health and run summaries | + +Create a Slack app with these bot scopes: + +- `chat:write` +- `chat:write.public` +- `commands` +- `incoming-webhook` + +Install the app to your workspace and keep the bot token. Add a slash command: + +| Field | Value | +| --- | --- | +| Command | `/vendor-research` | +| Request URL | `https://YOUR_N8N_HOST/webhook/slack-command` | +| Short description | `Run ad-hoc vendor research` | +| Usage hint | `[vendor name or domain]` | + +You can update the request URL after importing and activating the workflow if your n8n webhook URL differs. + +## 5. Import the n8n Workflow + +In n8n, import only this file for the normal setup: + +```text +n8n-workflows/workflow-combined.json +``` + +This is the canonical workflow. It has 56 nodes, 49 connections, and no `executeWorkflow` nodes, so there is no separate workflow-ID wiring step. + +The other workflow JSON files are included for advanced users who want to inspect or split the system. + +## 6. Configure Credentials and Variables + +In n8n, configure these credentials: + +| Credential | Used by | +| --- | --- | +| Google Sheets OAuth2 | All Google Sheets nodes | +| Slack API bot token | Slack message and slash-command response nodes | +| HTTP Header Auth, if requested by n8n | HTTP Request nodes that call Parallel APIs | + +Set these n8n variables: + +| Variable | Required | Example | +| --- | --- | --- | +| `PARALLEL_API_KEY` | Yes | `pws_...` | +| `GOOGLE_SHEET_ID` | Yes | `1abc...xyz` | +| `SLACK_WEBHOOK_URL` | Yes | `https://hooks.slack.com/services/...` | +| `N8N_WEBHOOK_BASE_URL` | Yes | `https://your-workspace.app.n8n.cloud` | +| `PROCUREMENT_DASHBOARD_WRITE_TOKEN` | Yes for dashboard writes | `replace-with-a-long-random-secret` | +| `SLACK_ALERT_TARGET` | No | `#procurement-critical` | + +Use `N8N_WEBHOOK_BASE_URL` without a trailing slash. The workflow builds callback URLs such as: + +```text +https://your-workspace.app.n8n.cloud/webhook/parallel-task-completion +``` + +If n8n prompts for an HTTP Header Auth credential, set the header name to `x-api-key` and the value to your Parallel API key. + +## 7. Activate and Test + +Run these tests in order: + +1. Execute `Sync: Manual Trigger`. +2. Confirm the `Registry` tab is populated from the `Vendors` tab. +3. Confirm monitor records are created in the `Monitors` tab. +4. Execute `Research: Manual Trigger`. +5. Confirm the `Audit Log` tab receives a scored assessment. +6. Confirm Slack receives alerts for HIGH or CRITICAL findings. +7. Activate the workflow. +8. In Slack, run `/vendor-research microsoft.com`. + +The scheduled triggers run vendor sync and research automatically after activation. + +## 8. Run the Dashboard + +The dashboard reads the combined workflow's `procurement-dashboard-snapshot` webhook and writes portfolio add/upload/reset actions through `procurement-portfolio-mutation`. It does not include a runtime mock fallback. + +Create `dashboard/.env.local`: + +```bash +PROCUREMENT_DASHBOARD_SNAPSHOT_URL=https://YOUR_N8N_HOST/webhook/procurement-dashboard-snapshot +PROCUREMENT_DASHBOARD_MUTATION_URL=https://YOUR_N8N_HOST/webhook/procurement-portfolio-mutation +PROCUREMENT_DASHBOARD_WRITE_TOKEN=replace-with-the-same-token-set-in-n8n +``` + +Then run: + +```bash +cd dashboard +npm ci +npm run check +npm run dev +``` + +Open the local Next.js URL printed by `npm run dev`. + +For production builds: + +```bash +npm run build +npm start +``` + +The dashboard includes: + +| Surface | Path | +| --- | --- | +| Overview | `/` | +| Attention queue | `/attention` | +| Portfolio manager | `/portfolio` | +| Monitor feed | `/feed` | +| Observe topology | `/observe` | +| Vendor detail | `/vendors/:vendorId` | + +Portfolio add, CSV upload, and reset are backend-backed. The dashboard calls its local `POST /api/portfolio/mutation` route, which proxies to n8n with the `x-procurement-dashboard-token` header. n8n validates the token, writes `Vendors` and `Registry`, and the dashboard refreshes from the live snapshot. Reset restores the shipped seed vendors and marks dashboard-added non-seed vendors inactive. Monitor creation and deletion still happen when the existing manual or scheduled sync path runs. + +## 9. Dashboard E2E Tests + +Playwright tests use local mocked snapshot and mutation endpoints for browser coverage and API contract checks. + +```bash +cd dashboard +npm run test:e2e +``` + +These tests do not require n8n Cloud. Before marking a PR ready, still smoke test the dashboard against the real imported workflow webhook. + +## 10. Troubleshooting + +| Symptom | Check | +| --- | --- | +| Google Sheets nodes fail | Confirm the imported tab names match exactly: `Vendors`, `Registry`, `Audit Log`, `Monitors` | +| Parallel calls fail | Confirm `PARALLEL_API_KEY` is set in n8n variables and any HTTP Header Auth credential | +| Slack messages fail | Confirm the Slack app is installed, the bot token credential is selected, and the bot is in the target channels | +| Slash command times out | Confirm the Slack request URL points to the active n8n webhook path | +| Task callbacks do not arrive | Confirm `N8N_WEBHOOK_BASE_URL` is public and has no trailing slash | +| Dashboard shows setup state | Confirm `PROCUREMENT_DASHBOARD_SNAPSHOT_URL` is set to the active n8n snapshot webhook | +| Portfolio writes fail | Confirm `PROCUREMENT_DASHBOARD_MUTATION_URL` points to the active n8n mutation webhook and both sides use the same `PROCUREMENT_DASHBOARD_WRITE_TOKEN` | +| Dashboard says the snapshot is invalid | Confirm the imported workflow JSON has the current `Snapshot: Build Payload` node | + +## 11. Keeping Workflow JSON in Sync + +When changing workflow generator code: + +```bash +npm run generate:workflows +npm run check +npm test +``` + +Commit both the TypeScript generator changes and the regenerated JSON files. diff --git a/typescript-recipes/parallel-n8n-procurement/dashboard/.gitignore b/typescript-recipes/parallel-n8n-procurement/dashboard/.gitignore new file mode 100644 index 0000000..31a23e2 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/dashboard/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.next/ +*.tsbuildinfo +test-results/ +playwright-report/ diff --git a/typescript-recipes/parallel-n8n-procurement/dashboard/app/api/portfolio/mutation/route.ts b/typescript-recipes/parallel-n8n-procurement/dashboard/app/api/portfolio/mutation/route.ts new file mode 100644 index 0000000..fbca252 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/dashboard/app/api/portfolio/mutation/route.ts @@ -0,0 +1,62 @@ +import { NextResponse } from "next/server"; +import { + DASHBOARD_MUTATION_URL_ENV, + DASHBOARD_WRITE_TOKEN_ENV, + DASHBOARD_WRITE_TOKEN_HEADER, + isPortfolioMutationRequest, + type PortfolioMutationResponse, +} from "@/lib/portfolio-mutations"; + +export const dynamic = "force-dynamic"; + +export async function POST(request: Request) { + const mutationUrl = process.env[DASHBOARD_MUTATION_URL_ENV]?.trim(); + const writeToken = process.env[DASHBOARD_WRITE_TOKEN_ENV]?.trim(); + + if (!mutationUrl || !writeToken) { + return NextResponse.json( + { + ok: false, + error: `Set ${DASHBOARD_MUTATION_URL_ENV} and ${DASHBOARD_WRITE_TOKEN_ENV} before using portfolio write-back.`, + }, + ); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ ok: false, error: "Request body must be JSON." }, { status: 400 }); + } + + if (!isPortfolioMutationRequest(body)) { + return NextResponse.json({ ok: false, error: "Invalid portfolio mutation payload." }, { status: 400 }); + } + + try { + const response = await fetch(mutationUrl, { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json", + [DASHBOARD_WRITE_TOKEN_HEADER]: writeToken, + }, + body: JSON.stringify(body), + cache: "no-store", + }); + + const responseBody = (await response.json().catch(() => ({ + ok: response.ok, + error: response.ok ? undefined : `n8n mutation webhook returned HTTP ${response.status}.`, + }))) as PortfolioMutationResponse; + + return NextResponse.json(responseBody, { status: response.ok ? response.status : 200 }); + } catch (error) { + return NextResponse.json( + { + ok: false, + error: error instanceof Error ? error.message : "Unable to reach the n8n mutation webhook.", + }, + ); + } +} diff --git a/typescript-recipes/parallel-n8n-procurement/dashboard/app/attention/page.tsx b/typescript-recipes/parallel-n8n-procurement/dashboard/app/attention/page.tsx new file mode 100644 index 0000000..1dce60c --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/dashboard/app/attention/page.tsx @@ -0,0 +1,29 @@ +import { AttentionQueuePage, DashboardShell, DashboardSetupState } from "@/components/dashboard-ui"; +import { loadDashboardData } from "@/lib/dashboard-data"; + +export default async function AttentionPage() { + const result = await loadDashboardData(); + + if (!result.ok) { + return ( + + + + ); + } + + return ( + + + + ); +} diff --git a/typescript-recipes/parallel-n8n-procurement/dashboard/app/feed/page.tsx b/typescript-recipes/parallel-n8n-procurement/dashboard/app/feed/page.tsx new file mode 100644 index 0000000..5f1af35 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/dashboard/app/feed/page.tsx @@ -0,0 +1,29 @@ +import { DashboardShell, DashboardSetupState, FeedPagePanels } from "@/components/dashboard-ui"; +import { loadDashboardData } from "@/lib/dashboard-data"; + +export default async function FeedPage() { + const result = await loadDashboardData(); + + if (!result.ok) { + return ( + + + + ); + } + + return ( + + + + ); +} diff --git a/typescript-recipes/parallel-n8n-procurement/dashboard/app/globals.css b/typescript-recipes/parallel-n8n-procurement/dashboard/app/globals.css new file mode 100644 index 0000000..5462386 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/dashboard/app/globals.css @@ -0,0 +1,1822 @@ +:root { + --font-sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-serif: Georgia, "Times New Roman", serif; + --font-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + --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); +} + +.page-breadcrumb-group { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.page-breadcrumb-item { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.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; +} + +.setup-state { + display: grid; + gap: 12px; +} + +.setup-state h2 { + margin: 0; + font-family: var(--font-serif), serif; + font-size: 1.55rem; + font-weight: 500; + letter-spacing: -0.03em; +} + +.setup-state p { + max-width: 72ch; + margin: 0; + color: var(--muted); + line-height: 1.6; +} + +.setup-state-code { + width: fit-content; + padding: 7px 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface-2); + font-family: var(--font-mono), monospace; + font-size: 0.78rem; +} + +.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 { + position: relative; +} + +.manage-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 { + background: var(--surface-2); +} + +.manage-menu-button { + padding: 8px 12px; + font-size: 0.88rem; +} + +.manage-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 { + 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 { + background: var(--surface-2); +} + +.manage-menu button:disabled, +.vendor-form-actions button:disabled { + cursor: not-allowed; + opacity: 0.52; +} + +.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-status { + margin-bottom: 12px; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 12px; + background: #f7f6f3; + color: var(--muted); + font-size: 0.88rem; + line-height: 1.45; +} + +.portfolio-status.error { + border-color: #efb9b9; + background: #fff5f5; + color: var(--critical); +} + +.portfolio-status.success { + border-color: #b9d7c6; + background: #f4fbf7; + color: var(--low); +} + +.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) minmax(68px, 0.34fr); + 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-sync-cell { + display: inline-flex; + width: fit-content; + align-items: center; + border: 1px solid var(--line); + border-radius: 999px; + padding: 5px 9px; + background: #fff; + color: var(--muted); + font-family: var(--font-mono), monospace; + font-size: 0.72rem; + text-transform: uppercase; +} + +.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; + } +} diff --git a/typescript-recipes/parallel-n8n-procurement/dashboard/app/layout.tsx b/typescript-recipes/parallel-n8n-procurement/dashboard/app/layout.tsx new file mode 100644 index 0000000..1a3d553 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/dashboard/app/layout.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "Parallel Procurement Dashboard", + description: "Production-grade vendor risk dashboard for the n8n procurement workflow.", +}; + +export const dynamic = "force-dynamic"; + +export default function RootLayout({ + children, +}: Readonly<{ + children: ReactNode; +}>) { + return ( + + {children} + + ); +} diff --git a/typescript-recipes/parallel-n8n-procurement/dashboard/app/observe/page.tsx b/typescript-recipes/parallel-n8n-procurement/dashboard/app/observe/page.tsx new file mode 100644 index 0000000..5f28eff --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/dashboard/app/observe/page.tsx @@ -0,0 +1,30 @@ +import { DashboardShell, DashboardSetupState } from "@/components/dashboard-ui"; +import { ObserveWorkspace } from "@/components/observe/ObserveWorkspace"; +import { loadDashboardData } from "@/lib/dashboard-data"; + +export default async function ObservePage() { + const result = await loadDashboardData(); + + if (!result.ok) { + return ( + + + + ); + } + + return ( + + + + ); +} diff --git a/typescript-recipes/parallel-n8n-procurement/dashboard/app/operations/page.tsx b/typescript-recipes/parallel-n8n-procurement/dashboard/app/operations/page.tsx new file mode 100644 index 0000000..f33d05f --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/dashboard/app/operations/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function OperationsPage() { + redirect("/feed"); +} diff --git a/typescript-recipes/parallel-n8n-procurement/dashboard/app/page.tsx b/typescript-recipes/parallel-n8n-procurement/dashboard/app/page.tsx new file mode 100644 index 0000000..f4b7c1d --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/dashboard/app/page.tsx @@ -0,0 +1,41 @@ +import { + ActionCard, + DashboardShell, + DashboardSetupState, + ImmediateAttentionPreview, + MetricsBand, + OverviewBottomGrid, + WatchlistTable, +} from "@/components/dashboard-ui"; +import { loadDashboardData } from "@/lib/dashboard-data"; + +export default async function HomePage() { + const result = await loadDashboardData(); + + if (!result.ok) { + return ( + + + + ); + } + + return ( + } + > + + + + + + ); +} diff --git a/typescript-recipes/parallel-n8n-procurement/dashboard/app/portfolio/page.tsx b/typescript-recipes/parallel-n8n-procurement/dashboard/app/portfolio/page.tsx new file mode 100644 index 0000000..3d58dc7 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/dashboard/app/portfolio/page.tsx @@ -0,0 +1,30 @@ +import { DashboardShell, DashboardSetupState } from "@/components/dashboard-ui"; +import { PortfolioTableManager } from "@/components/PortfolioTableManager"; +import { loadDashboardData } from "@/lib/dashboard-data"; + +export default async function PortfolioPage() { + const result = await loadDashboardData(); + + if (!result.ok) { + return ( + + + + ); + } + + return ( + + + + ); +} diff --git a/typescript-recipes/parallel-n8n-procurement/dashboard/app/vendors/[vendorId]/page.tsx b/typescript-recipes/parallel-n8n-procurement/dashboard/app/vendors/[vendorId]/page.tsx new file mode 100644 index 0000000..da012a3 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/dashboard/app/vendors/[vendorId]/page.tsx @@ -0,0 +1,44 @@ +import { notFound } from "next/navigation"; +import { DashboardShell, DashboardSetupState, VendorDetailPage } from "@/components/dashboard-ui"; +import { loadDashboardData } from "@/lib/dashboard-data"; + +export default async function VendorPage({ params }: { params: Promise<{ vendorId: string }> }) { + const { vendorId } = await params; + const result = await loadDashboardData(); + + if (!result.ok) { + return ( + + + + ); + } + + const vendor = result.data.vendors.find((entry) => entry.id === vendorId); + + if (!vendor) { + notFound(); + } + + return ( + + + + ); +} diff --git a/typescript-recipes/parallel-n8n-procurement/dashboard/components/DashboardDataProvider.tsx b/typescript-recipes/parallel-n8n-procurement/dashboard/components/DashboardDataProvider.tsx new file mode 100644 index 0000000..0abf9bc --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/dashboard/components/DashboardDataProvider.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { createContext, useContext, type ReactNode } from "react"; +import type { DashboardData } from "@/lib/dashboard-types"; + +const DashboardDataContext = createContext(null); + +export function DashboardDataProvider({ data, children }: { data: DashboardData; children: ReactNode }) { + return {children}; +} + +export function useDashboardData() { + const data = useContext(DashboardDataContext); + + if (!data) { + throw new Error("Dashboard data is unavailable. Render this component inside DashboardShell with live data."); + } + + return data; +} diff --git a/typescript-recipes/parallel-n8n-procurement/dashboard/components/PortfolioTableManager.tsx b/typescript-recipes/parallel-n8n-procurement/dashboard/components/PortfolioTableManager.tsx new file mode 100644 index 0000000..4c11a9d --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/dashboard/components/PortfolioTableManager.tsx @@ -0,0 +1,403 @@ +"use client"; + +import type { ChangeEvent, FormEvent } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { MoreHorizontal, Plus, RotateCcw, Upload } from "lucide-react"; +import { useDashboardData } from "@/components/DashboardDataProvider"; +import { + type RiskDimension, + type MonitoringPriority, + type RiskLevel, + type VendorProfile, +} from "@/lib/dashboard-types"; +import type { + PortfolioMutationRequest, + PortfolioMutationResponse, + PortfolioMutationVendorInput, +} from "@/lib/portfolio-mutations"; + +type VendorDraft = { + vendorName: string; + vendorDomain: string; + vendorCategory: string; + relationshipOwner: string; + region: string; + monitoringPriority: MonitoringPriority; + riskLevel: RiskLevel; + score: string; + nextResearchDate: string; +}; + +const defaultDraft: VendorDraft = { + vendorName: "", + vendorDomain: "", + vendorCategory: "", + relationshipOwner: "", + region: "", + monitoringPriority: "medium", + riskLevel: "MEDIUM", + score: "50", + nextResearchDate: "", +}; + +function slugify(value: string) { + return value + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function formatDate(input: string) { + return new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + }).format(new Date(input)); +} + +function normalizeRiskLevel(value: string | undefined): RiskLevel { + const normalized = value?.trim().toUpperCase(); + if (normalized === "LOW" || normalized === "MEDIUM" || normalized === "HIGH" || normalized === "CRITICAL") { + return normalized; + } + return "MEDIUM"; +} + +function normalizePriority(value: string | undefined): MonitoringPriority { + const normalized = value?.trim().toLowerCase(); + if (normalized === "low" || normalized === "medium" || normalized === "high") { + return normalized; + } + return "medium"; +} + +function csvCells(line: string) { + return line + .split(/,(?=(?:(?:[^"]*"){2})*[^"]*$)/) + .map((cell) => cell.trim().replace(/^"|"$/g, "")); +} + +function csvHeaderKey(header: string) { + return header.toLowerCase().replace(/[^a-z0-9]/g, ""); +} + +function defaultDimensions(level: RiskLevel): RiskDimension[] { + return [ + { key: "financial_health", label: "Financial health", severity: level, status: "watch", findings: "Imported vendor row pending review." }, + { key: "legal_regulatory", label: "Legal & regulatory", severity: "LOW" as RiskLevel, status: "stable", findings: "No imported legal details yet." }, + { key: "cybersecurity", label: "Cybersecurity", severity: "LOW" as RiskLevel, status: "stable", findings: "No imported cyber details yet." }, + { key: "leadership_governance", label: "Leadership & governance", severity: "LOW" as RiskLevel, status: "stable", findings: "No imported governance details yet." }, + { key: "esg_reputation", label: "ESG & reputation", severity: "LOW" as RiskLevel, status: "stable", findings: "No imported reputation details yet." }, + ]; +} + +function recommendationFor(level: RiskLevel) { + if (level === "CRITICAL") return "suspend_relationship"; + if (level === "HIGH") return "initiate_contingency"; + if (level === "MEDIUM") return "escalate_review"; + return "continue_monitoring"; +} + +function buildVendor(draft: VendorDraft, lastUpdated: string): VendorProfile { + const riskLevel = draft.riskLevel; + const name = draft.vendorName.trim(); + const domain = draft.vendorDomain.trim(); + + return { + id: slugify(name), + vendorName: name, + vendorDomain: domain.startsWith("http") ? domain : `https://${domain}`, + vendorCategory: draft.vendorCategory.trim().toLowerCase().replace(/\s+/g, "_"), + monitoringPriority: draft.monitoringPriority, + relationshipOwner: draft.relationshipOwner.trim(), + region: draft.region.trim(), + riskLevel, + overallRiskLevel: riskLevel, + score: Number(draft.score), + actionRequired: riskLevel === "HIGH" || riskLevel === "CRITICAL", + adverseFlag: riskLevel !== "LOW", + recommendation: recommendationFor(riskLevel), + summary: `${name} was added from portfolio management and is awaiting a full evidence review.`, + movement: "+0 new vendor", + lastAssessmentDate: lastUpdated.slice(0, 10), + nextResearchDate: draft.nextResearchDate, + triggeredOverrides: [], + dimensions: defaultDimensions(riskLevel), + adverseEvents: [], + evidence: [], + monitors: [], + }; +} + +function vendorToMutationInput(vendor: VendorProfile): PortfolioMutationVendorInput { + return { + vendorName: vendor.vendorName, + vendorDomain: vendor.vendorDomain, + vendorCategory: vendor.vendorCategory, + relationshipOwner: vendor.relationshipOwner, + region: vendor.region, + monitoringPriority: vendor.monitoringPriority, + riskLevel: vendor.riskLevel, + score: vendor.score, + nextResearchDate: vendor.nextResearchDate, + }; +} + +function parseCsv(text: string, lastUpdated: string) { + const lines = text + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + + if (lines.length < 2) { + return []; + } + + const headers = csvCells(lines[0]).map(csvHeaderKey); + + return lines.slice(1).map((line) => { + const values = csvCells(line); + const row = Object.fromEntries(headers.map((header, index) => [header, values[index] ?? ""])); + + const vendorName = row.vendorname || row.name; + if (!vendorName) { + return null; + } + + return buildVendor( + { + vendorName, + vendorDomain: row.vendordomain || row.domain || row.website || `${slugify(vendorName)}.com`, + vendorCategory: row.vendorcategory || row.category || "vendor", + relationshipOwner: row.relationshipowner || row.owner || "Unassigned", + region: row.region || "Unassigned", + monitoringPriority: normalizePriority(row.monitoringpriority || row.priority), + riskLevel: normalizeRiskLevel(row.risklevel || row.level), + score: row.score || "50", + nextResearchDate: row.nextresearchdate || row.next || lastUpdated.slice(0, 10), + }, + lastUpdated, + ); + }).filter((vendor): vendor is VendorProfile => Boolean(vendor)); +} + +function RiskSignal({ level }: { level: RiskLevel }) { + return ( + + + ); +} + +export function PortfolioTableManager() { + const data = useDashboardData(); + const router = useRouter(); + const [vendors, setVendors] = useState(data.vendors); + const [menuOpen, setMenuOpen] = useState(false); + const [formOpen, setFormOpen] = useState(false); + const [draft, setDraft] = useState(defaultDraft); + const [mutationError, setMutationError] = useState(null); + const [mutationMessage, setMutationMessage] = useState(null); + const [pendingAction, setPendingAction] = useState(null); + const fileInputRef = useRef(null); + const manageMenuRef = useRef(null); + + useEffect(() => { + setVendors(data.vendors); + }, [data.vendors]); + + useEffect(() => { + function handlePointerDown(event: MouseEvent) { + const target = event.target as Node; + + if (manageMenuRef.current && !manageMenuRef.current.contains(target)) { + setMenuOpen(false); + } + } + + document.addEventListener("mousedown", handlePointerDown); + return () => document.removeEventListener("mousedown", handlePointerDown); + }, []); + + const rows = useMemo(() => vendors.slice().sort((left, right) => right.score - left.score), [vendors]); + + const openUpload = () => { + setMenuOpen(false); + fileInputRef.current?.click(); + }; + + const openAdd = () => { + setMenuOpen(false); + setDraft(defaultDraft); + setFormOpen(true); + }; + + const resetVendors = () => { + setMenuOpen(false); + void runMutation({ action: "resetSeedVendors" }, "Demo portfolio restored from the backend seed set."); + }; + + const runMutation = async (request: PortfolioMutationRequest, successMessage: string) => { + setPendingAction(request.action); + setMutationError(null); + setMutationMessage(null); + + try { + const response = await fetch("/api/portfolio/mutation", { + method: "POST", + headers: { "content-type": "application/json", accept: "application/json" }, + body: JSON.stringify(request), + }); + + const body = (await response.json().catch(() => ({ ok: false, error: "Portfolio mutation returned invalid JSON." }))) as PortfolioMutationResponse; + + if (!response.ok || !body.ok) { + throw new Error(body.error || `Portfolio mutation failed with HTTP ${response.status}.`); + } + + setMutationMessage(successMessage); + router.refresh(); + } catch (error) { + setMutationError(error instanceof Error ? error.message : "Portfolio mutation failed."); + } finally { + setPendingAction(null); + } + }; + + const handleCsvUpload = async (event: ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + const text = await file.text(); + const imported = parseCsv(text, data.lastUpdated); + if (imported.length) { + await runMutation( + { action: "uploadVendors", vendors: imported.map(vendorToMutationInput) }, + `${imported.length} vendors uploaded through n8n.`, + ); + } else { + setMutationError("Upload did not contain any vendor rows."); + } + event.target.value = ""; + }; + + const handleAddVendor = (event: FormEvent) => { + event.preventDefault(); + const vendor = buildVendor(draft, data.lastUpdated); + void runMutation({ action: "addVendor", vendor: vendorToMutationInput(vendor) }, `${vendor.vendorName} saved through n8n.`); + setFormOpen(false); + }; + + return ( +
+
+
Portfolio
+
+ + {menuOpen ? ( +
+ + + +
+ ) : null} + +
+
+ + {formOpen ? ( +
+ setDraft((current) => ({ ...current, vendorName: event.target.value }))} placeholder="Vendor name" required /> + setDraft((current) => ({ ...current, vendorDomain: event.target.value }))} placeholder="Domain" required /> + setDraft((current) => ({ ...current, vendorCategory: event.target.value }))} placeholder="Category" required /> + setDraft((current) => ({ ...current, relationshipOwner: event.target.value }))} placeholder="Owner" required /> + setDraft((current) => ({ ...current, region: event.target.value }))} placeholder="Region" required /> + + + setDraft((current) => ({ ...current, score: event.target.value }))} type="number" min="0" max="100" placeholder="Score" required /> + setDraft((current) => ({ ...current, nextResearchDate: event.target.value }))} type="date" required /> +
+ + +
+
+ ) : null} + + {mutationError ?
{mutationError}
: null} + {mutationMessage ?
{mutationMessage}
: null} + {pendingAction ?
Syncing portfolio changes through n8n...
: null} + +
+
+ Vendor + Category + Owner + Region + Level + Score + Next + Status +
+ + {rows.map((vendor) => ( +
router.push(`/vendors/${vendor.id}`)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + router.push(`/vendors/${vendor.id}`); + } + }} + > + + {vendor.vendorName} + {vendor.vendorDomain.replace(/^https?:\/\//, "")} + + {vendor.vendorCategory.replaceAll("_", " ")} + {vendor.relationshipOwner} + {vendor.region} + + + + {vendor.score} + {formatDate(vendor.nextResearchDate)} + n8n +
+ ))} +
+ +
+ CSV headers: vendorName, vendorDomain, vendorCategory, relationshipOwner, region, monitoringPriority, riskLevel, score, nextResearchDate. +
+
+ ); +} diff --git a/typescript-recipes/parallel-n8n-procurement/dashboard/components/ProcurementDashboard.tsx b/typescript-recipes/parallel-n8n-procurement/dashboard/components/ProcurementDashboard.tsx new file mode 100644 index 0000000..b02a79a --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/dashboard/components/ProcurementDashboard.tsx @@ -0,0 +1,470 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { ArrowUpRight, Globe, MoveUpRight, X } from "lucide-react"; +import { dimensionOrder, type DashboardData, type RiskLevel, type VendorProfile } from "@/lib/dashboard-types"; + +function cn(...classes: Array) { + return classes.filter(Boolean).join(" "); +} + +function formatDate(input: string) { + return new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + }).format(new Date(input)); +} + +function formatUpdatedTime(input: string) { + return new Intl.DateTimeFormat("en", { + hour: "numeric", + minute: "2-digit", + hour12: true, + timeZone: "UTC", + }).format(new Date(input)); +} + +function riskClass(level: RiskLevel) { + return level.toLowerCase(); +} + +function priorityLabel(priority: VendorProfile["monitoringPriority"]) { + return priority.charAt(0).toUpperCase() + priority.slice(1); +} + +function recommendationLabel(recommendation: string) { + return recommendation.replaceAll("_", " "); +} + +function movementValue(movement: string) { + return movement.match(/[+-]\d+/)?.[0] ?? movement; +} + +function RiskBadge({ level }: { level: RiskLevel }) { + return {level}; +} + +function SeverityCell({ level }: { level: RiskLevel }) { + const shape = level === "LOW" ? "●" : level === "MEDIUM" ? "▲" : level === "HIGH" ? "■" : "◆"; + const label = level === "CRITICAL" ? "Critical" : level.toLowerCase(); + + return ( +
+ + {label} +
+ ); +} + +export function ProcurementDashboard({ data }: { data: DashboardData }) { + const initialVendor = + data.vendors.find((vendor) => vendor.riskLevel === "CRITICAL") ?? data.vendors[0]; + const [selectedVendorId, setSelectedVendorId] = useState(initialVendor?.id ?? ""); + const [detailOpen, setDetailOpen] = useState(false); + + const selectedVendor = useMemo( + () => data.vendors.find((vendor) => vendor.id === selectedVendorId) ?? initialVendor, + [data.vendors, selectedVendorId], + ); + + const actionRequiredCount = data.vendors.filter((vendor) => vendor.actionRequired).length; + const firstCriticalVendor = + data.vendors.find((vendor) => vendor.riskLevel === "CRITICAL") ?? initialVendor; + + if (!selectedVendor || !firstCriticalVendor) { + return
No vendors in the current live snapshot.
; + } + + const openVendor = (vendorId: string) => { + setSelectedVendorId(vendorId); + setDetailOpen(true); + }; + + return ( +
+
+
+
Priority notes
+

Immediate attention

+
+ Updated {formatUpdatedTime(data.lastUpdated)} UTC + {data.researchSummary.totalResearched} research runs completed +
+
+ {data.actionQueue.slice(0, 3).map((item) => ( + + ))} +
+
+ + +
+ +
+
+ {data.metrics.map((metric) => ( +
+ {metric.label} + {metric.value} +

{metric.trend}

+
+ ))} +
+
+ Today +

+ {data.researchSummary.totalDue} vendors were due for review.{" "} + {data.researchSummary.totalFailed} stayed queued after failed runs, and{" "} + {data.researchSummary.adverseCount} showed adverse conditions. +

+
+
+ +
+
+
+
+
Portfolio
+

Risk coverage matrix

+
+
+ {data.riskDistribution.map((band) => ( +
+ + {band.count} +
+ ))} +
+
+ +
+
+ Vendor + {dimensionOrder.map((dimension) => ( + {dimension.replaceAll("_", " ")} + ))} + Level +
+ + {data.vendors.map((vendor) => ( + + ))} +
+ +
+
+
+
Roster
+

Vendor list

+
+
+ +
+
+ Vendor + Owner + Score + Risk + Next + Movement +
+ + {data.vendors.map((vendor) => ( + + ))} +
+
+
+
+ +
+
+
+
+
Operations
+

Fleet health

+
+
+ +
+
+ Fleet health + + {data.health.activeCount}/{data.health.totalMonitors} + +
+
+ Failed monitors + {data.health.failedCount} +
+
+ Orphans + {data.health.orphanCount} +
+
+ Run duration + {data.researchSummary.duration} +
+
+
+ +
+
+
+
Feed
+

Latest monitor detections

+
+
+ + +
+
+ +
Built as an evidence-first review surface for procurement teams.
+ + {detailOpen ? ( +
setDetailOpen(false)} role="presentation"> + +
+ ) : null} +
+ ); +} diff --git a/typescript-recipes/parallel-n8n-procurement/dashboard/components/dashboard-ui.tsx b/typescript-recipes/parallel-n8n-procurement/dashboard/components/dashboard-ui.tsx new file mode 100644 index 0000000..d73607b --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/dashboard/components/dashboard-ui.tsx @@ -0,0 +1,724 @@ +"use client"; + +import type { ReactNode } from "react"; +import Link from "next/link"; +import { AlertTriangle, ArrowRight, Download, Globe, MoveUpRight, Share2 } from "lucide-react"; +import { DashboardDataProvider, useDashboardData } from "@/components/DashboardDataProvider"; +import { dimensionOrder, type DashboardData, type RiskLevel, type VendorProfile } from "@/lib/dashboard-types"; + +type SectionId = "overview" | "attention" | "portfolio" | "feed" | "observe"; + +const navigation: Array<{ id: SectionId; label: string; href: string }> = [ + { id: "overview", label: "Overview", href: "/" }, + { id: "attention", label: "Attention", href: "/attention" }, + { id: "portfolio", label: "Portfolio", href: "/portfolio" }, + { id: "feed", label: "Feed", href: "/feed" }, + { id: "observe", label: "Observe", href: "/observe" }, +]; + +const severityOrder: Record = { + CRITICAL: 4, + HIGH: 3, + MEDIUM: 2, + LOW: 1, +}; + +export function cn(...classes: Array) { + return classes.filter(Boolean).join(" "); +} + +export function formatDate(input: string) { + const date = new Date(input); + if (Number.isNaN(date.getTime())) return input || "Not scheduled"; + + return new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + }).format(date); +} + +export function formatUpdatedTime(input: string) { + const date = new Date(input); + if (Number.isNaN(date.getTime())) return "unavailable"; + + return new Intl.DateTimeFormat("en", { + hour: "numeric", + minute: "2-digit", + hour12: true, + timeZone: "UTC", + }).format(date); +} + +export function riskClass(level: RiskLevel) { + return level.toLowerCase(); +} + +export function movementValue(movement: string) { + return movement.match(/[+-]\d+/)?.[0] ?? movement; +} + +export function priorityLabel(priority: VendorProfile["monitoringPriority"]) { + return priority.charAt(0).toUpperCase() + priority.slice(1); +} + +export function recommendationLabel(recommendation: string) { + return recommendation.replaceAll("_", " "); +} + +function statusLabel(status: string) { + return status.replaceAll("_", " ").toUpperCase(); +} + +function vendorDisplayId(vendor: VendorProfile) { + const filtered = vendor.vendorName + .split(/\s+/) + .filter((token) => !["corp", "solutions", "partners", "logistics", "manufacturing"].includes(token.toLowerCase())); + const seed = filtered[0] ?? vendor.vendorName; + const capitals = seed.match(/[A-Z]/g)?.join("") ?? ""; + const prefix = (capitals || seed.replace(/[^A-Za-z]/g, "").slice(0, 2)).toUpperCase().slice(0, 2); + return `${prefix}-${String(vendor.score).padStart(3, "0")}`; +} + +function feedLogTimestamp(relativeTimestamp: string, lastUpdated: string) { + const base = new Date(lastUpdated); + if (Number.isNaN(base.getTime())) return "--:--"; + + const minuteMatch = relativeTimestamp.match(/(\d+)\s+minute/); + const hourMatch = relativeTimestamp.match(/(\d+)\s+hour/); + + if (minuteMatch) { + base.setUTCMinutes(base.getUTCMinutes() - Number(minuteMatch[1])); + } else if (hourMatch) { + base.setUTCHours(base.getUTCHours() - Number(hourMatch[1])); + } + + return new Intl.DateTimeFormat("en", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + timeZone: "UTC", + }).format(base); +} + +function shapeForRisk(level: RiskLevel) { + if (level === "LOW") return "●"; + if (level === "MEDIUM") return "▲"; + if (level === "HIGH") return "■"; + return "◆"; +} + +export function getVendorById(data: DashboardData, vendorId: string) { + return data.vendors.find((vendor) => vendor.id === vendorId); +} + +function topDrivers(vendor: VendorProfile) { + return [...vendor.dimensions] + .sort((left, right) => severityOrder[right.severity] - severityOrder[left.severity]) + .slice(0, 2); +} + +function driverLabel(label: string) { + const labels: Record = { + "Financial health": "Financial", + "Legal & regulatory": "Legal", + Cybersecurity: "Cyber", + "Leadership & governance": "Governance", + "ESG & reputation": "ESG", + }; + + return labels[label] ?? label; +} + +export function DashboardShell({ + data, + section, + title, + subtitle, + children, + aside, + breadcrumb, + breadcrumbItems, + headerMeta, + headerMetaItems, +}: { + data?: DashboardData; + section: SectionId; + title: string; + subtitle: string; + children: ReactNode; + aside?: ReactNode; + breadcrumb?: ReactNode; + breadcrumbItems?: Array<{ label: string; href?: string }>; + headerMeta?: ReactNode; + headerMetaItems?: string[]; +}) { + const renderedBreadcrumb = breadcrumbItems ? ( + + {breadcrumbItems.map((item, index) => ( + + {index > 0 ? / : null} + {item.href ? ( + + {item.label} + + ) : ( + {item.label} + )} + + ))} + + ) : ( + breadcrumb + ); + const renderedHeaderMeta = headerMetaItems ? ( +
+ {headerMetaItems.map((item) => ( + {item} + ))} +
+ ) : ( + headerMeta + ); + + const shell = ( +
+
+
+ + Parallel Procurement + + +
+ +
+
+ {renderedBreadcrumb ?
{renderedBreadcrumb}
: null} +

{title}

+ {subtitle ?

{subtitle}

: null} + {renderedHeaderMeta ? renderedHeaderMeta :
{data ? `Updated ${formatUpdatedTime(data.lastUpdated)} UTC` : "Snapshot unavailable"}
} +
+ {aside ?
{aside}
: null} +
+
+ +
{children}
+
+ ); + + return data ? {shell} : shell; +} + +export function ActionCard() { + const data = useDashboardData(); + const firstCriticalVendor = + data.vendors.find((vendor) => vendor.riskLevel === "CRITICAL") ?? data.vendors[0]; + const actionRequiredCount = data.vendors.filter((vendor) => vendor.actionRequired).length; + + if (!firstCriticalVendor) { + return ( +
+ Action required + No vendors in the live snapshot + Sync vendor registry +
+ ); + } + + return ( + + Action required + {actionRequiredCount} vendors need attention + Open highest-priority vendor + + ); +} + +export function MetricsBand() { + const data = useDashboardData(); + + return ( +
+
+ {data.metrics.map((metric) => ( +
+ {metric.label} + {metric.value.includes("/") ? ( + + {metric.value.split(" / ").map((part) => ( + {part} + ))} + + ) : ( + {metric.value} + )} +

{metric.trend}

+
+ ))} +
+
+ Today +

+ {data.researchSummary.totalDue} vendors were due for review. {data.researchSummary.totalFailed}{" "} + stayed queued after failed runs, and {data.researchSummary.adverseCount} showed adverse conditions. +

+
+
+ ); +} + +export function ImmediateAttentionPreview() { + const data = useDashboardData(); + const actionRequiredCount = data.vendors.filter((vendor) => vendor.actionRequired).length; + + return ( +
+
+
+
Priority notes ({actionRequiredCount})
+
+ + Review all + +
+ +
+ {data.actionQueue.slice(0, 3).map((item) => { + const vendor = data.vendors.find((entry) => entry.vendorName === item.vendorName); + + return ( + +
+ {item.vendorName} +
+ {item.deadline} + +
+
+

{item.action}

+ + ); + })} + {!data.actionQueue.length ?
No immediate actions in the current live snapshot.
: null} +
+
+ ); +} + +export function AttentionQueuePage() { + const data = useDashboardData(); + + return ( +
+
+
+
Queue
+
+
+ +
+
+ Vendor + Owner + Deadline + Risk + Action +
+ + {data.actionQueue.map((item) => { + const vendor = data.vendors.find((entry) => entry.vendorName === item.vendorName); + + return ( + + + {item.vendorName} + {vendor?.relationshipOwner} + + {item.owner} + {item.deadline} + + + + {item.action} + + ); + })} +
+ {!data.actionQueue.length ?
No action queue entries in the current live snapshot.
: null} +
+ ); +} + +export function WatchlistTable({ limit }: { limit?: number }) { + const data = useDashboardData(); + const vendors = limit ? data.vendors.slice(0, limit) : data.vendors; + + return ( +
+
+
+
Portfolio
+
+ + Open risk matrix + +
+ +
+
+ Vendor + Owner + Level + Key drivers + Score + Next + Movement +
+ + {vendors.map((vendor) => ( + + + {vendor.vendorName} + {vendor.vendorCategory.replaceAll("_", " ")} + + {vendor.relationshipOwner} + + + + + {topDrivers(vendor).map((dimension) => ( + + ))} + + + {vendor.score} + + {formatDate(vendor.nextResearchDate)} + + {movementValue(vendor.movement)} + + + ))} + {!vendors.length ?
No vendors in the current live snapshot.
: null} +
+
+ ); +} + +export function RiskMatrixPanel() { + const data = useDashboardData(); + + return ( +
+
+
+
Portfolio map
+

Risk coverage matrix

+
+
+ {data.riskDistribution.map((band) => ( +
+ + {band.count} +
+ ))} +
+
+ +
+
+ Vendor + {dimensionOrder.map((dimension) => ( + {dimension.replaceAll("_", " ")} + ))} + Level +
+ + {data.vendors.map((vendor) => ( + +
+ {vendor.vendorName} + {vendor.relationshipOwner} +
+ {dimensionOrder.map((dimension) => { + const value = vendor.dimensions.find((item) => item.key === dimension); + return value ? : null; + })} +
+ +
+ + ))} +
+ {!data.vendors.length ?
No vendors in the current live snapshot.
: null} +
+ ); +} + +export function OperationsPanel() { + const data = useDashboardData(); + + return ( +
+
+
+
Operations
+
+
+ +
+
+ Fleet health + + {data.health.activeCount}/{data.health.totalMonitors} + +
+
+ Failed monitors + {data.health.failedCount} +
+
+ Orphans + {data.health.orphanCount} +
+
+ Run duration + {data.researchSummary.duration} +
+
+
+ ); +} + +export function FeedPanel({ expanded = false, streamOnly = false }: { expanded?: boolean; streamOnly?: boolean }) { + const data = useDashboardData(); + const items = expanded ? data.feed : data.feed.slice(0, 4); + + return ( +
+ {!streamOnly ? ( +
+
+
Feed
+
+
+ ) : null} + +
+ {items.map((item) => ( + +
+ [{feedLogTimestamp(item.timestamp, data.lastUpdated)} UTC] + {item.vendorName} + {item.title} +
+ {item.detail} +
+ ))} + {!items.length ?
No feed events in the current live snapshot.
: null} +
+
+ ); +} + +export function FeedSharePanel() { + return ( +
+
+
+
Share feed
+

Distribute this intelligence snapshot

+
+
+ +
+
+
+ Download package + Single export +
+

Bundle this feed as one downloadable brief including all visible items and source links.

+ +
+ +
+
+ Share to Slack + One-click post +
+

Send this feed summary to a channel with key events, severity highlights, and linked sources.

+ +
+
+ +

+ UI-only preview: download and Slack actions are intentionally not wired yet. +

+
+ ); +} + +export function OverviewBottomGrid() { + return ( +
+ + +
+ ); +} + +export function FeedPagePanels() { + return ( + <> + + + + ); +} + +export function DashboardSetupState({ message, detail }: { message: string; detail?: string }) { + return ( +
+
+
Live snapshot required
+

{message}

+
+ {detail ?

{detail}

: null} +
PROCUREMENT_DASHBOARD_SNAPSHOT_URL
+
+ ); +} + +export function RiskBadge({ level }: { level: RiskLevel }) { + return {level}; +} + +export function RiskSignal({ level, label }: { level: RiskLevel; label?: string }) { + return ( + + + ); +} + +export function SeverityCell({ level }: { level: RiskLevel }) { + const label = level === "CRITICAL" ? "Critical" : level.toLowerCase(); + + return ( +
+ + {label} +
+ ); +} + +export function SeverityTag({ level, label }: { level: RiskLevel; label: string }) { + return {label}; +} + +export function VendorDetailPage({ vendor }: { vendor: VendorProfile }) { + const intelligence = [ + ...vendor.adverseEvents.map((event) => ({ + date: event.date, + title: event.title, + detail: event.description, + href: event.sourceUrl, + })), + ...vendor.evidence.map((item) => ({ + date: item.publishedAt, + title: item.title, + detail: item.materiality, + href: item.href, + })), + ].sort((left, right) => new Date(right.date).getTime() - new Date(left.date).getTime()); + + return ( +
+
+
+
Abstract
+

{vendor.summary}

+
+ + + {vendor.vendorDomain.replace("https://", "")} + + {priorityLabel(vendor.monitoringPriority)} priority + Updated {formatDate(vendor.lastAssessmentDate)} +
+
+ + +
+ +
+
+
Risk vector analysis
+
+ {vendor.dimensions.map((dimension) => ( +
+
+ {dimension.label} + +
+

{dimension.findings}

+
+ ))} +
+
+ +
+
Latest intelligence
+
+ {intelligence.length ? ( + intelligence.map((item, index) => ( + +
+ [{formatDate(item.date)}] + {item.title} + +
+

{item.detail}

+
+ )) + ) : ( +
No intelligence entries in the current monitoring window.
+ )} +
+
+
+
+ ); +} diff --git a/typescript-recipes/parallel-n8n-procurement/dashboard/components/observe/ObserveCanvas.tsx b/typescript-recipes/parallel-n8n-procurement/dashboard/components/observe/ObserveCanvas.tsx new file mode 100644 index 0000000..814d623 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/dashboard/components/observe/ObserveCanvas.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { + Background, + Controls, + MiniMap, + ReactFlow, + ReactFlowProvider, + useEdgesState, + useNodesState, + type OnSelectionChangeParams, +} from "@xyflow/react"; +import { useEffect, useMemo } from "react"; +import { ObserveNode } from "@/components/observe/ObserveNode"; +import { buildEventBatcher, isFlowLarge } from "@/lib/observe-adapters"; +import type { ObserveFlowEdge, ObserveFlowNode } from "@/lib/observe-types"; +import styles from "@/components/observe/observe-workspace.module.css"; +import "@xyflow/react/dist/style.css"; + +function ObserveCanvasInner({ + flowNodes, + flowEdges, + selectedNodeId, + onSelectNode, +}: { + flowNodes: ObserveFlowNode[]; + flowEdges: ObserveFlowEdge[]; + selectedNodeId: string | null; + onSelectNode: (nodeId: string | null) => void; +}) { + const [nodes, setNodes, onNodesChange] = useNodesState(flowNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(flowEdges); + const largeFlow = isFlowLarge(flowNodes, flowEdges); + + useEffect(() => { + const applyNodeBatch = buildEventBatcher((batches) => { + const latest = batches[batches.length - 1]; + if (latest) setNodes(latest); + }); + applyNodeBatch(flowNodes); + }, [flowNodes, setNodes]); + + useEffect(() => { + setEdges(flowEdges); + }, [flowEdges, setEdges]); + + const nodeTypes = useMemo(() => ({ observeNode: ObserveNode }), []); + + const onSelectionChange = (params: OnSelectionChangeParams) => { + const selected = params.nodes[0]; + onSelectNode(selected?.id ?? null); + }; + + return ( +
+ ({ ...node, selected: node.id === selectedNodeId }))} + edges={edges} + nodeTypes={nodeTypes} + onNodesChange={onNodesChange} + onEdgesChange={onEdgesChange} + onSelectionChange={onSelectionChange} + fitView + fitViewOptions={{ padding: 0.2 }} + minZoom={0.2} + maxZoom={1.4} + proOptions={{ hideAttribution: true }} + > + + + + + {largeFlow ? ( +
+ Large topology mode active: completed nodes are clustered and animation intensity is reduced. +
+ ) : null} +
+ ); +} + +export function ObserveCanvas(props: { + flowNodes: ObserveFlowNode[]; + flowEdges: ObserveFlowEdge[]; + selectedNodeId: string | null; + onSelectNode: (nodeId: string | null) => void; +}) { + return ( + + + + ); +} diff --git a/typescript-recipes/parallel-n8n-procurement/dashboard/components/observe/ObserveLeftPanel.tsx b/typescript-recipes/parallel-n8n-procurement/dashboard/components/observe/ObserveLeftPanel.tsx new file mode 100644 index 0000000..00f9e17 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/dashboard/components/observe/ObserveLeftPanel.tsx @@ -0,0 +1,95 @@ +"use client"; + +import type { ObserveFilters, ObserveGraphSnapshot } from "@/lib/observe-types"; +import styles from "@/components/observe/observe-workspace.module.css"; + +type ObserveControls = { + enableClustering: boolean; + autoLayout: boolean; +}; + +export function ObserveLeftPanel({ + snapshot, + filters, + controls, + mode, + isPlaying, + onModeChange, + onTogglePlaying, + onSetMaxDepth, + onToggleHideCompleted, + onToggleControl, +}: { + snapshot: ObserveGraphSnapshot; + filters: ObserveFilters; + controls: ObserveControls; + mode: "snapshot" | "replay"; + isPlaying: boolean; + onModeChange: (mode: "snapshot" | "replay") => void; + onTogglePlaying: () => void; + onSetMaxDepth: (value: number) => void; + onToggleHideCompleted: () => void; + onToggleControl: (name: keyof ObserveControls) => void; +}) { + return ( + + ); +} diff --git a/typescript-recipes/parallel-n8n-procurement/dashboard/components/observe/ObserveNode.tsx b/typescript-recipes/parallel-n8n-procurement/dashboard/components/observe/ObserveNode.tsx new file mode 100644 index 0000000..1ebf1bd --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/dashboard/components/observe/ObserveNode.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { Handle, Position, type NodeProps } from "@xyflow/react"; +import { cn } from "@/components/dashboard-ui"; +import type { ObserveFlowNode, ObserveNodeType } from "@/lib/observe-types"; +import styles from "@/components/observe/observe-workspace.module.css"; + +const typeIcon: Record = { + campaign: "◉", + monitor: "◎", + search: "⊕", + deep_research: "◈", + enrichment: "⬡", + find_all: "⊞", + cluster: "◌", +}; + +function stateLabel(state: ObserveFlowNode["data"]["state"]) { + return state.replaceAll("_", " "); +} + +export function ObserveNode({ data, selected }: NodeProps) { + const progressRatio = Math.max(0.05, Math.min(data.cost.actualUsd / Math.max(data.cost.estimatedTotalUsd, 0.01), 1)); + const ring = `${Math.round(progressRatio * 100)}%`; + + return ( +
+ +
+ + {stateLabel(data.state)} +
+ {data.title} +

{data.subtitle}

+
+ {data.provenance.source} + {data.spawnedChildren.length} spawned + ${data.cost.actualUsd.toFixed(2)} +
+ + ); +} diff --git a/typescript-recipes/parallel-n8n-procurement/dashboard/components/observe/ObserveRightPanel.tsx b/typescript-recipes/parallel-n8n-procurement/dashboard/components/observe/ObserveRightPanel.tsx new file mode 100644 index 0000000..1702434 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/dashboard/components/observe/ObserveRightPanel.tsx @@ -0,0 +1,103 @@ +"use client"; + +import Link from "next/link"; +import type { ObserveGraphSnapshot, ObserveNodeData } from "@/lib/observe-types"; +import styles from "@/components/observe/observe-workspace.module.css"; + +function fmt(value: string) { + return new Date(value).toLocaleString("en-US", { hour12: true }); +} + +export function ObserveRightPanel({ + selectedNode, + snapshot, +}: { + selectedNode: ObserveNodeData | null; + snapshot: ObserveGraphSnapshot; +}) { + return ( + + ); +} diff --git a/typescript-recipes/parallel-n8n-procurement/dashboard/components/observe/ObserveTimeline.tsx b/typescript-recipes/parallel-n8n-procurement/dashboard/components/observe/ObserveTimeline.tsx new file mode 100644 index 0000000..ffdf086 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/dashboard/components/observe/ObserveTimeline.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { useMemo } from "react"; +import type { ObserveTimelineEvent } from "@/lib/observe-types"; +import styles from "@/components/observe/observe-workspace.module.css"; + +export function ObserveTimeline({ + events, + mode, + isPlaying, + onTogglePlaying, + playheadIndex, + onPlayheadChange, + onStep, +}: { + events: ObserveTimelineEvent[]; + mode: "snapshot" | "replay"; + isPlaying: boolean; + onTogglePlaying: () => void; + playheadIndex: number; + onPlayheadChange: (index: number) => void; + onStep: (direction: -1 | 1) => void; +}) { + const activeEvent = useMemo(() => events[Math.max(0, Math.min(playheadIndex, events.length - 1))], [events, playheadIndex]); + const replayEnabled = mode === "replay"; + const eventPreview = events.slice(Math.max(0, playheadIndex - 2), playheadIndex + 1); + + return ( +
+
+
+
Replay timeline
+

Spawn chronology

+
+ {replayEnabled ? ( + + ) : null} +
+ +
+ + onPlayheadChange(Number(event.target.value))} + disabled={!replayEnabled} + /> + +
+ +

+ {replayEnabled && activeEvent + ? `${activeEvent.summary} (${new Date(activeEvent.happenedAt).toLocaleTimeString("en-US", { hour12: true })})` + : "Snapshot mode is active. Switch to Replay to scrub the spawn chain over time."} +

+ +
+ {eventPreview.map((event) => { + const index = events.findIndex((candidate) => candidate.id === event.id); + return ( + + ); + })} +
+
+ ); +} diff --git a/typescript-recipes/parallel-n8n-procurement/dashboard/components/observe/ObserveWorkspace.tsx b/typescript-recipes/parallel-n8n-procurement/dashboard/components/observe/ObserveWorkspace.tsx new file mode 100644 index 0000000..a939ce4 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/dashboard/components/observe/ObserveWorkspace.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { ObserveCanvas } from "@/components/observe/ObserveCanvas"; +import { ObserveLeftPanel } from "@/components/observe/ObserveLeftPanel"; +import { ObserveTimeline } from "@/components/observe/ObserveTimeline"; +import { + buildObserveFlow, + createDefaultObserveFilters, + filterNodesByReplayWindow, + inferActiveNodeIdsFromReplay, + selectReplayWindow, + summarizeNodeCounts, +} from "@/lib/observe-adapters"; +import { observeMockSnapshot } from "@/lib/observe-mock-data"; +import type { ObserveFilters } from "@/lib/observe-types"; +import styles from "@/components/observe/observe-workspace.module.css"; + +type ObserveControls = { + enableClustering: boolean; + autoLayout: boolean; +}; + +export function ObserveWorkspace() { + const [filters, setFilters] = useState(createDefaultObserveFilters); + const [controls, setControls] = useState({ + enableClustering: true, + autoLayout: true, + }); + const [mode, setMode] = useState<"snapshot" | "replay">("replay"); + const [isPlaying, setIsPlaying] = useState(false); + const [playheadIndex, setPlayheadIndex] = useState(observeMockSnapshot.timeline.length - 1); + const [selectedNodeId, setSelectedNodeId] = useState(null); + const replayEnabled = mode === "replay"; + + const replayEvents = useMemo( + () => selectReplayWindow(observeMockSnapshot.timeline, replayEnabled ? playheadIndex : observeMockSnapshot.timeline.length - 1), + [playheadIndex, replayEnabled], + ); + + const replayNodeIds = useMemo(() => inferActiveNodeIdsFromReplay(replayEvents), [replayEvents]); + const replayScopedSnapshot = useMemo( + () => ({ + ...observeMockSnapshot, + nodes: replayEnabled + ? filterNodesByReplayWindow(observeMockSnapshot.nodes, replayNodeIds) + : observeMockSnapshot.nodes, + }), + [replayEnabled, replayNodeIds], + ); + + const flow = useMemo( + () => + buildObserveFlow({ + snapshot: replayScopedSnapshot, + filters, + enableClustering: controls.enableClustering, + }), + [replayScopedSnapshot, filters, controls.enableClustering], + ); + + const selectedNode = useMemo( + () => replayScopedSnapshot.nodes.find((node) => node.id === selectedNodeId) ?? null, + [replayScopedSnapshot.nodes, selectedNodeId], + ); + + const counts = useMemo(() => summarizeNodeCounts(replayScopedSnapshot.nodes), [replayScopedSnapshot.nodes]); + + const toggleControl = (name: keyof ObserveControls) => + setControls((current) => ({ + ...current, + [name]: !current[name], + })); + + useEffect(() => { + if (!replayEnabled || !isPlaying) return; + const id = window.setInterval(() => { + setPlayheadIndex((current) => { + if (current >= observeMockSnapshot.timeline.length - 1) { + setIsPlaying(false); + return current; + } + return current + 1; + }); + }, 900); + return () => window.clearInterval(id); + }, [isPlaying, replayEnabled]); + + return ( +
+
+
+
Topology size
+ {replayScopedSnapshot.nodes.length} nodes +

{replayScopedSnapshot.edges.length} edges in current scope

+
+
+
Active states
+ {counts.byState.get("active") ?? 0} active +

{counts.byState.get("triggered") ?? 0} triggered, {counts.byState.get("spawning") ?? 0} spawning

+
+
+
Node types
+ {counts.byType.get("monitor") ?? 0} monitors +

{counts.byType.get("deep_research") ?? 0} deep research, {counts.byType.get("search") ?? 0} searches

+
+
+
Mode
+
+ + +
+

{mode === "replay" ? "Chronology scrub and playback enabled." : "Full topology snapshot view."}

+
+
+ +
+ setIsPlaying((current) => !current)} + onModeChange={(nextMode) => { + setMode(nextMode); + if (nextMode === "snapshot") setIsPlaying(false); + }} + onSetMaxDepth={(value) => setFilters((current) => ({ ...current, maxDepth: value }))} + onToggleHideCompleted={() => setFilters((current) => ({ ...current, hideCompleted: !current.hideCompleted }))} + onToggleControl={toggleControl} + /> + +
+ + setIsPlaying((current) => !current)} + playheadIndex={playheadIndex} + onPlayheadChange={setPlayheadIndex} + onStep={(direction) => + setPlayheadIndex((current) => + Math.max(0, Math.min(observeMockSnapshot.timeline.length - 1, current + direction)), + ) + } + /> +
+ ); +} diff --git a/typescript-recipes/parallel-n8n-procurement/dashboard/components/observe/observe-workspace.module.css b/typescript-recipes/parallel-n8n-procurement/dashboard/components/observe/observe-workspace.module.css new file mode 100644 index 0000000..0a49fc8 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/dashboard/components/observe/observe-workspace.module.css @@ -0,0 +1,480 @@ +.observeWorkspace { + display: grid; + gap: 18px; +} + +.observeTopStats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 14px; +} + +.observeTopStats section strong { + display: block; + margin-top: 6px; + font-family: var(--font-serif), serif; + font-size: 2rem; + line-height: 1; + letter-spacing: -0.04em; +} + +.observeTopStats section p { + margin: 6px 0 0; + color: var(--muted); + font-size: 0.86rem; +} + +.observeMainGrid { + display: grid; + grid-template-columns: minmax(220px, 0.55fr) minmax(0, 3fr); + gap: 14px; + min-height: 720px; +} + +.leftPanel, +.rightPanel { + display: grid; + gap: 12px; + align-content: start; +} + +.panelHeading { + margin: 4px 0 0; + font-family: var(--font-serif), serif; + font-size: 1.1rem; + line-height: 1.2; +} + +.panelBody { + margin: 8px 0 0; + color: var(--muted); + font-size: 0.88rem; + line-height: 1.55; +} + +.toggleGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin-top: 10px; +} + +.modeSwitch { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.modeButton, +.modeButtonActive, +.replayButton { + border: 1px solid var(--line); + border-radius: 10px; + background: #fff; + color: var(--text); + min-height: 34px; + padding: 7px 10px; + cursor: pointer; + font-size: 0.8rem; +} + +.modeButtonActive { + border-color: var(--text); + background: var(--surface-2); +} + +.replayButton { + width: fit-content; + font-weight: 600; +} + +.toggleIdle, +.toggleActive, +.timelineRow, +.timelineRowActive, +.timelineControls button { + border: 1px solid var(--line); + border-radius: 10px; + background: #fff; + color: var(--text); + cursor: pointer; +} + +.toggleIdle, +.toggleActive { + min-height: 34px; + padding: 8px 9px; + font-size: 0.8rem; + text-align: left; +} + +.toggleActive { + border-color: var(--text); + background: var(--surface-2); +} + +.controlStack { + display: grid; + gap: 10px; + margin-top: 10px; +} + +.inlineControl { + display: grid; + gap: 8px; + font-size: 0.82rem; + color: var(--muted); +} + +.inlineControl input[type="range"] { + width: 100%; +} + +.inlineControlCheckbox { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 0.82rem; + color: var(--muted); +} + +.canvasWrap { + position: relative; + border: 1px solid var(--line); + border-radius: var(--radius); + min-height: 100%; + background: radial-gradient(circle at 25% 30%, #fff, #faf8f4 60%); + overflow: hidden; +} + +.performanceBanner { + position: absolute; + top: 10px; + right: 10px; + padding: 6px 10px; + border-radius: 999px; + border: 1px solid var(--line); + background: #fff; + font-size: 0.74rem; + color: var(--muted); + z-index: 10; +} + +.observeNode { + width: 240px; + border: 1px solid var(--line); + border-radius: 16px; + background: #fff; + padding: 10px; + box-shadow: 0 8px 22px rgba(17, 17, 17, 0.06); + display: grid; + gap: 6px; + transition: transform 180ms ease, box-shadow 180ms ease, border-color 180ms ease, opacity 180ms ease; +} + +.observeNode strong { + font-size: 0.86rem; + line-height: 1.3; +} + +.observeNode p { + margin: 0; + color: var(--muted); + font-size: 0.76rem; + line-height: 1.4; +} + +.nodeTopRow { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; +} + +.nodeIcon { + font-size: 1rem; +} + +.nodeStatePill { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 2px 8px; + border-radius: 999px; + background: var(--surface-2); + font-size: 0.64rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); +} + +.nodeMeta { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.nodeMeta span { + color: var(--muted); + font-size: 0.7rem; +} + +.nodeRing { + height: 5px; + border-radius: 999px; + background: #efece7; + overflow: hidden; +} + +.nodeRingFill { + height: 100%; + background: currentColor; +} + +.nodeHandle { + width: 8px; + height: 8px; + border: 1px solid #fff; + background: currentColor; +} + +.nodeSelected { + border-color: var(--text); + box-shadow: 0 0 0 2px rgba(23, 22, 20, 0.08), 0 12px 28px rgba(17, 17, 17, 0.09); +} + +.nodeType_campaign { + width: 280px; + color: #334155; +} + +.nodeType_monitor { + width: 240px; + color: #0891b2; +} + +.nodeType_search { + width: 200px; + color: #8b5cf6; +} + +.nodeType_deep_research { + width: 240px; + color: #f97316; +} + +.nodeType_enrichment { + width: 220px; + color: #22c55e; +} + +.nodeType_find_all { + width: 220px; + color: #f97316; +} + +.nodeType_cluster { + width: 220px; + color: #64748b; +} + +.nodeState_spawning { + transform: scale(0.96); + opacity: 0.78; + animation: spawnPulse 850ms ease-in-out infinite; +} + +.nodeState_active { + animation: ambientPulse 2500ms ease-in-out infinite; +} + +.nodeState_triggered { + animation: triggerFlash 420ms ease-in-out infinite; +} + +.nodeState_complete { + opacity: 0.56; + transform: scale(0.92); +} + +.nodeState_failed { + border-color: #ef4444; + box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.16); +} + +.nodeState_paused { + opacity: 0.66; + filter: saturate(0.6); +} + +.nodeState_budget_blocked { + border-color: #fbbf24; + box-shadow: 0 0 0 2px rgba(251, 191, 36, 0.18); +} + +.narrativeStack, +.detailList, +.transportStack { + display: grid; + gap: 8px; +} + +.detailList strong { + font-size: 0.78rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.detailList span, +.detailList a { + font-size: 0.82rem; + line-height: 1.45; +} + +.transportRow { + display: flex; + gap: 8px; + justify-content: space-between; + align-items: flex-start; + padding-bottom: 8px; + border-bottom: 1px solid var(--line); +} + +.transportRow:last-child { + border-bottom: none; + padding-bottom: 0; +} + +.transportRow p { + margin: 4px 0 0; + color: var(--muted); + font-size: 0.8rem; + line-height: 1.45; +} + +.transportStatus { + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 0.68rem; + color: var(--muted-2); +} + +.timelinePanel { + display: grid; + gap: 8px; +} + +.timelineControls { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 8px; +} + +.timelineControls button { + min-height: 34px; + padding: 0 10px; + font-size: 0.78rem; +} + +.timelineControls input[type="range"] { + width: 100%; +} + +.timelineList { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; +} + +.timelineRow, +.timelineRowActive { + width: 100%; + text-align: left; + padding: 8px 10px; + display: grid; + gap: 3px; +} + +.timelineRow span, +.timelineRowActive span { + color: var(--muted-2); + font-size: 0.72rem; +} + +.timelineRow strong, +.timelineRowActive strong { + font-size: 0.8rem; + font-weight: 600; +} + +.timelineRowActive { + border-color: var(--text); + background: var(--surface-2); +} + +@keyframes spawnPulse { + 0% { + transform: scale(0.9); + opacity: 0.52; + } + 50% { + transform: scale(1); + opacity: 0.82; + } + 100% { + transform: scale(0.9); + opacity: 0.52; + } +} + +@keyframes ambientPulse { + 0% { + box-shadow: 0 8px 22px rgba(17, 17, 17, 0.05); + } + 50% { + box-shadow: 0 8px 26px rgba(17, 17, 17, 0.11); + } + 100% { + box-shadow: 0 8px 22px rgba(17, 17, 17, 0.05); + } +} + +@keyframes triggerFlash { + 0% { + box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.3); + } + 50% { + box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.7); + } + 100% { + box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.3); + } +} + +@media (max-width: 1400px) { + .observeTopStats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .observeMainGrid { + grid-template-columns: minmax(220px, 0.55fr) minmax(0, 2fr); + } +} + +@media (max-width: 1000px) { + .observeTopStats { + grid-template-columns: 1fr; + } + + .observeMainGrid { + grid-template-columns: 1fr; + } + + .canvasWrap { + min-height: 560px; + } + + .timelineList { + grid-template-columns: 1fr; + } +} diff --git a/typescript-recipes/parallel-n8n-procurement/dashboard/lib/dashboard-data.ts b/typescript-recipes/parallel-n8n-procurement/dashboard/lib/dashboard-data.ts new file mode 100644 index 0000000..7c91897 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/dashboard/lib/dashboard-data.ts @@ -0,0 +1,132 @@ +import type { DashboardData, RiskLevel, VendorProfile } from "@/lib/dashboard-types"; + +export const DASHBOARD_SNAPSHOT_ENV = "PROCUREMENT_DASHBOARD_SNAPSHOT_URL"; + +export type DashboardDataLoadResult = + | { ok: true; data: DashboardData } + | { ok: false; message: string; detail?: string }; + +export async function loadDashboardData(): Promise { + const snapshotUrl = process.env[DASHBOARD_SNAPSHOT_ENV]?.trim(); + + if (!snapshotUrl) { + return { + ok: false, + message: "Dashboard snapshot endpoint is not configured.", + detail: `Set ${DASHBOARD_SNAPSHOT_ENV} to the n8n procurement-dashboard-snapshot webhook URL.`, + }; + } + + try { + new URL(snapshotUrl); + } catch { + return { + ok: false, + message: "Dashboard snapshot endpoint is not a valid URL.", + detail: `${DASHBOARD_SNAPSHOT_ENV} must be an absolute http(s) URL.`, + }; + } + + try { + const response = await fetch(snapshotUrl, { + headers: { accept: "application/json" }, + cache: "no-store", + }); + + if (!response.ok) { + return { + ok: false, + message: "Dashboard snapshot endpoint returned an error.", + detail: `n8n responded with HTTP ${response.status}.`, + }; + } + + const payload = (await response.json()) as unknown; + return validateDashboardData(payload); + } catch (error) { + return { + ok: false, + message: "Dashboard snapshot endpoint could not be reached.", + detail: error instanceof Error ? error.message : "Unknown fetch failure.", + }; + } +} + +function validateDashboardData(payload: unknown): DashboardDataLoadResult { + if (!isRecord(payload)) { + return invalid("Snapshot response must be a JSON object."); + } + + const data = payload as Partial; + const requiredArrays = ["metrics", "riskDistribution", "feed", "actionQueue", "vendors"] as const; + const missingArray = requiredArrays.find((key) => !Array.isArray(data[key])); + + if (typeof data.lastUpdated !== "string") { + return invalid("Snapshot response is missing lastUpdated."); + } + + if (missingArray) { + return invalid(`Snapshot response is missing ${missingArray}.`); + } + + if (!isRecord(data.researchSummary)) { + return invalid("Snapshot response is missing researchSummary."); + } + + if (!isRecord(data.health)) { + return invalid("Snapshot response is missing health."); + } + + const invalidVendor = data.vendors?.find((vendor) => !isVendorProfile(vendor)); + if (invalidVendor) { + return invalid(`Snapshot contains an invalid vendor profile near ${vendorLabel(invalidVendor)}.`); + } + + const invalidRiskBand = data.riskDistribution?.find( + (band) => !isRecord(band) || !isRiskLevel(band.label) || typeof band.count !== "number", + ); + if (invalidRiskBand) { + return invalid("Snapshot contains an invalid riskDistribution entry."); + } + + return { ok: true, data: data as DashboardData }; +} + +function invalid(detail: string): DashboardDataLoadResult { + return { + ok: false, + message: "Dashboard snapshot response is invalid.", + detail, + }; +} + +function isVendorProfile(value: unknown): value is VendorProfile { + if (!isRecord(value)) return false; + + return ( + typeof value.id === "string" && + typeof value.vendorName === "string" && + typeof value.vendorDomain === "string" && + typeof value.vendorCategory === "string" && + isRiskLevel(value.riskLevel) && + isRiskLevel(value.overallRiskLevel) && + typeof value.score === "number" && + typeof value.actionRequired === "boolean" && + Array.isArray(value.dimensions) && + Array.isArray(value.adverseEvents) && + Array.isArray(value.evidence) && + Array.isArray(value.monitors) + ); +} + +function isRiskLevel(value: unknown): value is RiskLevel { + return value === "LOW" || value === "MEDIUM" || value === "HIGH" || value === "CRITICAL"; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function vendorLabel(value: unknown) { + return isRecord(value) && typeof value.vendorName === "string" ? value.vendorName : "unknown vendor"; +} diff --git a/typescript-recipes/parallel-n8n-procurement/dashboard/lib/dashboard-types.ts b/typescript-recipes/parallel-n8n-procurement/dashboard/lib/dashboard-types.ts new file mode 100644 index 0000000..5f53e4a --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/dashboard/lib/dashboard-types.ts @@ -0,0 +1,119 @@ +export type RiskLevel = "LOW" | "MEDIUM" | "HIGH" | "CRITICAL"; +export type MonitoringPriority = "high" | "medium" | "low"; +export type DimensionKey = + | "financial_health" + | "legal_regulatory" + | "cybersecurity" + | "leadership_governance" + | "esg_reputation"; + +export interface MetricCard { + label: string; + value: string; + trend: string; + tone: "default" | "critical" | "warning" | "positive"; +} + +export interface RiskDimension { + key: DimensionKey; + label: string; + severity: RiskLevel; + status: string; + findings: string; +} + +export interface AdverseEvent { + title: string; + date: string; + category: string; + severity: RiskLevel; + description: string; + sourceUrl: string; +} + +export interface EvidenceItem { + title: string; + publication: string; + publishedAt: string; + materiality: string; + href: string; +} + +export interface MonitorLens { + dimension: string; + cadence: string; + status: "active" | "watching" | "needs_review"; + query: string; + lastEvent: string; +} + +export interface VendorProfile { + id: string; + vendorName: string; + vendorDomain: string; + vendorCategory: string; + monitoringPriority: MonitoringPriority; + relationshipOwner: string; + region: string; + riskLevel: RiskLevel; + overallRiskLevel: RiskLevel; + score: number; + actionRequired: boolean; + adverseFlag: boolean; + recommendation: string; + summary: string; + movement: string; + lastAssessmentDate: string; + nextResearchDate: string; + triggeredOverrides: string[]; + dimensions: RiskDimension[]; + adverseEvents: AdverseEvent[]; + evidence: EvidenceItem[]; + monitors: MonitorLens[]; +} + +export interface DashboardData { + lastUpdated: string; + metrics: MetricCard[]; + riskDistribution: Array<{ label: RiskLevel; count: number }>; + researchSummary: { + totalDue: number; + totalResearched: number; + totalFailed: number; + adverseCount: number; + batchesExecuted: number; + duration: string; + }; + health: { + totalMonitors: number; + activeCount: number; + failedCount: number; + orphanCount: number; + recreated: number; + webhookHealthy: boolean; + }; + feed: Array<{ + vendorName: string; + title: string; + severity: RiskLevel; + timestamp: string; + detail: string; + sourceUrl: string; + }>; + actionQueue: Array<{ + vendorName: string; + owner: string; + deadline: string; + action: string; + riskLevel: RiskLevel; + }>; + vendors: VendorProfile[]; +} + +export const dimensionOrder: DimensionKey[] = [ + "financial_health", + "legal_regulatory", + "cybersecurity", + "leadership_governance", + "esg_reputation", +]; diff --git a/typescript-recipes/parallel-n8n-procurement/dashboard/lib/observe-adapters.ts b/typescript-recipes/parallel-n8n-procurement/dashboard/lib/observe-adapters.ts new file mode 100644 index 0000000..06fdf76 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/dashboard/lib/observe-adapters.ts @@ -0,0 +1,288 @@ +import type { EdgeMarker, NodePositionChange } from "@xyflow/react"; +import type { + ObserveEdgeData, + ObserveFilters, + ObserveFlowEdge, + ObserveFlowNode, + ObserveGraphSnapshot, + ObserveNodeData, + ObserveNodeType, + ObserveTimelineEvent, +} from "@/lib/observe-types"; + +const NODE_HORIZONTAL_GAP = 320; +const NODE_VERTICAL_GAP = 160; +const CLUSTER_NODE_ID = "cluster-completed"; + +function getDepthByNodeId(snapshot: ObserveGraphSnapshot) { + const depthMap = new Map(); + const rootNode = snapshot.nodes.find((node) => node.type === "campaign") ?? snapshot.nodes[0]; + if (!rootNode) return depthMap; + + const queue: Array<{ id: string; depth: number }> = [{ id: rootNode.id, depth: 0 }]; + const visited = new Set(); + + while (queue.length) { + const current = queue.shift(); + if (!current || visited.has(current.id)) continue; + visited.add(current.id); + depthMap.set(current.id, current.depth); + + snapshot.edges + .filter((edge) => edge.source === current.id) + .forEach((edge) => queue.push({ id: edge.target, depth: current.depth + 1 })); + } + + snapshot.nodes.forEach((node) => { + if (!depthMap.has(node.id)) depthMap.set(node.id, 0); + }); + + return depthMap; +} + +function positionNodesByDepth(snapshot: ObserveGraphSnapshot, visibleNodes: ObserveNodeData[]) { + const depthMap = getDepthByNodeId(snapshot); + const rowsByDepth = new Map(); + + visibleNodes.forEach((node) => { + const depth = depthMap.get(node.id) ?? 0; + const rows = rowsByDepth.get(depth) ?? []; + rows.push(node); + rowsByDepth.set(depth, rows); + }); + + const positions = new Map(); + Array.from(rowsByDepth.entries()).forEach(([depth, nodes]) => { + nodes + .sort((left, right) => left.spawnedAt.localeCompare(right.spawnedAt)) + .forEach((node, index) => { + positions.set(node.id, { + x: depth * NODE_HORIZONTAL_GAP, + y: index * NODE_VERTICAL_GAP, + }); + }); + }); + + return positions; +} + +function markerForRelation(): EdgeMarker { + return { type: "arrowclosed", width: 18, height: 18 }; +} + +function shouldKeepNode(node: ObserveNodeData, filters: ObserveFilters, depthMap: Map) { + if (!filters.nodeTypes.includes(node.type)) return false; + if (!filters.nodeStates.includes(node.state)) return false; + if (filters.hideCompleted && node.state === "complete") return false; + if ((depthMap.get(node.id) ?? 0) > filters.maxDepth) return false; + return true; +} + +function clusterCompletedNodes( + nodes: ObserveNodeData[], + edges: ObserveGraphSnapshot["edges"], + enableClustering: boolean, + threshold: number, +) { + if (!enableClustering || nodes.length <= threshold) { + return { nodes, edges }; + } + + const completeNodes = nodes.filter((node) => node.state === "complete"); + if (completeNodes.length < 20) { + return { nodes, edges }; + } + + const clusteredIds = new Set(completeNodes.map((node) => node.id)); + const clusteredNode: ObserveNodeData = { + id: CLUSTER_NODE_ID, + type: "cluster", + state: "complete", + title: `${completeNodes.length} completed nodes`, + subtitle: "Collapsed for performance", + campaignId: nodes[0]?.campaignId ?? "campaign", + childIds: completeNodes.map((node) => node.id), + whyThisNodeExists: "Completed nodes are auto-collapsed to preserve canvas readability and frame-rate.", + whatItIsDoing: "Summarizing low-activity completed branches.", + spawnedAt: completeNodes[0]?.spawnedAt ?? new Date().toISOString(), + spawnedChildren: [], + cost: { + actualUsd: completeNodes.reduce((sum, node) => sum + node.cost.actualUsd, 0), + estimatedTotalUsd: completeNodes.reduce((sum, node) => sum + node.cost.estimatedTotalUsd, 0), + remainingBudgetUsd: completeNodes[0]?.cost.remainingBudgetUsd ?? 0, + }, + lifecycle: { + currentState: "complete", + lastTransitionAt: new Date().toISOString(), + transitions: [ + { + state: "complete", + changedAt: new Date().toISOString(), + reason: "Auto-clustered completed nodes for performance.", + }, + ], + }, + provenance: { + source: "adhoc", + citations: [], + }, + rulesEvaluation: { + signalStrength: 1, + threshold: 0.7, + budgetGate: "pass", + deduplication: "pass", + rateLimit: "pass", + depthLimit: "pass", + scopeCheck: "pass", + decision: "allowed", + decisionReason: "Visualization layer performance optimization.", + }, + }; + + const nodesWithoutClustered = nodes.filter((node) => !clusteredIds.has(node.id)); + const clusterEdges: ObserveGraphSnapshot["edges"] = []; + const nextEdges = edges + .filter((edge) => !clusteredIds.has(edge.source) || !clusteredIds.has(edge.target)) + .map((edge) => { + if (clusteredIds.has(edge.source) && !clusteredIds.has(edge.target)) { + const clusterEdgeId = `${CLUSTER_NODE_ID}->${edge.target}`; + if (!clusterEdges.find((candidate) => candidate.id === clusterEdgeId)) { + clusterEdges.push({ + id: clusterEdgeId, + source: CLUSTER_NODE_ID, + target: edge.target, + relation: "spawned", + reasonSummary: "Collapsed completed branch", + createdAt: edge.createdAt, + confidence: edge.confidence, + }); + } + } + + if (!clusteredIds.has(edge.source) && clusteredIds.has(edge.target)) { + const clusterEdgeId = `${edge.source}->${CLUSTER_NODE_ID}`; + if (!clusterEdges.find((candidate) => candidate.id === clusterEdgeId)) { + clusterEdges.push({ + id: clusterEdgeId, + source: edge.source, + target: CLUSTER_NODE_ID, + relation: "spawned", + reasonSummary: "Collapsed completed branch", + createdAt: edge.createdAt, + confidence: edge.confidence, + }); + } + } + + return edge; + }); + + return { + nodes: [...nodesWithoutClustered, clusteredNode], + edges: [...nextEdges.filter((edge) => !clusteredIds.has(edge.source) && !clusteredIds.has(edge.target)), ...clusterEdges], + }; +} + +export function createDefaultObserveFilters(): ObserveFilters { + return { + nodeTypes: ["campaign", "monitor", "search", "deep_research", "enrichment", "find_all", "cluster"], + nodeStates: ["spawning", "active", "triggered", "complete", "failed", "paused", "budget_blocked"], + maxDepth: 10, + hideCompleted: false, + }; +} + +export function buildObserveFlow({ + snapshot, + filters, + enableClustering, + clusterThreshold = 1000, +}: { + snapshot: ObserveGraphSnapshot; + filters: ObserveFilters; + enableClustering: boolean; + clusterThreshold?: number; +}) { + const depthMap = getDepthByNodeId(snapshot); + const visibleNodes = snapshot.nodes.filter((node) => shouldKeepNode(node, filters, depthMap)); + const clustered = clusterCompletedNodes(visibleNodes, snapshot.edges, enableClustering, clusterThreshold); + const visibleNodeIds = new Set(clustered.nodes.map((node) => node.id)); + const positionedNodes = positionNodesByDepth(snapshot, clustered.nodes); + + const nodes: ObserveFlowNode[] = clustered.nodes.map((node) => ({ + id: node.id, + type: "observeNode", + data: node, + position: positionedNodes.get(node.id) ?? { x: 0, y: 0 }, + draggable: true, + })); + + const edges: ObserveFlowEdge[] = clustered.edges + .filter((edge) => visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target)) + .map((edge) => ({ + id: edge.id, + source: edge.source, + target: edge.target, + type: "smoothstep", + markerEnd: markerForRelation(), + animated: edge.relation === "spawned", + label: `${edge.relation}: ${edge.reasonSummary}`, + data: { + relation: edge.relation, + reasonSummary: edge.reasonSummary, + createdAt: edge.createdAt, + confidence: edge.confidence, + } satisfies ObserveEdgeData, + labelStyle: { fontSize: 10 }, + })); + + return { nodes, edges }; +} + +export function selectReplayWindow(events: ObserveTimelineEvent[], playheadIndex: number) { + if (playheadIndex < 0) return []; + return events.slice(0, Math.min(playheadIndex + 1, events.length)); +} + +export function inferActiveNodeIdsFromReplay(events: ObserveTimelineEvent[]) { + return new Set(events.map((event) => event.nodeId)); +} + +export function filterNodesByReplayWindow(nodes: ObserveNodeData[], replayNodeIds: Set) { + return nodes.filter((node) => replayNodeIds.has(node.id) || node.type === "campaign"); +} + +export function buildEventBatcher(applyBatch: (items: T[]) => void) { + let queue: T[] = []; + let raf: number | null = null; + + return (item: T) => { + queue.push(item); + if (raf !== null) return; + raf = window.requestAnimationFrame(() => { + applyBatch(queue); + queue = []; + raf = null; + }); + }; +} + +export function isFlowLarge(nodes: ObserveFlowNode[], edges: ObserveFlowEdge[]) { + return nodes.length > 1000 || edges.length > 2000; +} + +export function summarizeNodeCounts(nodes: ObserveNodeData[]) { + const byType = new Map(); + const byState = new Map(); + + nodes.forEach((node) => { + byType.set(node.type, (byType.get(node.type) ?? 0) + 1); + byState.set(node.state, (byState.get(node.state) ?? 0) + 1); + }); + + return { byType, byState }; +} + +export function nodePositionChangeLookup(changes: NodePositionChange[]) { + return new Set(changes.map((change) => change.id)); +} diff --git a/typescript-recipes/parallel-n8n-procurement/dashboard/lib/observe-mock-data.ts b/typescript-recipes/parallel-n8n-procurement/dashboard/lib/observe-mock-data.ts new file mode 100644 index 0000000..fd32233 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/dashboard/lib/observe-mock-data.ts @@ -0,0 +1,621 @@ +import type { ObserveGraphSnapshot, ObserveNodeData, ObserveNodeType } from "@/lib/observe-types"; + +type NodeSeed = Pick< + ObserveNodeData, + | "id" + | "type" + | "state" + | "title" + | "subtitle" + | "campaignId" + | "vendorId" + | "parentId" + | "childIds" + | "whyThisNodeExists" + | "whatItIsDoing" + | "spawnedBy" + | "spawnedAt" + | "spawnedChildren" + | "cost" + | "lifecycle" + | "provenance" + | "rulesEvaluation" +>; + +const CAMPAIGN_ID = "campaign-procurement-risk-q1"; + +function typeLabel(type: ObserveNodeType) { + return type.replaceAll("_", " "); +} + +const nodes: NodeSeed[] = [ + { + id: "campaign-seed", + type: "campaign", + state: "active", + title: "Enterprise Vendor Risk Campaign", + subtitle: "200 enterprise vendors, auto-investigate anomalies", + campaignId: CAMPAIGN_ID, + childIds: ["monitor-acme-sec", "monitor-acme-news", "monitor-acme-web", "monitor-acme-social"], + whyThisNodeExists: + "The operator started a campaign to continuously monitor enterprise vendors for risk signals and autonomously investigate anomalies.", + whatItIsDoing: "Maintaining campaign budget, spawn policy, and active monitor topology.", + spawnedAt: "2026-03-07T17:30:00.000Z", + spawnedChildren: [ + { id: "monitor-acme-sec", type: "monitor", reason: "Seed monitor for SEC filing risk signals." }, + { id: "monitor-acme-news", type: "monitor", reason: "Seed monitor for negative news spikes." }, + { id: "monitor-acme-web", type: "monitor", reason: "Seed monitor for pricing and website changes." }, + { id: "monitor-acme-social", type: "monitor", reason: "Seed monitor for social and sentiment shifts." }, + ], + cost: { actualUsd: 41.13, estimatedTotalUsd: 120, remainingBudgetUsd: 78.87 }, + lifecycle: { + currentState: "active", + lastTransitionAt: "2026-03-07T17:30:00.000Z", + transitions: [{ state: "active", changedAt: "2026-03-07T17:30:00.000Z", reason: "Campaign launched." }], + }, + provenance: { source: "adhoc", runId: "campaign-run-001", citations: [] }, + rulesEvaluation: { + signalStrength: 1, + threshold: 0.7, + budgetGate: "pass", + deduplication: "pass", + rateLimit: "pass", + depthLimit: "pass", + scopeCheck: "pass", + decision: "allowed", + decisionReason: "Campaign root node established by operator intent.", + }, + }, + { + id: "monitor-acme-sec", + type: "monitor", + state: "triggered", + title: "SEC Filing Watch", + subtitle: "Acme Corp 8-K, 10-Q, governance filings", + campaignId: CAMPAIGN_ID, + vendorId: "acme", + parentId: "campaign-seed", + childIds: ["research-acme-exec-comp"], + whyThisNodeExists: "Created during campaign seeding to track Acme SEC filing risk dimensions.", + whatItIsDoing: "Scanning new filings and evaluating anomaly confidence against trigger threshold.", + spawnedBy: { id: "campaign-seed", type: "campaign", reason: "Initial monitor seeding." }, + spawnedAt: "2026-03-07T17:30:06.000Z", + spawnedChildren: [ + { + id: "research-acme-exec-comp", + type: "deep_research", + reason: "Anomalous executive compensation filing exceeded confidence threshold.", + }, + ], + cost: { actualUsd: 8.74, estimatedTotalUsd: 22, remainingBudgetUsd: 70.13 }, + lifecycle: { + currentState: "triggered", + lastTransitionAt: "2026-03-07T18:34:00.000Z", + transitions: [ + { state: "active", changedAt: "2026-03-07T17:30:06.000Z", reason: "Monitor initialized." }, + { + state: "triggered", + changedAt: "2026-03-07T18:34:00.000Z", + reason: "8-K compensation signal reached 0.85 confidence.", + }, + ], + }, + provenance: { + source: "monitor_event", + monitorId: "mtr_81acme", + eventGroupId: "eg_23040", + signalStrength: 0.85, + threshold: 0.7, + citations: [ + { + title: "Acme Corp 8-K filing", + url: "https://www.sec.gov/ixviewer/acme-8k", + confidence: 0.92, + excerpt: "Material executive compensation amendment approved by board.", + }, + ], + }, + rulesEvaluation: { + signalStrength: 0.85, + threshold: 0.7, + budgetGate: "pass", + deduplication: "pass", + rateLimit: "pass", + depthLimit: "pass", + scopeCheck: "pass", + decision: "allowed", + decisionReason: "Signal strength exceeded threshold and was in scope for active campaign.", + }, + }, + { + id: "monitor-acme-news", + type: "monitor", + state: "active", + title: "News Spike Monitor", + subtitle: "Acme Corp risk news velocity", + campaignId: CAMPAIGN_ID, + vendorId: "acme", + parentId: "campaign-seed", + childIds: [], + whyThisNodeExists: "Seeded to detect adverse press clusters linked to supplier risk.", + whatItIsDoing: "Tracking sources and sentiment delta for emerging governance/legal incidents.", + spawnedBy: { id: "campaign-seed", type: "campaign", reason: "Initial monitor seeding." }, + spawnedAt: "2026-03-07T17:30:08.000Z", + spawnedChildren: [], + cost: { actualUsd: 6.2, estimatedTotalUsd: 18, remainingBudgetUsd: 72.67 }, + lifecycle: { + currentState: "active", + lastTransitionAt: "2026-03-07T17:30:08.000Z", + transitions: [{ state: "active", changedAt: "2026-03-07T17:30:08.000Z", reason: "Monitor initialized." }], + }, + provenance: { source: "monitor_event", monitorId: "mtr_82acme", citations: [] }, + rulesEvaluation: { + signalStrength: 0.44, + threshold: 0.7, + budgetGate: "pass", + deduplication: "pass", + rateLimit: "pass", + depthLimit: "pass", + scopeCheck: "pass", + decision: "queued", + decisionReason: "Signal under threshold, monitor remains active without spawning.", + }, + }, + { + id: "monitor-acme-web", + type: "monitor", + state: "active", + title: "Website + Pricing Monitor", + subtitle: "Acme Corp pricing and policy diffs", + campaignId: CAMPAIGN_ID, + vendorId: "acme", + parentId: "campaign-seed", + childIds: [], + whyThisNodeExists: "Seeded to detect pricing and compliance policy drift.", + whatItIsDoing: "Watching key pages for changes and classifying materiality.", + spawnedBy: { id: "campaign-seed", type: "campaign", reason: "Initial monitor seeding." }, + spawnedAt: "2026-03-07T17:30:10.000Z", + spawnedChildren: [], + cost: { actualUsd: 4.95, estimatedTotalUsd: 14, remainingBudgetUsd: 73.92 }, + lifecycle: { + currentState: "active", + lastTransitionAt: "2026-03-07T17:30:10.000Z", + transitions: [{ state: "active", changedAt: "2026-03-07T17:30:10.000Z", reason: "Monitor initialized." }], + }, + provenance: { source: "monitor_event", monitorId: "mtr_83acme", citations: [] }, + rulesEvaluation: { + signalStrength: 0.33, + threshold: 0.7, + budgetGate: "pass", + deduplication: "pass", + rateLimit: "pass", + depthLimit: "pass", + scopeCheck: "pass", + decision: "queued", + decisionReason: "No material drift above threshold yet.", + }, + }, + { + id: "monitor-acme-social", + type: "monitor", + state: "paused", + title: "Social Reputation Monitor", + subtitle: "Acme Corp social sentiment + influence spikes", + campaignId: CAMPAIGN_ID, + vendorId: "acme", + parentId: "campaign-seed", + childIds: [], + whyThisNodeExists: "Seeded for fast-moving reputation and community risk signals.", + whatItIsDoing: "Paused due to temporary rate cap and awaiting next scheduler window.", + spawnedBy: { id: "campaign-seed", type: "campaign", reason: "Initial monitor seeding." }, + spawnedAt: "2026-03-07T17:30:12.000Z", + spawnedChildren: [], + cost: { actualUsd: 2.13, estimatedTotalUsd: 10, remainingBudgetUsd: 76.74 }, + lifecycle: { + currentState: "paused", + lastTransitionAt: "2026-03-07T18:12:00.000Z", + transitions: [ + { state: "active", changedAt: "2026-03-07T17:30:12.000Z", reason: "Monitor initialized." }, + { state: "paused", changedAt: "2026-03-07T18:12:00.000Z", reason: "Rate limiter applied." }, + ], + }, + provenance: { source: "monitor_event", monitorId: "mtr_84acme", citations: [] }, + rulesEvaluation: { + signalStrength: 0.28, + threshold: 0.7, + budgetGate: "pass", + deduplication: "pass", + rateLimit: "blocked", + depthLimit: "pass", + scopeCheck: "pass", + decision: "blocked", + decisionReason: "Spawn blocked by campaign rate-limit guardrail.", + }, + }, + { + id: "research-acme-exec-comp", + type: "deep_research", + state: "active", + title: "Deep Research: Exec Compensation Shift", + subtitle: "Assess procurement risk from 8-K compensation anomaly", + campaignId: CAMPAIGN_ID, + vendorId: "acme", + parentId: "monitor-acme-sec", + childIds: [ + "search-acme-board-dispute", + "search-acme-analyst-reaction", + "search-acme-litigation", + "search-acme-subsidiary", + "search-acme-governance", + "search-acme-employee-exit", + "enrich-acme-board", + "monitor-subsidiary-y", + ], + whyThisNodeExists: + "Spawned by SEC Filing Watch after an executive compensation filing exceeded threshold and required expanded context.", + whatItIsDoing: + "Running parallel searches, synthesizing risk evidence, and deciding whether long-term monitoring expansion is justified.", + spawnedBy: { + id: "monitor-acme-sec", + type: "monitor", + reason: "8-K compensation anomaly scored above threshold.", + }, + spawnedAt: "2026-03-07T18:34:10.000Z", + spawnedChildren: [ + { id: "search-acme-board-dispute", type: "search", reason: "Look for board dispute context." }, + { id: "search-acme-analyst-reaction", type: "search", reason: "Collect external analyst response." }, + { id: "search-acme-litigation", type: "search", reason: "Check legal and enforcement spillover." }, + { id: "search-acme-subsidiary", type: "search", reason: "Identify acquisition/subsidiary links." }, + { id: "search-acme-governance", type: "search", reason: "Detect governance structure changes." }, + { id: "search-acme-employee-exit", type: "search", reason: "Assess leadership churn." }, + { id: "enrich-acme-board", type: "enrichment", reason: "Extract structured board member changes." }, + { id: "monitor-subsidiary-y", type: "monitor", reason: "Track newly discovered subsidiary." }, + ], + cost: { actualUsd: 0.52, estimatedTotalUsd: 0.76, remainingBudgetUsd: 69.61 }, + lifecycle: { + currentState: "active", + lastTransitionAt: "2026-03-07T18:35:20.000Z", + transitions: [ + { + state: "spawning", + changedAt: "2026-03-07T18:34:10.000Z", + reason: "Monitor trigger accepted by rules engine.", + }, + { + state: "active", + changedAt: "2026-03-07T18:35:20.000Z", + reason: "Search and enrichment children launched.", + }, + ], + }, + provenance: { + source: "deep_research", + runId: "run_2981", + taskGroupId: "tg_8921", + monitorId: "mtr_81acme", + eventGroupId: "eg_23040", + signalStrength: 0.85, + threshold: 0.7, + citations: [ + { + title: "Board governance analysis", + url: "https://example.com/governance-analysis", + confidence: 0.78, + }, + ], + }, + rulesEvaluation: { + signalStrength: 0.85, + threshold: 0.7, + budgetGate: "pass", + deduplication: "pass", + rateLimit: "pass", + depthLimit: "pass", + scopeCheck: "pass", + decision: "allowed", + decisionReason: "All guardrails passed. Deep research spawned.", + }, + }, + { + id: "enrich-acme-board", + type: "enrichment", + state: "active", + title: "Board Member Enrichment", + subtitle: "Extracting structured board-member deltas", + campaignId: CAMPAIGN_ID, + vendorId: "acme", + parentId: "research-acme-exec-comp", + childIds: [], + whyThisNodeExists: "Deep research required structured extraction for board dispute context.", + whatItIsDoing: "Extracting names, roles, timelines, and confidence-ranked supporting citations.", + spawnedBy: { + id: "research-acme-exec-comp", + type: "deep_research", + reason: "Structured output required for governance risk scoring.", + }, + spawnedAt: "2026-03-07T18:35:31.000Z", + spawnedChildren: [], + cost: { actualUsd: 0.11, estimatedTotalUsd: 0.14, remainingBudgetUsd: 69.5 }, + lifecycle: { + currentState: "active", + lastTransitionAt: "2026-03-07T18:35:31.000Z", + transitions: [{ state: "active", changedAt: "2026-03-07T18:35:31.000Z", reason: "Enrichment launched." }], + }, + provenance: { + source: "deep_research", + runId: "run_2981_enrich", + taskGroupId: "tg_8921", + citations: [], + }, + rulesEvaluation: { + signalStrength: 0.82, + threshold: 0.7, + budgetGate: "pass", + deduplication: "pass", + rateLimit: "pass", + depthLimit: "pass", + scopeCheck: "pass", + decision: "allowed", + decisionReason: "Enrichment is within scope and budget.", + }, + }, + { + id: "monitor-subsidiary-y", + type: "monitor", + state: "spawning", + title: "Subsidiary Y Monitor", + subtitle: "Newly discovered acquired entity watch", + campaignId: CAMPAIGN_ID, + vendorId: "subsidiary-y", + parentId: "research-acme-exec-comp", + childIds: [], + whyThisNodeExists: + "Deep research discovered Subsidiary Y as a newly acquired entity with potential procurement impact.", + whatItIsDoing: "Provisioning long-term monitor coverage for Subsidiary Y under campaign scope.", + spawnedBy: { + id: "research-acme-exec-comp", + type: "deep_research", + reason: "Discovered high-relevance entity requiring ongoing monitoring.", + }, + spawnedAt: "2026-03-07T18:36:04.000Z", + spawnedChildren: [], + cost: { actualUsd: 0.03, estimatedTotalUsd: 8.5, remainingBudgetUsd: 69.47 }, + lifecycle: { + currentState: "spawning", + lastTransitionAt: "2026-03-07T18:36:04.000Z", + transitions: [ + { + state: "spawning", + changedAt: "2026-03-07T18:36:04.000Z", + reason: "Provisioning monitor from deep research discovery.", + }, + ], + }, + provenance: { + source: "deep_research", + runId: "run_2981", + taskGroupId: "tg_8921", + citations: [ + { + title: "Acquisition disclosure", + url: "https://example.com/acme-subsidiary-y", + confidence: 0.88, + excerpt: "Acme completed acquisition of Subsidiary Y last quarter.", + }, + ], + }, + rulesEvaluation: { + signalStrength: 0.88, + threshold: 0.7, + budgetGate: "pass", + deduplication: "pass", + rateLimit: "pass", + depthLimit: "pass", + scopeCheck: "pass", + decision: "allowed", + decisionReason: "Entity discovery passed all spawn checks.", + }, + }, + ...[ + "search-acme-board-dispute", + "search-acme-analyst-reaction", + "search-acme-litigation", + "search-acme-subsidiary", + "search-acme-governance", + "search-acme-employee-exit", + ].map((id, index) => ({ + id, + type: "search" as const, + state: (index <= 3 ? "complete" : "active") as "complete" | "active", + title: `Search ${index + 1}`, + subtitle: `Parallel web search child ${index + 1} for deep research synthesis`, + campaignId: CAMPAIGN_ID, + vendorId: "acme", + parentId: "research-acme-exec-comp", + childIds: [], + whyThisNodeExists: "Spawned by deep research decomposition into parallel evidence collection paths.", + whatItIsDoing: index <= 3 ? "Completed search and submitted evidence to parent synthesis." : "Running targeted web search.", + spawnedBy: { + id: "research-acme-exec-comp", + type: "deep_research" as const, + reason: "Objective decomposition required parallel evidence streams.", + }, + spawnedAt: new Date(Date.parse("2026-03-07T18:35:00.000Z") + index * 6_000).toISOString(), + spawnedChildren: [], + cost: { actualUsd: 0.02 + index * 0.01, estimatedTotalUsd: 0.06, remainingBudgetUsd: 69.3 - index * 0.02 }, + lifecycle: { + currentState: index <= 3 ? "complete" : "active", + lastTransitionAt: new Date(Date.parse("2026-03-07T18:35:00.000Z") + index * 7_000).toISOString(), + transitions: [ + { + state: "active", + changedAt: new Date(Date.parse("2026-03-07T18:35:00.000Z") + index * 6_000).toISOString(), + reason: "Search spawned.", + }, + ...(index <= 3 + ? [ + { + state: "complete" as const, + changedAt: new Date(Date.parse("2026-03-07T18:35:28.000Z") + index * 6_000).toISOString(), + reason: "Search completed and attached citations.", + }, + ] + : []), + ], + }, + provenance: { + source: "deep_research" as const, + runId: `run_2981_s${index + 1}`, + taskGroupId: "tg_8921", + citations: [], + }, + rulesEvaluation: { + signalStrength: 0.8, + threshold: 0.7, + budgetGate: "pass" as const, + deduplication: "pass" as const, + rateLimit: "pass" as const, + depthLimit: "pass" as const, + scopeCheck: "pass" as const, + decision: "allowed" as const, + decisionReason: "Child search accepted under active deep research run.", + }, + })), +]; + +const edges: ObserveGraphSnapshot["edges"] = [ + { id: "edge-campaign-sec", source: "campaign-seed", target: "monitor-acme-sec", relation: "seeded", reasonSummary: "Seed monitor created", createdAt: "2026-03-07T17:30:06.000Z" }, + { id: "edge-campaign-news", source: "campaign-seed", target: "monitor-acme-news", relation: "seeded", reasonSummary: "Seed monitor created", createdAt: "2026-03-07T17:30:08.000Z" }, + { id: "edge-campaign-web", source: "campaign-seed", target: "monitor-acme-web", relation: "seeded", reasonSummary: "Seed monitor created", createdAt: "2026-03-07T17:30:10.000Z" }, + { id: "edge-campaign-social", source: "campaign-seed", target: "monitor-acme-social", relation: "seeded", reasonSummary: "Seed monitor created", createdAt: "2026-03-07T17:30:12.000Z" }, + { + id: "edge-sec-research", + source: "monitor-acme-sec", + target: "research-acme-exec-comp", + relation: "investigated", + reasonSummary: "8-K compensation anomaly triggered deep research", + createdAt: "2026-03-07T18:34:10.000Z", + confidence: 0.85, + }, + ...[ + "search-acme-board-dispute", + "search-acme-analyst-reaction", + "search-acme-litigation", + "search-acme-subsidiary", + "search-acme-governance", + "search-acme-employee-exit", + ].map((id, index) => ({ + id: `edge-research-${index}`, + source: "research-acme-exec-comp", + target: id, + relation: "spawned" as const, + reasonSummary: `Parallel search child ${index + 1} launched`, + createdAt: new Date(Date.parse("2026-03-07T18:35:00.000Z") + index * 6_000).toISOString(), + confidence: 0.82, + })), + { + id: "edge-research-enrich", + source: "research-acme-exec-comp", + target: "enrich-acme-board", + relation: "enriched", + reasonSummary: "Structured board extraction requested", + createdAt: "2026-03-07T18:35:31.000Z", + confidence: 0.82, + }, + { + id: "edge-research-monitor-y", + source: "research-acme-exec-comp", + target: "monitor-subsidiary-y", + relation: "discovered", + reasonSummary: "Subsidiary Y discovered and escalated to long-term monitoring", + createdAt: "2026-03-07T18:36:04.000Z", + confidence: 0.88, + }, +]; + +const timeline = [ + { + id: "evt-1", + happenedAt: "2026-03-07T17:30:00.000Z", + nodeId: "campaign-seed", + nodeType: "campaign", + state: "active", + summary: "Campaign launched and initial monitor topology seeded.", + }, + { + id: "evt-2", + happenedAt: "2026-03-07T18:34:00.000Z", + nodeId: "monitor-acme-sec", + nodeType: "monitor", + state: "triggered", + summary: "SEC monitor fired on anomalous 8-K compensation filing (0.85 confidence).", + }, + { + id: "evt-3", + happenedAt: "2026-03-07T18:34:10.000Z", + nodeId: "research-acme-exec-comp", + nodeType: "deep_research", + state: "spawning", + summary: "Deep research spawned from monitor trigger with risk-impact objective.", + }, + { + id: "evt-4", + happenedAt: "2026-03-07T18:35:31.000Z", + nodeId: "enrich-acme-board", + nodeType: "enrichment", + state: "active", + summary: "Board-member enrichment task launched for structured governance extraction.", + }, + { + id: "evt-5", + happenedAt: "2026-03-07T18:36:04.000Z", + nodeId: "monitor-subsidiary-y", + nodeType: "monitor", + state: "spawning", + summary: "New monitor created for discovered Subsidiary Y entity.", + }, +] as ObserveGraphSnapshot["timeline"]; + +export const observeMockSnapshot: ObserveGraphSnapshot = { + campaignId: CAMPAIGN_ID, + campaignName: "Q1 Procurement Autonomous Risk Intelligence", + generatedAt: "2026-03-07T18:36:10.000Z", + budget: { + totalUsd: 120, + spentUsd: 50.53, + remainingUsd: 69.47, + }, + nodes, + edges, + timeline, + transportPhases: [ + { + id: "snapshot", + title: "Snapshot Mode", + status: "available", + details: "Static graph from latest orchestration checkpoint. Deterministic and safe for demos.", + }, + { + id: "replay", + title: "Replay Mode", + status: "partial", + details: "Chronological event playback from audit lineage with deterministic graph reconstruction.", + }, + { + id: "live", + title: "Live Mode", + status: "planned", + details: "Realtime stream over WebSocket/SSE with reconnection and event catchup semantics.", + }, + ], +}; + +export const observeNodeTypeLabels = Object.freeze({ + campaign: typeLabel("campaign"), + monitor: typeLabel("monitor"), + search: typeLabel("search"), + deep_research: typeLabel("deep_research"), + enrichment: typeLabel("enrichment"), + find_all: typeLabel("find_all"), + cluster: typeLabel("cluster"), +}); diff --git a/typescript-recipes/parallel-n8n-procurement/dashboard/lib/observe-types.ts b/typescript-recipes/parallel-n8n-procurement/dashboard/lib/observe-types.ts new file mode 100644 index 0000000..a0c4b3a --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/dashboard/lib/observe-types.ts @@ -0,0 +1,145 @@ +import type { Edge, Node } from "@xyflow/react"; + +export type ObserveNodeType = "campaign" | "monitor" | "search" | "deep_research" | "enrichment" | "find_all" | "cluster"; + +export type ObserveNodeState = + | "spawning" + | "active" + | "triggered" + | "complete" + | "failed" + | "paused" + | "budget_blocked"; + +export type ObserveEdgeRelation = "seeded" | "spawned" | "investigated" | "enriched" | "discovered"; + +export type ObserveSource = "deep_research" | "monitor_event" | "adhoc"; + +export interface ObserveCitation { + title: string; + url: string; + confidence: number; + excerpt?: string; +} + +export interface ObserveLifecycleTransition { + state: ObserveNodeState; + changedAt: string; + reason: string; +} + +export interface ObserveNodeCost { + actualUsd: number; + estimatedTotalUsd: number; + remainingBudgetUsd: number; +} + +export interface ObserveSpawnReference { + id: string; + type: ObserveNodeType; + reason: string; +} + +export interface ObserveNodeProvenance { + source: ObserveSource; + runId?: string; + taskGroupId?: string; + monitorId?: string; + eventGroupId?: string; + signalStrength?: number; + threshold?: number; + citations: ObserveCitation[]; +} + +export interface ObserveRulesEvaluation { + signalStrength: number; + threshold: number; + budgetGate: "pass" | "queued" | "blocked"; + deduplication: "pass" | "blocked"; + rateLimit: "pass" | "blocked"; + depthLimit: "pass" | "blocked"; + scopeCheck: "pass" | "blocked"; + decision: "allowed" | "queued" | "blocked"; + decisionReason: string; +} + +export interface ObserveTimelineEvent { + id: string; + happenedAt: string; + nodeId: string; + nodeType: ObserveNodeType; + state: ObserveNodeState; + summary: string; +} + +export interface ObserveTransportPhase { + id: "snapshot" | "replay" | "live"; + title: string; + status: "available" | "partial" | "planned"; + details: string; +} + +export interface ObserveNodeData extends Record { + id: string; + type: ObserveNodeType; + state: ObserveNodeState; + title: string; + subtitle: string; + campaignId: string; + vendorId?: string; + parentId?: string; + childIds: string[]; + whyThisNodeExists: string; + whatItIsDoing: string; + spawnedBy?: ObserveSpawnReference; + spawnedAt: string; + spawnedChildren: ObserveSpawnReference[]; + cost: ObserveNodeCost; + lifecycle: { + currentState: ObserveNodeState; + lastTransitionAt: string; + transitions: ObserveLifecycleTransition[]; + }; + provenance: ObserveNodeProvenance; + rulesEvaluation: ObserveRulesEvaluation; +} + +export interface ObserveEdgeData extends Record { + relation: ObserveEdgeRelation; + reasonSummary: string; + createdAt: string; + confidence?: number; +} + +export interface ObserveGraphSnapshot { + campaignId: string; + campaignName: string; + generatedAt: string; + budget: { + totalUsd: number; + spentUsd: number; + remainingUsd: number; + }; + nodes: ObserveNodeData[]; + edges: Array<{ + id: string; + source: string; + target: string; + relation: ObserveEdgeRelation; + reasonSummary: string; + createdAt: string; + confidence?: number; + }>; + timeline: ObserveTimelineEvent[]; + transportPhases: ObserveTransportPhase[]; +} + +export interface ObserveFilters { + nodeTypes: ObserveNodeType[]; + nodeStates: ObserveNodeState[]; + maxDepth: number; + hideCompleted: boolean; +} + +export type ObserveFlowNode = Node; +export type ObserveFlowEdge = Edge; diff --git a/typescript-recipes/parallel-n8n-procurement/dashboard/lib/portfolio-mutations.ts b/typescript-recipes/parallel-n8n-procurement/dashboard/lib/portfolio-mutations.ts new file mode 100644 index 0000000..859c20f --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/dashboard/lib/portfolio-mutations.ts @@ -0,0 +1,76 @@ +import type { MonitoringPriority, RiskLevel } from "@/lib/dashboard-types"; + +export const DASHBOARD_MUTATION_URL_ENV = "PROCUREMENT_DASHBOARD_MUTATION_URL"; +export const DASHBOARD_WRITE_TOKEN_ENV = "PROCUREMENT_DASHBOARD_WRITE_TOKEN"; +export const DASHBOARD_WRITE_TOKEN_HEADER = "x-procurement-dashboard-token"; + +export type PortfolioMutationAction = "addVendor" | "uploadVendors" | "resetSeedVendors"; + +export interface PortfolioMutationVendorInput { + vendorName: string; + vendorDomain: string; + vendorCategory: string; + relationshipOwner: string; + region: string; + monitoringPriority: MonitoringPriority; + riskLevel: RiskLevel; + score: number; + nextResearchDate: string; +} + +export type PortfolioMutationRequest = + | { action: "addVendor"; vendor: PortfolioMutationVendorInput } + | { action: "uploadVendors"; vendors: PortfolioMutationVendorInput[] } + | { action: "resetSeedVendors" }; + +export interface PortfolioMutationResponse { + ok: boolean; + action?: PortfolioMutationAction; + affected?: number; + error?: string; +} + +export function isPortfolioMutationRequest(value: unknown): value is PortfolioMutationRequest { + if (!isRecord(value) || typeof value.action !== "string") return false; + + if (value.action === "resetSeedVendors") return true; + + if (value.action === "addVendor") { + return isMutationVendor(value.vendor); + } + + if (value.action === "uploadVendors") { + return Array.isArray(value.vendors) && value.vendors.length > 0 && value.vendors.every(isMutationVendor); + } + + return false; +} + +function isMutationVendor(value: unknown): value is PortfolioMutationVendorInput { + if (!isRecord(value)) return false; + + return ( + typeof value.vendorName === "string" && + typeof value.vendorDomain === "string" && + typeof value.vendorCategory === "string" && + typeof value.relationshipOwner === "string" && + typeof value.region === "string" && + isMonitoringPriority(value.monitoringPriority) && + isRiskLevel(value.riskLevel) && + typeof value.score === "number" && + Number.isFinite(value.score) && + typeof value.nextResearchDate === "string" + ); +} + +function isMonitoringPriority(value: unknown): value is MonitoringPriority { + return value === "high" || value === "medium" || value === "low"; +} + +function isRiskLevel(value: unknown): value is RiskLevel { + return value === "LOW" || value === "MEDIUM" || value === "HIGH" || value === "CRITICAL"; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/typescript-recipes/parallel-n8n-procurement/dashboard/next-env.d.ts b/typescript-recipes/parallel-n8n-procurement/dashboard/next-env.d.ts new file mode 100644 index 0000000..c4b7818 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/dashboard/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import "./.next/dev/types/routes.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/typescript-recipes/parallel-n8n-procurement/dashboard/next.config.ts b/typescript-recipes/parallel-n8n-procurement/dashboard/next.config.ts new file mode 100644 index 0000000..6341ee9 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/dashboard/next.config.ts @@ -0,0 +1,11 @@ +import path from "node:path"; +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + reactStrictMode: true, + turbopack: { + root: path.join(__dirname), + }, +}; + +export default nextConfig; diff --git a/typescript-recipes/parallel-n8n-procurement/dashboard/package-lock.json b/typescript-recipes/parallel-n8n-procurement/dashboard/package-lock.json new file mode 100644 index 0000000..3d6442d --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/dashboard/package-lock.json @@ -0,0 +1,1279 @@ +{ + "name": "parallel-procurement-dashboard", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "parallel-procurement-dashboard", + "version": "0.1.0", + "dependencies": { + "@xyflow/react": "^12.10.1", + "lucide-react": "^0.577.0", + "next": "^16.2.4", + "react": "^19.2.4", + "react-dom": "^19.2.4" + }, + "devDependencies": { + "@playwright/test": "^1.59.1", + "@types/node": "^25.3.5", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "typescript": "^5.9.3" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@next/env": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.4.tgz", + "integrity": "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.4.tgz", + "integrity": "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.4.tgz", + "integrity": "sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.4.tgz", + "integrity": "sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.4.tgz", + "integrity": "sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.4.tgz", + "integrity": "sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.4.tgz", + "integrity": "sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.4.tgz", + "integrity": "sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz", + "integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/node": { + "version": "25.3.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.5.tgz", + "integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@xyflow/react": { + "version": "12.10.1", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.1.tgz", + "integrity": "sha512-5eSWtIK/+rkldOuFbOOz44CRgQRjtS9v5nufk77DV+XBnfCGL9HAQ8PG00o2ZYKqkEU/Ak6wrKC95Tu+2zuK3Q==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.75", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.75", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.75.tgz", + "integrity": "sha512-iXs+AGFLi8w/VlAoc/iSxk+CxfT6o64Uw/k0CKASOPqjqz6E0rb5jFZgJtXGZCpfQI6OQpu5EnumP5fGxQheaQ==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001777", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz", + "integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lucide-react": { + "version": "0.577.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.577.0.tgz", + "integrity": "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.4.tgz", + "integrity": "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==", + "license": "MIT", + "dependencies": { + "@next/env": "16.2.4", + "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.9.19", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.2.4", + "@next/swc-darwin-x64": "16.2.4", + "@next/swc-linux-arm64-gnu": "16.2.4", + "@next/swc-linux-arm64-musl": "16.2.4", + "@next/swc-linux-x64-gnu": "16.2.4", + "@next/swc-linux-x64-musl": "16.2.4", + "@next/swc-win32-arm64-msvc": "16.2.4", + "@next/swc-win32-x64-msvc": "16.2.4", + "sharp": "^0.34.5" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/postcss": { + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", + "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + } + } +} diff --git a/typescript-recipes/parallel-n8n-procurement/dashboard/package.json b/typescript-recipes/parallel-n8n-procurement/dashboard/package.json new file mode 100644 index 0000000..dc30c71 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/dashboard/package.json @@ -0,0 +1,29 @@ +{ + "name": "parallel-procurement-dashboard", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "check": "tsc --noEmit --project tsconfig.check.json", + "test:e2e": "playwright test" + }, + "dependencies": { + "@xyflow/react": "^12.10.1", + "lucide-react": "^0.577.0", + "next": "^16.2.4", + "react": "^19.2.4", + "react-dom": "^19.2.4" + }, + "overrides": { + "postcss": "^8.5.10" + }, + "devDependencies": { + "@playwright/test": "^1.59.1", + "@types/node": "^25.3.5", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "typescript": "^5.9.3" + } +} diff --git a/typescript-recipes/parallel-n8n-procurement/dashboard/playwright.config.ts b/typescript-recipes/parallel-n8n-procurement/dashboard/playwright.config.ts new file mode 100644 index 0000000..2593905 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/dashboard/playwright.config.ts @@ -0,0 +1,45 @@ +import { defineConfig, devices } from "@playwright/test"; + +const appPort = 3107; +const mockPort = 4111; + +export default defineConfig({ + testDir: "./tests/e2e", + timeout: 30_000, + expect: { + timeout: 10_000, + }, + fullyParallel: false, + forbidOnly: Boolean(process.env.CI), + reporter: [["list"]], + use: { + baseURL: `http://127.0.0.1:${appPort}`, + trace: "on-first-retry", + }, + webServer: [ + { + command: "node tests/e2e/mock-snapshot-server.mjs", + url: `http://127.0.0.1:${mockPort}/health`, + env: { + PROCUREMENT_DASHBOARD_WRITE_TOKEN: "test-write-token", + }, + reuseExistingServer: !process.env.CI, + }, + { + command: `npm run dev -- --hostname 127.0.0.1 --port ${appPort}`, + url: `http://127.0.0.1:${appPort}`, + env: { + PROCUREMENT_DASHBOARD_SNAPSHOT_URL: `http://127.0.0.1:${mockPort}/snapshot`, + PROCUREMENT_DASHBOARD_MUTATION_URL: `http://127.0.0.1:${mockPort}/mutation`, + PROCUREMENT_DASHBOARD_WRITE_TOKEN: "test-write-token", + }, + reuseExistingServer: !process.env.CI, + }, + ], + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}); diff --git a/typescript-recipes/parallel-n8n-procurement/dashboard/tests/e2e/dashboard.spec.ts b/typescript-recipes/parallel-n8n-procurement/dashboard/tests/e2e/dashboard.spec.ts new file mode 100644 index 0000000..ffd531f --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/dashboard/tests/e2e/dashboard.spec.ts @@ -0,0 +1,214 @@ +import { Buffer } from "node:buffer"; +import { expect, test, type Page } from "@playwright/test"; + +const mockBaseUrl = "http://127.0.0.1:4111"; +const mockWriteToken = "test-write-token"; + +function collectUnexpectedBrowserErrors(page: Page) { + const errors: string[] = []; + + page.on("pageerror", (error) => { + errors.push(error.message); + }); + + page.on("console", (message) => { + if (message.type() === "error") { + errors.push(message.text()); + } + }); + + return () => expect(errors).toEqual([]); +} + +test.beforeEach(async ({ request }) => { + const response = await request.post(`${mockBaseUrl}/mutation`, { + headers: { "x-procurement-dashboard-token": mockWriteToken }, + data: { action: "resetSeedVendors" }, + }); + expect(response.ok()).toBeTruthy(); +}); + +test("dashboard loads from the mocked live snapshot and navigates primary surfaces", async ({ page }) => { + const assertNoBrowserErrors = collectUnexpectedBrowserErrors(page); + + await page.goto("/"); + await expect(page.getByRole("heading", { name: "Vendor intelligence overview" })).toBeVisible(); + await expect(page.getByText("GlobalTech Solutions").first()).toBeVisible(); + await expect(page.getByText("Portfolio risk posture")).toBeVisible(); + + await page.getByRole("link", { name: "Attention", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Immediate attention queue" })).toBeVisible(); + await expect(page.getByText("Validate breach scope")).toBeVisible(); + + await page.getByRole("link", { name: "Portfolio", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Portfolio" })).toBeVisible(); + + await page.getByRole("link", { name: "Feed", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Feed" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Download feed package" })).toBeEnabled(); + await page.getByRole("button", { name: "Share to Slack" }).click(); + + await page.getByRole("link", { name: "Observe", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Observe" })).toBeVisible(); + await expect(page.getByText("Topology size")).toBeVisible(); + + await page.goto("/vendors/globaltech-solutions"); + await expect(page.getByRole("heading", { name: "GlobalTech Solutions" })).toBeVisible(); + await expect(page.getByText("Recommendation")).toBeVisible(); + + assertNoBrowserErrors(); +}); + +test("portfolio manager adds, persists, and resets vendors", async ({ page }) => { + const assertNoBrowserErrors = collectUnexpectedBrowserErrors(page); + + await page.goto("/portfolio"); + await page.getByRole("button", { name: /manage vendors/i }).click(); + await page.getByRole("button", { name: "Add vendor" }).click(); + + await page.getByPlaceholder("Vendor name").fill("Atlas Components"); + await page.getByPlaceholder("Domain").fill("atlas-components.example"); + await page.getByPlaceholder("Category").fill("manufacturing"); + await page.getByPlaceholder("Owner").fill("Casey Lee"); + await page.getByPlaceholder("Region").fill("North America"); + await page.locator("select").first().selectOption("high"); + await page.locator("select").nth(1).selectOption("HIGH"); + await page.getByPlaceholder("Score").fill("82"); + await page.locator('input[type="date"]').fill("2026-05-01"); + await page.getByRole("button", { name: "Save vendor" }).click(); + + await expect(page.getByText("Atlas Components saved through n8n.")).toBeVisible(); + await expect(page.getByRole("link", { name: /Atlas Components/ })).toBeVisible(); + await page.reload(); + await expect(page.getByRole("link", { name: /Atlas Components/ })).toBeVisible(); + + await page.getByRole("button", { name: /manage vendors/i }).click(); + await page.getByRole("button", { name: "Reset demo data" }).click(); + await expect(page.getByText("Demo portfolio restored from the backend seed set.")).toBeVisible(); + await expect(page.getByRole("link", { name: /Atlas Components/ })).toHaveCount(0); + + assertNoBrowserErrors(); +}); + +test("portfolio manager uploads CSV vendors through the backend and resets them", async ({ page }) => { + const assertNoBrowserErrors = collectUnexpectedBrowserErrors(page); + const csv = [ + "vendorName,vendorDomain,vendorCategory,relationshipOwner,region,monitoringPriority,riskLevel,score,nextResearchDate", + "Bolt Industrial,bolt-industrial.example,manufacturing,Alex Kim,EMEA,medium,MEDIUM,64,2026-05-02", + "Lumen Parts,lumen-parts.example,logistics,Sam Rivera,APAC,low,LOW,24,2026-05-03", + ].join("\n"); + + await page.goto("/portfolio"); + await page.getByRole("button", { name: /manage vendors/i }).click(); + await page.getByRole("button", { name: "Upload CSV" }).click(); + await page.locator('input[type="file"]').setInputFiles({ + name: "portfolio-upload.csv", + mimeType: "text/csv", + buffer: Buffer.from(csv), + }); + + await expect(page.getByText("2 vendors uploaded through n8n.")).toBeVisible(); + await expect(page.getByText("Bolt Industrial")).toBeVisible(); + await expect(page.getByText("Lumen Parts")).toBeVisible(); + + await page.reload(); + await expect(page.getByText("Bolt Industrial")).toBeVisible(); + await expect(page.getByText("Lumen Parts")).toBeVisible(); + + await page.getByRole("button", { name: /manage vendors/i }).click(); + await page.getByRole("button", { name: "Reset demo data" }).click(); + await expect(page.getByText("Bolt Industrial")).toHaveCount(0); + await expect(page.getByText("Lumen Parts")).toHaveCount(0); + + assertNoBrowserErrors(); +}); + +test("portfolio mutation failures show an actionable error state", async ({ page }) => { + const assertNoBrowserErrors = collectUnexpectedBrowserErrors(page); + + await page.goto("/portfolio"); + await page.getByRole("button", { name: /manage vendors/i }).click(); + await page.getByRole("button", { name: "Add vendor" }).click(); + + await page.getByPlaceholder("Vendor name").fill("Mutation Failure"); + await page.getByPlaceholder("Domain").fill("mutation-failure.example"); + await page.getByPlaceholder("Category").fill("technology"); + await page.getByPlaceholder("Owner").fill("Riley Chen"); + await page.getByPlaceholder("Region").fill("North America"); + await page.locator("select").first().selectOption("medium"); + await page.locator("select").nth(1).selectOption("MEDIUM"); + await page.getByPlaceholder("Score").fill("50"); + await page.locator('input[type="date"]').fill("2026-05-04"); + await page.getByRole("button", { name: "Save vendor" }).click(); + + await expect(page.getByText("Forced mutation failure from mock n8n endpoint.")).toBeVisible(); + await expect(page.getByRole("link", { name: /Mutation Failure/ })).toHaveCount(0); + + assertNoBrowserErrors(); +}); + +test("feed and observe controls respond", async ({ page }) => { + const assertNoBrowserErrors = collectUnexpectedBrowserErrors(page); + + await page.goto("/feed"); + await page.getByRole("button", { name: "Download feed package" }).click(); + await page.getByRole("button", { name: "Share to Slack" }).click(); + await expect(page.getByText("UI-only preview")).toBeVisible(); + + await page.goto("/observe"); + await page.getByRole("button", { name: "Snapshot" }).first().click(); + await expect(page.getByText("Full topology snapshot view.")).toBeVisible(); + await page.getByRole("button", { name: "Replay" }).first().click(); + await page.getByRole("button", { name: "Play replay" }).first().click(); + await expect(page.getByRole("button", { name: "Pause replay" }).first()).toBeVisible(); + + assertNoBrowserErrors(); +}); + +test("mock snapshot endpoint satisfies the dashboard API contract", async ({ request }) => { + const missingToken = await request.post(`${mockBaseUrl}/mutation`, { + data: { action: "resetSeedVendors" }, + }); + expect(missingToken.status()).toBe(401); + + const mutation = await request.post(`${mockBaseUrl}/mutation`, { + headers: { "x-procurement-dashboard-token": mockWriteToken }, + data: { + action: "addVendor", + vendor: { + vendorName: "Contract Check Systems", + vendorDomain: "contract-check.example", + vendorCategory: "technology", + relationshipOwner: "QA", + region: "Global", + monitoringPriority: "medium", + riskLevel: "MEDIUM", + score: 58, + nextResearchDate: "2026-05-05", + }, + }, + }); + expect(mutation.ok()).toBeTruthy(); + + const response = await request.get(`${mockBaseUrl}/snapshot`); + expect(response.ok()).toBeTruthy(); + + const body = await response.json(); + expect(body.lastUpdated).toEqual(expect.any(String)); + expect(body.metrics).toEqual(expect.any(Array)); + expect(body.riskDistribution).toEqual(expect.any(Array)); + expect(body.researchSummary.totalDue).toEqual(expect.any(Number)); + expect(body.health.totalMonitors).toEqual(expect.any(Number)); + expect(body.feed[0]).toEqual(expect.objectContaining({ vendorName: expect.any(String), severity: expect.any(String) })); + expect(body.actionQueue[0]).toEqual(expect.objectContaining({ vendorName: expect.any(String), riskLevel: expect.any(String) })); + expect(body.vendors.some((vendor: { vendorName: string }) => vendor.vendorName === "Contract Check Systems")).toBe(true); + expect(body.vendors[0]).toEqual( + expect.objectContaining({ + id: expect.any(String), + vendorName: expect.any(String), + riskLevel: expect.any(String), + dimensions: expect.any(Array), + monitors: expect.any(Array), + }), + ); +}); diff --git a/typescript-recipes/parallel-n8n-procurement/dashboard/tests/e2e/fixtures/snapshot.json b/typescript-recipes/parallel-n8n-procurement/dashboard/tests/e2e/fixtures/snapshot.json new file mode 100644 index 0000000..db8d1ad --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/dashboard/tests/e2e/fixtures/snapshot.json @@ -0,0 +1,339 @@ +{ + "lastUpdated": "2026-04-29T15:30:00Z", + "metrics": [ + { + "label": "Portfolio risk posture", + "value": "1 CRITICAL / 1 HIGH", + "trend": "2 vendors require immediate review", + "tone": "critical" + }, + { + "label": "Research cadence", + "value": "3 due today", + "trend": "2 audit log entries recorded today", + "tone": "warning" + }, + { + "label": "Monitor fleet health", + "value": "5 active", + "trend": "Webhook healthy, live snapshot generated", + "tone": "positive" + }, + { + "label": "Action queue", + "value": "2 escalations", + "trend": "1 due in the next 12h", + "tone": "default" + } + ], + "riskDistribution": [ + { "label": "LOW", "count": 0 }, + { "label": "MEDIUM", "count": 1 }, + { "label": "HIGH", "count": 1 }, + { "label": "CRITICAL", "count": 1 } + ], + "researchSummary": { + "totalDue": 3, + "totalResearched": 2, + "totalFailed": 0, + "adverseCount": 2, + "batchesExecuted": 1, + "duration": "live" + }, + "health": { + "totalMonitors": 5, + "activeCount": 5, + "failedCount": 0, + "orphanCount": 0, + "recreated": 0, + "webhookHealthy": true + }, + "feed": [ + { + "vendorName": "GlobalTech Solutions", + "title": "Ransomware disclosure under active investigation", + "severity": "CRITICAL", + "timestamp": "46 minutes ago", + "detail": "The vendor confirmed restricted access to internal systems and initiated incident response.", + "sourceUrl": "https://security.example.com/globaltech-ransomware" + }, + { + "vendorName": "Acme Corp", + "title": "Credit downgrade and covenant pressure", + "severity": "HIGH", + "timestamp": "2 hours ago", + "detail": "A downgrade flagged worsening leverage and short-term covenant pressure.", + "sourceUrl": "https://finance.example.com/acme-downgrade" + } + ], + "actionQueue": [ + { + "vendorName": "GlobalTech Solutions", + "owner": "Security operations", + "deadline": "Due in 12h", + "action": "Validate breach scope, review contingency supplier path, and notify accountable stakeholders.", + "riskLevel": "CRITICAL" + }, + { + "vendorName": "Acme Corp", + "owner": "Procurement finance", + "deadline": "Due in 24h", + "action": "Update the vendor risk memo and confirm mitigation owner.", + "riskLevel": "HIGH" + } + ], + "vendors": [ + { + "id": "globaltech-solutions", + "vendorName": "GlobalTech Solutions", + "vendorDomain": "https://globaltech.example", + "vendorCategory": "technology", + "monitoringPriority": "high", + "relationshipOwner": "Priya Shah", + "region": "Global", + "riskLevel": "CRITICAL", + "overallRiskLevel": "CRITICAL", + "score": 94, + "actionRequired": true, + "adverseFlag": true, + "recommendation": "suspend_relationship", + "summary": "GlobalTech Solutions has an active ransomware disclosure with customer exposure still under investigation.", + "movement": "+18 live snapshot", + "lastAssessmentDate": "2026-04-29", + "nextResearchDate": "2026-04-29", + "triggeredOverrides": ["active_data_breach"], + "dimensions": [ + { + "key": "financial_health", + "label": "Financial health", + "severity": "LOW", + "status": "stable", + "findings": "No active financial findings in the current monitoring window." + }, + { + "key": "legal_regulatory", + "label": "Legal & regulatory", + "severity": "MEDIUM", + "status": "watch", + "findings": "Customer notification obligations may apply." + }, + { + "key": "cybersecurity", + "label": "Cybersecurity", + "severity": "CRITICAL", + "status": "watch", + "findings": "Ransomware disclosure remains under active investigation." + }, + { + "key": "leadership_governance", + "label": "Leadership & governance", + "severity": "LOW", + "status": "stable", + "findings": "No active governance findings in the current monitoring window." + }, + { + "key": "esg_reputation", + "label": "ESG & reputation", + "severity": "LOW", + "status": "stable", + "findings": "No active reputation findings in the current monitoring window." + } + ], + "adverseEvents": [ + { + "title": "Ransomware disclosure under active investigation", + "date": "2026-04-29", + "category": "cybersecurity", + "severity": "CRITICAL", + "description": "The vendor confirmed restricted access to internal systems and initiated incident response.", + "sourceUrl": "https://security.example.com/globaltech-ransomware" + } + ], + "evidence": [ + { + "title": "Ransomware disclosure under active investigation", + "publication": "Security desk", + "publishedAt": "2026-04-29", + "materiality": "Customer exposure and supplier continuity are not yet confirmed.", + "href": "https://security.example.com/globaltech-ransomware" + } + ], + "monitors": [ + { + "dimension": "cybersecurity", + "cadence": "daily", + "status": "active", + "query": "\"GlobalTech Solutions\" ransomware vendor risk", + "lastEvent": "46 minutes ago" + }, + { + "dimension": "legal_regulatory", + "cadence": "weekly", + "status": "active", + "query": "\"GlobalTech Solutions\" customer notification regulatory risk", + "lastEvent": "46 minutes ago" + } + ] + }, + { + "id": "acme-corp", + "vendorName": "Acme Corp", + "vendorDomain": "https://acme.example", + "vendorCategory": "technology", + "monitoringPriority": "high", + "relationshipOwner": "Jordan Hall", + "region": "North America", + "riskLevel": "HIGH", + "overallRiskLevel": "HIGH", + "score": 78, + "actionRequired": true, + "adverseFlag": true, + "recommendation": "initiate_contingency", + "summary": "Acme Corp shows deteriorating financial signals and short-term covenant pressure.", + "movement": "+11 live snapshot", + "lastAssessmentDate": "2026-04-29", + "nextResearchDate": "2026-04-29", + "triggeredOverrides": [], + "dimensions": [ + { + "key": "financial_health", + "label": "Financial health", + "severity": "HIGH", + "status": "watch", + "findings": "Credit downgrade flagged worsening leverage." + }, + { + "key": "legal_regulatory", + "label": "Legal & regulatory", + "severity": "LOW", + "status": "stable", + "findings": "No active legal findings in the current monitoring window." + }, + { + "key": "cybersecurity", + "label": "Cybersecurity", + "severity": "LOW", + "status": "stable", + "findings": "No active cyber findings in the current monitoring window." + }, + { + "key": "leadership_governance", + "label": "Leadership & governance", + "severity": "LOW", + "status": "stable", + "findings": "No active governance findings in the current monitoring window." + }, + { + "key": "esg_reputation", + "label": "ESG & reputation", + "severity": "LOW", + "status": "stable", + "findings": "No active reputation findings in the current monitoring window." + } + ], + "adverseEvents": [ + { + "title": "Credit downgrade and covenant pressure", + "date": "2026-04-29", + "category": "financial_health", + "severity": "HIGH", + "description": "A downgrade flagged worsening leverage and short-term covenant pressure.", + "sourceUrl": "https://finance.example.com/acme-downgrade" + } + ], + "evidence": [ + { + "title": "Credit downgrade and covenant pressure", + "publication": "Finance desk", + "publishedAt": "2026-04-29", + "materiality": "Backup supplier readiness should be reviewed.", + "href": "https://finance.example.com/acme-downgrade" + } + ], + "monitors": [ + { + "dimension": "financial_health", + "cadence": "daily", + "status": "active", + "query": "\"Acme Corp\" credit downgrade covenant vendor risk", + "lastEvent": "2 hours ago" + } + ] + }, + { + "id": "bluepeak-logistics", + "vendorName": "BluePeak Logistics", + "vendorDomain": "https://bluepeak.example", + "vendorCategory": "logistics", + "monitoringPriority": "medium", + "relationshipOwner": "Morgan Chen", + "region": "United States", + "riskLevel": "MEDIUM", + "overallRiskLevel": "MEDIUM", + "score": 52, + "actionRequired": false, + "adverseFlag": false, + "recommendation": "escalate_review", + "summary": "BluePeak Logistics remains serviceable with labor disruption monitoring still active.", + "movement": "+3 live snapshot", + "lastAssessmentDate": "2026-04-28", + "nextResearchDate": "2026-04-29", + "triggeredOverrides": [], + "dimensions": [ + { + "key": "financial_health", + "label": "Financial health", + "severity": "LOW", + "status": "stable", + "findings": "No active financial findings in the current monitoring window." + }, + { + "key": "legal_regulatory", + "label": "Legal & regulatory", + "severity": "LOW", + "status": "stable", + "findings": "No active legal findings in the current monitoring window." + }, + { + "key": "cybersecurity", + "label": "Cybersecurity", + "severity": "LOW", + "status": "stable", + "findings": "No active cyber findings in the current monitoring window." + }, + { + "key": "leadership_governance", + "label": "Leadership & governance", + "severity": "LOW", + "status": "stable", + "findings": "No active governance findings in the current monitoring window." + }, + { + "key": "esg_reputation", + "label": "ESG & reputation", + "severity": "MEDIUM", + "status": "watch", + "findings": "Labor disruption monitoring remains active." + } + ], + "adverseEvents": [], + "evidence": [], + "monitors": [ + { + "dimension": "esg_reputation", + "cadence": "weekly", + "status": "active", + "query": "\"BluePeak Logistics\" labor disruption vendor risk", + "lastEvent": "1 day ago" + }, + { + "dimension": "financial_health", + "cadence": "monthly", + "status": "active", + "query": "\"BluePeak Logistics\" financial health vendor risk", + "lastEvent": "No event yet" + } + ] + } + ] +} diff --git a/typescript-recipes/parallel-n8n-procurement/dashboard/tests/e2e/mock-snapshot-server.mjs b/typescript-recipes/parallel-n8n-procurement/dashboard/tests/e2e/mock-snapshot-server.mjs new file mode 100644 index 0000000..773ccc8 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/dashboard/tests/e2e/mock-snapshot-server.mjs @@ -0,0 +1,224 @@ +import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const port = Number(process.env.SNAPSHOT_MOCK_PORT || 4111); +const writeToken = process.env.PROCUREMENT_DASHBOARD_WRITE_TOKEN || "test-write-token"; +const here = dirname(fileURLToPath(import.meta.url)); +const fixturePath = join(here, "fixtures", "snapshot.json"); +const seedSnapshot = JSON.parse(await readFile(fixturePath, "utf8")); + +let snapshot = clone(seedSnapshot); + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +function send(response, status, body) { + response.writeHead(status, { + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET,POST,OPTIONS", + "access-control-allow-headers": "content-type,x-procurement-dashboard-token", + "content-type": "application/json", + }); + response.end(JSON.stringify(body)); +} + +function readJson(request) { + return new Promise((resolve, reject) => { + let body = ""; + request.setEncoding("utf8"); + request.on("data", (chunk) => { + body += chunk; + }); + request.on("end", () => { + try { + resolve(body ? JSON.parse(body) : {}); + } catch (error) { + reject(error); + } + }); + request.on("error", reject); + }); +} + +function slugify(value) { + return String(value || "vendor") + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") || "vendor"; +} + +function normalizeRisk(value) { + const normalized = String(value || "").trim().toUpperCase(); + return ["LOW", "MEDIUM", "HIGH", "CRITICAL"].includes(normalized) ? normalized : "MEDIUM"; +} + +function recommendationFor(level) { + if (level === "CRITICAL") return "suspend_relationship"; + if (level === "HIGH") return "initiate_contingency"; + if (level === "MEDIUM") return "escalate_review"; + return "continue_monitoring"; +} + +function dimensionsFor(level) { + return [ + { key: "financial_health", label: "Financial health", severity: level, status: "watch", findings: "Dashboard write-back vendor pending review." }, + { key: "legal_regulatory", label: "Legal & regulatory", severity: "LOW", status: "stable", findings: "No active legal findings in the mock snapshot." }, + { key: "cybersecurity", label: "Cybersecurity", severity: "LOW", status: "stable", findings: "No active cyber findings in the mock snapshot." }, + { key: "leadership_governance", label: "Leadership & governance", severity: "LOW", status: "stable", findings: "No active governance findings in the mock snapshot." }, + { key: "esg_reputation", label: "ESG & reputation", severity: "LOW", status: "stable", findings: "No active reputation findings in the mock snapshot." }, + ]; +} + +function vendorFromInput(input) { + const riskLevel = normalizeRisk(input.riskLevel); + const vendorName = String(input.vendorName || "Unnamed vendor").trim(); + const domain = String(input.vendorDomain || `${slugify(vendorName)}.example`).trim(); + + return { + id: slugify(vendorName), + vendorName, + vendorDomain: domain.startsWith("http://") || domain.startsWith("https://") ? domain : `https://${domain}`, + vendorCategory: String(input.vendorCategory || "vendor").trim().toLowerCase().replace(/\s+/g, "_"), + monitoringPriority: input.monitoringPriority || "medium", + relationshipOwner: input.relationshipOwner || "Procurement", + region: input.region || "Global", + riskLevel, + overallRiskLevel: riskLevel, + score: Number(input.score) || 50, + actionRequired: riskLevel === "HIGH" || riskLevel === "CRITICAL", + adverseFlag: riskLevel !== "LOW", + recommendation: recommendationFor(riskLevel), + summary: `${vendorName} was written through the mocked n8n mutation endpoint.`, + movement: "+0 mock write", + lastAssessmentDate: "2026-04-29", + nextResearchDate: input.nextResearchDate || "2026-05-01", + triggeredOverrides: [], + dimensions: dimensionsFor(riskLevel), + adverseEvents: [], + evidence: [], + monitors: [], + }; +} + +function upsertVendor(vendor) { + const next = snapshot.vendors.filter((current) => current.id !== vendor.id); + next.push(vendor); + snapshot.vendors = next.sort((left, right) => right.score - left.score); + refreshDerivedFields(); +} + +function refreshDerivedFields() { + const levels = ["LOW", "MEDIUM", "HIGH", "CRITICAL"]; + const actionQueue = snapshot.vendors + .filter((vendor) => vendor.actionRequired) + .map((vendor) => ({ + vendorName: vendor.vendorName, + owner: vendor.riskLevel === "CRITICAL" ? "Security operations" : "Procurement finance", + deadline: vendor.riskLevel === "CRITICAL" ? "Due in 12h" : "Due in 24h", + action: vendor.riskLevel === "CRITICAL" + ? "Validate exposure, review contingency supplier path, and notify accountable stakeholders." + : "Update the vendor risk memo and confirm mitigation owner.", + riskLevel: vendor.riskLevel, + })); + + const criticalCount = snapshot.vendors.filter((vendor) => vendor.riskLevel === "CRITICAL").length; + const highCount = snapshot.vendors.filter((vendor) => vendor.riskLevel === "HIGH").length; + + snapshot = { + ...snapshot, + lastUpdated: new Date().toISOString(), + riskDistribution: levels.map((level) => ({ + label: level, + count: snapshot.vendors.filter((vendor) => vendor.riskLevel === level).length, + })), + actionQueue, + metrics: snapshot.metrics.map((metric) => { + if (metric.label === "Portfolio risk posture") { + return { + ...metric, + value: `${criticalCount} CRITICAL / ${highCount} HIGH`, + trend: `${actionQueue.length} vendors require immediate review`, + tone: actionQueue.length ? "critical" : "positive", + }; + } + if (metric.label === "Action queue") { + return { + ...metric, + value: `${actionQueue.length} escalations`, + trend: `${actionQueue.filter((item) => item.deadline.includes("12h")).length} due in the next 12h`, + tone: actionQueue.length ? "default" : "positive", + }; + } + return metric; + }), + }; +} + +const server = createServer(async (request, response) => { + if (request.method === "OPTIONS") { + send(response, 204, {}); + return; + } + + if (request.url === "/health") { + send(response, 200, { ok: true }); + return; + } + + if (request.url === "/snapshot" && request.method === "GET") { + send(response, 200, snapshot); + return; + } + + if (request.url === "/mutation" && request.method === "POST") { + if (request.headers["x-procurement-dashboard-token"] !== writeToken) { + send(response, 401, { ok: false, error: "missing or invalid dashboard write token" }); + return; + } + + let body; + try { + body = await readJson(request); + } catch { + send(response, 400, { ok: false, error: "invalid json" }); + return; + } + + const vendors = body.action === "addVendor" ? [body.vendor] : body.vendors; + if (Array.isArray(vendors) && vendors.some((vendor) => vendor?.vendorName === "Mutation Failure")) { + send(response, 500, { ok: false, error: "Forced mutation failure from mock n8n endpoint." }); + return; + } + + if (body.action === "addVendor" && body.vendor) { + upsertVendor(vendorFromInput(body.vendor)); + send(response, 200, { ok: true, action: body.action, affected: 1 }); + return; + } + + if (body.action === "uploadVendors" && Array.isArray(body.vendors)) { + body.vendors.forEach((vendor) => upsertVendor(vendorFromInput(vendor))); + send(response, 200, { ok: true, action: body.action, affected: body.vendors.length }); + return; + } + + if (body.action === "resetSeedVendors") { + snapshot = clone(seedSnapshot); + send(response, 200, { ok: true, action: body.action, affected: snapshot.vendors.length }); + return; + } + + send(response, 400, { ok: false, error: "unsupported mutation action" }); + return; + } + + send(response, 404, { ok: false, error: "not_found" }); +}); + +server.listen(port, "127.0.0.1", () => { + process.stdout.write(`snapshot mock listening on ${port}\n`); +}); diff --git a/typescript-recipes/parallel-n8n-procurement/dashboard/tsconfig.check.json b/typescript-recipes/parallel-n8n-procurement/dashboard/tsconfig.check.json new file mode 100644 index 0000000..45c9f4c --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/dashboard/tsconfig.check.json @@ -0,0 +1,20 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "incremental": false + }, + "include": [ + "next-env.d.ts", + "app/**/*.ts", + "app/**/*.tsx", + "components/**/*.ts", + "components/**/*.tsx", + "lib/**/*.ts", + "lib/**/*.tsx" + ], + "exclude": [ + "node_modules", + ".next", + "*.tsbuildinfo" + ] +} diff --git a/typescript-recipes/parallel-n8n-procurement/dashboard/tsconfig.json b/typescript-recipes/parallel-n8n-procurement/dashboard/tsconfig.json new file mode 100644 index 0000000..3f0b70d --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/dashboard/tsconfig.json @@ -0,0 +1,41 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": false, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": [ + "./*" + ] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/typescript-recipes/parallel-n8n-procurement/n8n-workflows/workflow-combined.json b/typescript-recipes/parallel-n8n-procurement/n8n-workflows/workflow-combined.json new file mode 100644 index 0000000..4a1df71 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/n8n-workflows/workflow-combined.json @@ -0,0 +1,1832 @@ +{ + "name": "Vendor Risk Monitoring — Combined Workflow", + "nodes": [ + { + "id": "node-1", + "name": "Sync: Daily Midnight Trigger", + "type": "n8n-nodes-base.scheduleTrigger", + "position": [ + 100, + -300 + ], + "typeVersion": 1.2, + "parameters": { + "rule": { + "interval": [ + { + "field": "hours", + "hoursInterval": 24, + "triggerAtHour": 0 + } + ] + } + } + }, + { + "id": "node-2", + "name": "Sync: Manual Trigger", + "type": "n8n-nodes-base.manualTrigger", + "position": [ + 100, + -100 + ], + "typeVersion": 1, + "parameters": {} + }, + { + "id": "node-3", + "name": "Sync: Read Vendor List", + "type": "n8n-nodes-base.googleSheets", + "position": [ + 340, + -300 + ], + "typeVersion": 4.5, + "parameters": { + "operation": "read", + "documentId": { + "__rl": true, + "mode": "id", + "value": "={{ $vars.GOOGLE_SHEET_ID }}" + }, + "sheetName": { + "__rl": true, + "mode": "name", + "value": "Vendors" + }, + "options": {} + } + }, + { + "id": "node-4", + "name": "Sync: Read Previous Registry", + "type": "n8n-nodes-base.googleSheets", + "position": [ + 580, + -300 + ], + "typeVersion": 4.5, + "parameters": { + "operation": "read", + "documentId": { + "__rl": true, + "mode": "id", + "value": "={{ $vars.GOOGLE_SHEET_ID }}" + }, + "sheetName": { + "__rl": true, + "mode": "name", + "value": "Registry" + }, + "options": {} + } + }, + { + "id": "node-5", + "name": "Sync: Compute Diff", + "type": "n8n-nodes-base.code", + "position": [ + 820, + -300 + ], + "typeVersion": 2, + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "\nconst rawIncoming = $('Sync: Read Vendor List').all().map(i => i.json);\nconst rawPrevious = $('Sync: Read Previous Registry').all().map(i => i.json);\n\nfunction isActive(row) {\n const value = String(row.active ?? 'TRUE').trim().toLowerCase();\n return !['false', 'no', '0', 'inactive'].includes(value);\n}\n\nfunction key(row) {\n return String(row.vendor_domain || row.domain || row.vendor_name || '').trim().toLowerCase();\n}\n\nfunction hasMonitorIds(row) {\n const ids = row.monitor_ids ?? row.monitorIds ?? '';\n if (Array.isArray(ids)) return ids.length > 0;\n return String(ids).trim() !== '' && String(ids).trim() !== '[]';\n}\n\nconst incoming = rawIncoming.filter(isActive).filter(v => key(v));\nconst previous = rawPrevious.filter(isActive).filter(v => key(v));\nconst previousWithMonitors = rawPrevious.filter(v => key(v) && hasMonitorIds(v));\n\nconst incomingMap = new Map(incoming.map(v => [key(v), v]));\nconst previousMap = new Map(previous.map(v => [key(v), v]));\n\nconst added = incoming.filter(v => {\n const prev = previousMap.get(key(v));\n return !prev || !hasMonitorIds(prev);\n});\nconst removed = previousWithMonitors.filter(v => !incomingMap.has(key(v)) || !isActive(v));\nconst modified = incoming.filter(v => {\n const prev = previousMap.get(key(v));\n return prev && hasMonitorIds(prev) && (prev.monitoring_priority !== v.monitoring_priority || prev.vendor_category !== v.vendor_category);\n});\n\nreturn [{ json: { added, removed, modified, unchanged_count: incoming.length - added.length - modified.length } }];\n" + } + }, + { + "id": "node-6", + "name": "Sync: Loop Added Vendors", + "type": "n8n-nodes-base.splitInBatches", + "position": [ + 1060, + -500 + ], + "typeVersion": 3, + "parameters": { + "batchSize": 1, + "options": {} + } + }, + { + "id": "node-7", + "name": "Sync: Build Monitor Payload", + "type": "n8n-nodes-base.code", + "position": [ + 1300, + -500 + ], + "typeVersion": 2, + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "\nconst vendor = $json;\nif (!vendor || !vendor.vendor_name) {\n throw new Error('Sync: Build Monitor Payload received empty vendor input. Ensure Vendors sheet has vendor_name.');\n}\nconst templates = [\n { dim: \"legal\", cat: \"Legal & Regulatory\", q: `\"${vendor.vendor_name}\" lawsuit OR litigation OR regulatory action` },\n { dim: \"cyber\", cat: \"Cybersecurity\", q: `\"${vendor.vendor_name}\" data breach OR cybersecurity incident` },\n { dim: \"financial\", cat: \"Financial Health\", q: `\"${vendor.vendor_name}\" bankruptcy OR financial distress OR credit downgrade` },\n { dim: \"leadership\", cat: \"Leadership & Governance\", q: `\"${vendor.vendor_name}\" CEO departure OR executive change OR merger` },\n { dim: \"esg\", cat: \"ESG & Reputation\", q: `\"${vendor.vendor_name}\" recall OR safety violation OR environmental fine` },\n];\nconst cadence = vendor.monitoring_priority === \"low\" ? \"weekly\" : \"daily\";\nconst dims = vendor.monitoring_priority === \"high\" ? templates\n : vendor.monitoring_priority === \"medium\" ? templates.slice(0, 3)\n : [templates[0], templates[2]];\n\nreturn dims.map(t => ({\n json: {\n monitorPayload: {\n query: t.q, cadence,\n metadata: { vendor_name: vendor.vendor_name, vendor_domain: vendor.vendor_domain, monitor_category: t.cat, risk_dimension: t.dim },\n }\n }\n}));\n" + } + }, + { + "id": "node-8", + "name": "Sync: Create Monitor", + "type": "n8n-nodes-base.httpRequest", + "position": [ + 1540, + -500 + ], + "typeVersion": 4.2, + "parameters": { + "method": "POST", + "url": "https://api.parallel.ai/v1alpha/monitors", + "authentication": "genericCredentialType", + "genericAuthType": "httpHeaderAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "x-api-key", + "value": "={{ $vars.PARALLEL_API_KEY }}" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{\n JSON.stringify({\n query: $json.monitorPayload?.query || ('\"' + ($json.vendor_name || 'Unknown Vendor') + '\" vendor risk'),\n cadence: $json.monitorPayload?.cadence || 'daily',\n webhook: {\n url: ($vars.N8N_WEBHOOK_BASE_URL || '') + '/webhook/parallel-monitor-event',\n event_types: ['monitor.event.detected'],\n },\n metadata: $json.monitorPayload?.metadata || {\n vendor_name: $json.vendor_name || 'Unknown Vendor',\n vendor_domain: $json.vendor_domain || '',\n monitor_category: 'General',\n risk_dimension: 'general',\n },\n })\n }}" + } + }, + { + "id": "node-9", + "name": "Sync: Loop Removed Vendors", + "type": "n8n-nodes-base.splitInBatches", + "position": [ + 1060, + -100 + ], + "typeVersion": 3, + "parameters": { + "batchSize": 1, + "options": {} + } + }, + { + "id": "node-10", + "name": "Sync: Delete Monitor", + "type": "n8n-nodes-base.httpRequest", + "position": [ + 1300, + -100 + ], + "typeVersion": 4.2, + "parameters": { + "method": "DELETE", + "url": "={{ 'https://api.parallel.ai/v1alpha/monitors/' + ($json.monitor_id || $json.id || '') }}", + "authentication": "genericCredentialType", + "genericAuthType": "httpHeaderAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "x-api-key", + "value": "={{ $vars.PARALLEL_API_KEY }}" + } + ] + } + } + }, + { + "id": "node-11", + "name": "Sync: Update Registry", + "type": "n8n-nodes-base.googleSheets", + "position": [ + 1780, + -300 + ], + "typeVersion": 4.5, + "parameters": { + "operation": "appendOrUpdate", + "documentId": { + "__rl": true, + "mode": "id", + "value": "={{ $vars.GOOGLE_SHEET_ID }}" + }, + "sheetName": { + "__rl": true, + "mode": "name", + "value": "Registry" + }, + "options": {}, + "columns": { + "mappingMode": "autoMapInputData" + } + } + }, + { + "id": "node-12", + "name": "Research: Daily 6AM Trigger", + "type": "n8n-nodes-base.scheduleTrigger", + "position": [ + 100, + 300 + ], + "typeVersion": 1.2, + "parameters": { + "rule": { + "interval": [ + { + "field": "hours", + "hoursInterval": 24, + "triggerAtHour": 6 + } + ] + } + } + }, + { + "id": "node-13", + "name": "Research: Manual Trigger", + "type": "n8n-nodes-base.manualTrigger", + "position": [ + 100, + 500 + ], + "typeVersion": 1, + "parameters": {} + }, + { + "id": "node-14", + "name": "Research: Read Registry", + "type": "n8n-nodes-base.googleSheets", + "position": [ + 340, + 300 + ], + "typeVersion": 4.5, + "parameters": { + "operation": "read", + "documentId": { + "__rl": true, + "mode": "id", + "value": "={{ $vars.GOOGLE_SHEET_ID }}" + }, + "sheetName": { + "__rl": true, + "mode": "name", + "value": "Registry" + }, + "options": {} + } + }, + { + "id": "node-15", + "name": "Research: Filter Due Vendors", + "type": "n8n-nodes-base.code", + "position": [ + 580, + 300 + ], + "typeVersion": 2, + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "\nconst today = new Date().toISOString().slice(0, 10);\nconst vendors = $input.all().map(i => i.json);\nconst due = vendors.filter(v => {\n if (v.active === false || v.active === \"false\") return false;\n if (!v.next_research_date) return true;\n return v.next_research_date.slice(0, 10) <= today;\n});\nreturn due.map(v => ({ json: v }));\n" + } + }, + { + "id": "node-16", + "name": "Research: Build Prompts", + "type": "n8n-nodes-base.code", + "position": [ + 820, + 300 + ], + "typeVersion": 2, + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "\nconst vendors = $input.all().map(i => i.json);\nconst outputSchema = JSON.stringify({\n type: \"object\",\n properties: {\n vendor_name: { type: \"string\" },\n overall_risk_level: { type: \"string\", enum: [\"LOW\",\"MEDIUM\",\"HIGH\",\"CRITICAL\"] },\n financial_health: { type: \"object\", properties: { status: { type: \"string\" }, findings: { type: \"string\" }, severity: { type: \"string\" } }, required: [\"status\",\"findings\",\"severity\"] },\n legal_regulatory: { type: \"object\", properties: { status: { type: \"string\" }, findings: { type: \"string\" }, severity: { type: \"string\" } }, required: [\"status\",\"findings\",\"severity\"] },\n cybersecurity: { type: \"object\", properties: { status: { type: \"string\" }, findings: { type: \"string\" }, severity: { type: \"string\" } }, required: [\"status\",\"findings\",\"severity\"] },\n leadership_governance: { type: \"object\", properties: { status: { type: \"string\" }, findings: { type: \"string\" }, severity: { type: \"string\" } }, required: [\"status\",\"findings\",\"severity\"] },\n esg_reputation: { type: \"object\", properties: { status: { type: \"string\" }, findings: { type: \"string\" }, severity: { type: \"string\" } }, required: [\"status\",\"findings\",\"severity\"] },\n adverse_events: { type: \"array\", items: { type: \"object\" } },\n recommendation: { type: \"string\" },\n },\n required: [\"vendor_name\",\"overall_risk_level\",\"financial_health\",\"legal_regulatory\",\"cybersecurity\",\"leadership_governance\",\"esg_reputation\",\"adverse_events\",\"recommendation\"]\n});\nreturn vendors\n .filter(v => v && v.vendor_name)\n .map(v => ({\n json: {\n ...v,\n prompt: \"Conduct a vendor risk assessment of \" + v.vendor_name + \" (\" + v.vendor_domain + \"). \" +\n \"Investigate financial health, legal & regulatory, cybersecurity, leadership & governance, ESG & reputation. \" +\n \"Classify each finding by severity (LOW/MEDIUM/HIGH/CRITICAL) and include source URLs.\",\n outputSchema,\n }\n}}));\n" + } + }, + { + "id": "node-17", + "name": "Research: Loop Vendors", + "type": "n8n-nodes-base.splitInBatches", + "position": [ + 1060, + 300 + ], + "typeVersion": 3, + "parameters": { + "batchSize": 1, + "options": {} + } + }, + { + "id": "node-18", + "name": "Research: Run Deep Research", + "type": "n8n-nodes-base.httpRequest", + "position": [ + 1300, + 300 + ], + "typeVersion": 4.2, + "parameters": { + "method": "POST", + "url": "https://api.parallel.ai/v1/tasks/runs", + "authentication": "genericCredentialType", + "genericAuthType": "httpHeaderAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "x-api-key", + "value": "={{ $vars.PARALLEL_API_KEY }}" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{\n JSON.stringify({\n input: $json.prompt || ('Conduct a vendor risk assessment of ' + ($json.vendor_name || 'Unknown Vendor')),\n processor: 'ultra8x',\n task_spec: { output_schema: JSON.parse($json.outputSchema || '{}') },\n })\n }}" + } + }, + { + "id": "node-19", + "name": "Research: Wait 90s", + "type": "n8n-nodes-base.wait", + "position": [ + 1540, + 300 + ], + "typeVersion": 1.1, + "parameters": { + "amount": 90, + "unit": "seconds" + } + }, + { + "id": "node-20", + "name": "Research: Collect Results", + "type": "n8n-nodes-base.code", + "position": [ + 1780, + 300 + ], + "typeVersion": 2, + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "\nconst items = $input.all().map(i => i.json);\nreturn items.filter(r => r.run_id && r.status === 'started').map(r => ({\n json: {\n vendor: { vendor_name: r.vendor_name, vendor_domain: r.vendor_domain },\n research_output: r.output || r,\n run_id: r.run_id,\n status: r.status,\n }\n}));\n" + } + }, + { + "id": "node-21", + "name": "Monitor: Deploy Webhook", + "type": "n8n-nodes-base.webhook", + "position": [ + 100, + 900 + ], + "typeVersion": 2, + "parameters": { + "path": "/webhook/deploy-monitors", + "httpMethod": "POST", + "responseMode": "onReceived" + } + }, + { + "id": "node-22", + "name": "Monitor: Generate Queries", + "type": "n8n-nodes-base.code", + "position": [ + 340, + 900 + ], + "typeVersion": 2, + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "\nconst vendor = $input.first().json;\nif (!vendor || !vendor.vendor_name) {\n throw new Error('Monitor: Generate Queries received empty vendor input. Pass vendor_name/vendor_domain in webhook payload.');\n}\nconst templates = [\n { dim: \"legal\", cat: \"Legal & Regulatory\", q: '\"' + vendor.vendor_name + '\" lawsuit OR litigation OR regulatory action OR SEC investigation OR enforcement' },\n { dim: \"cyber\", cat: \"Cybersecurity\", q: '\"' + vendor.vendor_name + '\" data breach OR cybersecurity incident OR ransomware OR vulnerability disclosure' },\n { dim: \"financial\", cat: \"Financial Health\", q: '\"' + vendor.vendor_name + '\" bankruptcy OR financial distress OR credit downgrade OR debt default OR layoffs' },\n { dim: \"leadership\", cat: \"Leadership & Governance\", q: '\"' + vendor.vendor_name + '\" CEO departure OR executive change OR acquisition OR merger OR leadership' },\n { dim: \"esg\", cat: \"ESG & Reputation\", q: '\"' + vendor.vendor_name + '\" recall OR safety violation OR environmental fine OR labor dispute OR ESG controversy' },\n];\nconst cadence = vendor.monitoring_priority === \"low\" ? \"weekly\" : \"daily\";\nconst selected = vendor.monitoring_priority === \"high\" ? templates\n : vendor.monitoring_priority === \"medium\" ? templates.slice(0, 3)\n : [templates[0], templates[2]];\n\nreturn selected.map(t => ({\n json: {\n monitorPayload: {\n query: t.q, cadence,\n metadata: { vendor_name: vendor.vendor_name, vendor_domain: vendor.vendor_domain, monitor_category: t.cat, risk_dimension: t.dim },\n output_schema: {\n type: \"json\",\n json_schema: { type: \"object\", properties: { event_summary: { type: \"string\" }, severity: { type: \"string\" }, adverse: { type: \"boolean\" }, event_type: { type: \"string\" } }, required: [\"event_summary\",\"severity\",\"adverse\",\"event_type\"] }\n }\n }\n }\n}));\n" + } + }, + { + "id": "node-23", + "name": "Monitor: Loop Monitors", + "type": "n8n-nodes-base.splitInBatches", + "position": [ + 580, + 900 + ], + "typeVersion": 3, + "parameters": { + "batchSize": 1, + "options": {} + } + }, + { + "id": "node-24", + "name": "Monitor: Create Monitor", + "type": "n8n-nodes-base.httpRequest", + "position": [ + 820, + 900 + ], + "typeVersion": 4.2, + "parameters": { + "method": "POST", + "url": "https://api.parallel.ai/v1alpha/monitors", + "authentication": "genericCredentialType", + "genericAuthType": "httpHeaderAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "x-api-key", + "value": "={{ $vars.PARALLEL_API_KEY }}" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{\n JSON.stringify({\n query: $json.monitorPayload?.query || ('\"' + ($json.vendor_name || 'Unknown Vendor') + '\" vendor risk'),\n cadence: $json.monitorPayload?.cadence || 'daily',\n webhook: {\n url: ($vars.N8N_WEBHOOK_BASE_URL || '') + '/webhook/parallel-monitor-event',\n event_types: ['monitor.event.detected'],\n },\n metadata: $json.monitorPayload?.metadata || {\n vendor_name: $json.vendor_name || 'Unknown Vendor',\n vendor_domain: $json.vendor_domain || '',\n monitor_category: 'General',\n risk_dimension: 'general',\n },\n output_schema: $json.monitorPayload?.output_schema || {\n type: 'json',\n json_schema: {\n type: 'object',\n properties: {\n event_summary: { type: 'string' },\n severity: { type: 'string' },\n adverse: { type: 'boolean' },\n event_type: { type: 'string' },\n },\n required: ['event_summary', 'severity', 'adverse', 'event_type'],\n },\n },\n })\n }}" + } + }, + { + "id": "node-25", + "name": "Monitor: Record Monitor IDs", + "type": "n8n-nodes-base.googleSheets", + "position": [ + 1060, + 900 + ], + "typeVersion": 4.5, + "parameters": { + "operation": "appendOrUpdate", + "documentId": { + "__rl": true, + "mode": "id", + "value": "={{ $vars.GOOGLE_SHEET_ID }}" + }, + "sheetName": { + "__rl": true, + "mode": "name", + "value": "Monitors" + }, + "options": {}, + "columns": { + "mappingMode": "autoMapInputData" + } + } + }, + { + "id": "node-26", + "name": "Monitor: Event Trigger", + "type": "n8n-nodes-base.webhook", + "position": [ + 100, + 1300 + ], + "typeVersion": 2, + "parameters": { + "path": "/webhook/parallel-monitor-event", + "httpMethod": "POST", + "responseMode": "onReceived" + } + }, + { + "id": "node-27", + "name": "Monitor: Enrich & Classify Event", + "type": "n8n-nodes-base.code", + "position": [ + 340, + 1300 + ], + "typeVersion": 2, + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "\nconst data = $input.first().json;\nconst topEvents = Array.isArray(data.events) ? data.events : [];\nconst eventGroup = data.event_group || {};\nconst groupEvents = Array.isArray(eventGroup.events) ? eventGroup.events : [];\nconst events = topEvents.length ? topEvents : groupEvents;\nconst eventEntry = events.find(e => e.type === 'event');\nlet output = {};\nif (eventEntry && eventEntry.output && typeof eventEntry.output === 'object') {\n output = eventEntry.output;\n} else if (eventEntry && typeof eventEntry.output === 'string') {\n output = { event_summary: eventEntry.output, severity: 'LOW', adverse: false, event_type: 'unknown' };\n} else if (data.output && typeof data.output === 'object') {\n output = data.output;\n}\nreturn [{\n json: {\n monitor_id: data.monitor_id || data.monitor?.id || data.metadata?.monitor_id,\n metadata: data.metadata || data.monitor?.metadata || {},\n ...output,\n source: 'monitor_event',\n event_date: eventEntry?.event_date || data.event_date,\n source_urls: eventEntry?.source_urls || data.source_urls,\n }\n}];\n" + } + }, + { + "id": "node-28", + "name": "AdHoc: Slack Command", + "type": "n8n-nodes-base.webhook", + "position": [ + 100, + 1700 + ], + "typeVersion": 2, + "parameters": { + "path": "/webhook/slack-command", + "httpMethod": "POST", + "responseMode": "onReceived" + } + }, + { + "id": "node-29", + "name": "AdHoc: Parse Command", + "type": "n8n-nodes-base.code", + "position": [ + 340, + 1700 + ], + "typeVersion": 2, + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "\nconst payload = $input.first().json;\nconst vendor_name = (payload.text || '').trim();\nif (!vendor_name) throw new Error('Vendor name is required. Usage: /vendor-research {vendor_name}');\n\nconst prompt = 'Conduct a comprehensive vendor risk assessment of \"' + vendor_name + '\". ' +\n 'Investigate financial health, legal & regulatory, cybersecurity, leadership & governance, ESG & reputation. ' +\n 'Classify each finding by severity (LOW/MEDIUM/HIGH/CRITICAL) and include source URLs.';\n\nconst outputSchema = JSON.stringify({\n type: \"object\",\n properties: {\n vendor_name: { type: \"string\" },\n overall_risk_level: { type: \"string\", enum: [\"LOW\",\"MEDIUM\",\"HIGH\",\"CRITICAL\"] },\n financial_health: { type: \"object\", properties: { status: { type: \"string\" }, findings: { type: \"string\" }, severity: { type: \"string\" } } },\n legal_regulatory: { type: \"object\", properties: { status: { type: \"string\" }, findings: { type: \"string\" }, severity: { type: \"string\" } } },\n cybersecurity: { type: \"object\", properties: { status: { type: \"string\" }, findings: { type: \"string\" }, severity: { type: \"string\" } } },\n leadership_governance: { type: \"object\", properties: { status: { type: \"string\" }, findings: { type: \"string\" }, severity: { type: \"string\" } } },\n esg_reputation: { type: \"object\", properties: { status: { type: \"string\" }, findings: { type: \"string\" }, severity: { type: \"string\" } } },\n adverse_events: { type: \"array\", items: { type: \"object\" } },\n recommendation: { type: \"string\" },\n },\n required: [\"vendor_name\",\"overall_risk_level\",\"recommendation\"]\n});\n\nreturn [{ json: {\n vendor_name,\n channel_id: payload.channel_id || payload.channel,\n user_name: payload.user_name || payload.user,\n response_url: payload.response_url,\n prompt,\n outputSchema,\n webhookUrl: ($vars?.N8N_WEBHOOK_BASE_URL || '') + \"/webhook/parallel-task-completion\",\n} }];\n" + } + }, + { + "id": "node-30", + "name": "AdHoc: Send Acknowledgment", + "type": "n8n-nodes-base.slack", + "position": [ + 580, + 1700 + ], + "typeVersion": 2.2, + "parameters": { + "resource": "message", + "operation": "post", + "channel": { + "__rl": true, + "mode": "name", + "value": "={{ $json.channel_id }}" + }, + "text": "={{ \"\\ud83d\\udd0d Starting deep research on *\" + $json.vendor_name + \"*. This typically takes 15-30 minutes...\" }}", + "otherOptions": {} + } + }, + { + "id": "node-31", + "name": "AdHoc: Start Research Task", + "type": "n8n-nodes-base.httpRequest", + "position": [ + 820, + 1700 + ], + "typeVersion": 4.2, + "parameters": { + "method": "POST", + "url": "https://api.parallel.ai/v1/tasks/runs", + "authentication": "genericCredentialType", + "genericAuthType": "httpHeaderAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "x-api-key", + "value": "={{ $vars.PARALLEL_API_KEY }}" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{\n JSON.stringify({\n input: $json.prompt || ('Conduct a vendor risk assessment of ' + ($json.vendor_name || 'Unknown Vendor')),\n processor: 'ultra8x',\n task_spec: { output_schema: JSON.parse($json.outputSchema || '{}') },\n webhook: {\n url: $json.webhookUrl || (($vars.N8N_WEBHOOK_BASE_URL || '') + '/webhook/parallel-task-completion'),\n events: ['task_run.status'],\n },\n })\n }}" + }, + "notes": "Creates a single deep research run with webhook callback" + }, + { + "id": "node-32", + "name": "AdHoc: Result Callback", + "type": "n8n-nodes-base.webhook", + "position": [ + 100, + 2100 + ], + "typeVersion": 2, + "parameters": { + "path": "/webhook/parallel-task-completion", + "httpMethod": "POST", + "responseMode": "onReceived" + } + }, + { + "id": "node-33", + "name": "AdHoc: Tag Source", + "type": "n8n-nodes-base.code", + "position": [ + 340, + 2100 + ], + "typeVersion": 2, + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "\nconst data = $input.first().json || {};\nconst events = Array.isArray(data.events) ? data.events : [];\nconst event = events.find((e) => e?.type === 'event') || events[events.length - 1];\nconst eventData = event?.data || event || {};\nconst output = (eventData.output && typeof eventData.output === 'object')\n ? eventData.output\n : (data.output && typeof data.output === 'object' ? data.output : {});\n\nconst run_id = data.run_id || eventData.run_id || data.id || eventData.id;\nconst status = data.status || eventData.status || 'completed';\n\nreturn [{\n json: {\n ...data,\n run_id,\n status,\n research_output: output,\n ...output,\n source: 'adhoc',\n }\n}];\n" + } + }, + { + "id": "node-34", + "name": "Scoring: Risk Scorer", + "type": "n8n-nodes-base.code", + "position": [ + 1300, + 2700 + ], + "typeVersion": 2, + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "\nconst input = $input.first().json;\nconst output = input.research_output || input;\n\n// Step 1: Severity aggregation\nconst dims = ['financial_health','legal_regulatory','cybersecurity','leadership_governance','esg_reputation'];\nconst counts = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 };\nconst categories = [];\nconst mediumCats = [];\n\nfor (const dim of dims) {\n const sev = (output[dim]?.severity || 'LOW').toUpperCase();\n counts[sev] = (counts[sev] || 0) + 1;\n if (sev === 'CRITICAL' || sev === 'HIGH') categories.push(dim);\n if (sev === 'MEDIUM') mediumCats.push(dim);\n}\n\n// Step 2: Risk level assignment\nlet risk_level, adverse_flag;\nif (counts.CRITICAL > 0) { risk_level = 'CRITICAL'; adverse_flag = true; }\nelse if (counts.HIGH >= 1) { risk_level = 'HIGH'; adverse_flag = true; }\nelse if (counts.MEDIUM >= 3) { risk_level = 'MEDIUM'; adverse_flag = new Set(mediumCats).size >= 2; }\nelse if (counts.MEDIUM >= 1) { risk_level = 'MEDIUM'; adverse_flag = false; }\nelse { risk_level = 'LOW'; adverse_flag = false; }\n\n// Step 3: Overrides\nconst overrides = [];\nif ((output.cybersecurity?.status || '').toUpperCase() === 'CRITICAL') {\n risk_level = 'CRITICAL'; adverse_flag = true; overrides.push('active_data_breach');\n}\nif ((output.legal_regulatory?.status || '').toUpperCase() === 'CRITICAL') {\n if (['LOW','MEDIUM'].includes(risk_level)) risk_level = 'HIGH';\n adverse_flag = true; overrides.push('active_government_litigation');\n}\n\n// Step 4: Derived fields\nconst action_required = risk_level === 'HIGH' || risk_level === 'CRITICAL';\nconst recMap = { LOW: 'continue_monitoring', MEDIUM: 'escalate_review', HIGH: 'initiate_contingency', CRITICAL: 'suspend_relationship' };\nconst recommendation = recMap[risk_level];\nconst vendor_name = output.vendor_name || input.vendor?.vendor_name || 'Unknown';\nconst summary = vendor_name + ' assessed at ' + risk_level + ' risk. ' + (adverse_flag ? 'Adverse conditions detected.' : 'No adverse conditions.');\n\nreturn [{\n json: {\n vendor_name, risk_level, adverse_flag, action_required, recommendation,\n summary, categories, severity_counts: counts, triggered_overrides: overrides,\n assessment_date: new Date().toISOString().slice(0, 10),\n source: input.source || 'deep_research',\n }\n}];\n" + } + }, + { + "id": "node-35", + "name": "Scoring: Route by Risk Level", + "type": "n8n-nodes-base.switch", + "position": [ + 1540, + 2700 + ], + "typeVersion": 3.2, + "parameters": { + "rules": { + "values": [ + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "leftValue": "={{ $json.risk_level }}", + "rightValue": "CRITICAL", + "operator": { + "type": "string", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "CRITICAL" + }, + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "leftValue": "={{ $json.risk_level }}", + "rightValue": "HIGH", + "operator": { + "type": "string", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "HIGH" + }, + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "leftValue": "={{ $json.risk_level }}", + "rightValue": "MEDIUM", + "operator": { + "type": "string", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "MEDIUM" + }, + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "leftValue": "={{ $json.risk_level }}", + "rightValue": "LOW", + "operator": { + "type": "string", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "LOW" + } + ] + }, + "options": { + "fallbackOutput": "extra" + } + } + }, + { + "id": "node-36", + "name": "Scoring: Alert Critical", + "type": "n8n-nodes-base.slack", + "position": [ + 1780, + 2300 + ], + "typeVersion": 2.2, + "parameters": { + "resource": "message", + "operation": "post", + "channel": { + "__rl": true, + "mode": "name", + "value": "={{ $vars.SLACK_ALERT_TARGET || '#procurement-critical' }}" + }, + "text": "={{ \"\\ud83d\\udd34 CRITICAL: \" + $json.vendor_name + \" — \" + $json.summary }}", + "otherOptions": {} + } + }, + { + "id": "node-37", + "name": "Scoring: Alert High", + "type": "n8n-nodes-base.slack", + "position": [ + 1780, + 2500 + ], + "typeVersion": 2.2, + "parameters": { + "resource": "message", + "operation": "post", + "channel": { + "__rl": true, + "mode": "name", + "value": "={{ $vars.SLACK_ALERT_TARGET || '#procurement-critical' }}" + }, + "text": "={{ \"\\ud83d\\udfe0 HIGH: \" + $json.vendor_name + \" — \" + $json.summary }}", + "otherOptions": {} + } + }, + { + "id": "node-38", + "name": "Scoring: Format Digest", + "type": "n8n-nodes-base.code", + "position": [ + 1780, + 2700 + ], + "typeVersion": 2, + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "\nconst data = $input.first().json;\nreturn [{ json: { ...data, digest_formatted: true } }];\n" + } + }, + { + "id": "node-39", + "name": "Scoring: Log Low", + "type": "n8n-nodes-base.code", + "position": [ + 1780, + 2900 + ], + "typeVersion": 2, + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "return [$input.first()];" + } + }, + { + "id": "node-40", + "name": "Scoring: Audit Log", + "type": "n8n-nodes-base.googleSheets", + "position": [ + 2020, + 2700 + ], + "typeVersion": 4.5, + "parameters": { + "operation": "appendOrUpdate", + "documentId": { + "__rl": true, + "mode": "id", + "value": "={{ $vars.GOOGLE_SHEET_ID }}" + }, + "sheetName": { + "__rl": true, + "mode": "name", + "value": "Audit Log" + }, + "options": {}, + "columns": { + "mappingMode": "autoMapInputData" + } + } + }, + { + "id": "node-41", + "name": "Scoring: Route Back", + "type": "n8n-nodes-base.switch", + "position": [ + 2260, + 2700 + ], + "typeVersion": 3.2, + "parameters": { + "rules": { + "values": [ + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "leftValue": "={{ $json.source }}", + "rightValue": "deep_research", + "operator": { + "type": "string", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "deep_research" + }, + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "leftValue": "={{ $json.source }}", + "rightValue": "adhoc", + "operator": { + "type": "string", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "adhoc" + }, + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "leftValue": "={{ $json.source }}", + "rightValue": "monitor_event", + "operator": { + "type": "string", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "monitor_event" + } + ] + }, + "options": { + "fallbackOutput": "extra" + } + } + }, + { + "id": "node-42", + "name": "Research: Update Research Dates", + "type": "n8n-nodes-base.googleSheets", + "position": [ + 2500, + 2500 + ], + "typeVersion": 4.5, + "parameters": { + "operation": "appendOrUpdate", + "documentId": { + "__rl": true, + "mode": "id", + "value": "={{ $vars.GOOGLE_SHEET_ID }}" + }, + "sheetName": { + "__rl": true, + "mode": "name", + "value": "Registry" + }, + "options": {}, + "columns": { + "mappingMode": "autoMapInputData" + } + } + }, + { + "id": "node-43", + "name": "AdHoc: Post Thread Reply", + "type": "n8n-nodes-base.slack", + "position": [ + 2500, + 2900 + ], + "typeVersion": 2.2, + "parameters": { + "resource": "message", + "operation": "post", + "channel": { + "__rl": true, + "mode": "name", + "value": "={{ $json.channel_id }}" + }, + "text": "={{ $json.text }}", + "otherOptions": {} + } + }, + { + "id": "snapshot-webhook-1", + "name": "Snapshot: Dashboard Webhook", + "type": "n8n-nodes-base.webhook", + "position": [ + 100, + 3300 + ], + "typeVersion": 2, + "parameters": { + "path": "procurement-dashboard-snapshot", + "httpMethod": "GET", + "responseMode": "lastNode", + "options": {} + } + }, + { + "id": "node-44", + "name": "Snapshot: Read Registry", + "type": "n8n-nodes-base.googleSheets", + "position": [ + 340, + 3200 + ], + "typeVersion": 4.5, + "parameters": { + "operation": "read", + "documentId": { + "__rl": true, + "mode": "id", + "value": "={{ $vars.GOOGLE_SHEET_ID }}" + }, + "sheetName": { + "__rl": true, + "mode": "name", + "value": "Registry" + }, + "options": {} + } + }, + { + "id": "node-45", + "name": "Snapshot: Read Audit Log", + "type": "n8n-nodes-base.googleSheets", + "position": [ + 580, + 3200 + ], + "typeVersion": 4.5, + "parameters": { + "operation": "read", + "documentId": { + "__rl": true, + "mode": "id", + "value": "={{ $vars.GOOGLE_SHEET_ID }}" + }, + "sheetName": { + "__rl": true, + "mode": "name", + "value": "Audit Log" + }, + "options": {} + } + }, + { + "id": "node-46", + "name": "Snapshot: Read Monitors", + "type": "n8n-nodes-base.googleSheets", + "position": [ + 820, + 3200 + ], + "typeVersion": 4.5, + "parameters": { + "operation": "read", + "documentId": { + "__rl": true, + "mode": "id", + "value": "={{ $vars.GOOGLE_SHEET_ID }}" + }, + "sheetName": { + "__rl": true, + "mode": "name", + "value": "Monitors" + }, + "options": {} + } + }, + { + "id": "node-47", + "name": "Snapshot: Build Payload", + "type": "n8n-nodes-base.code", + "position": [ + 1060, + 3200 + ], + "typeVersion": 2, + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "\nconst registry = $('Snapshot: Read Registry').all().map(i => i.json);\nconst audit_log = $('Snapshot: Read Audit Log').all().map(i => i.json);\nconst monitors = $('Snapshot: Read Monitors').all().map(i => i.json);\nconst now = new Date();\n\nconst riskLevels = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'];\nconst riskScores = { LOW: 18, MEDIUM: 48, HIGH: 76, CRITICAL: 94 };\nconst dimensionLabels = {\n financial_health: 'Financial health',\n legal_regulatory: 'Legal & regulatory',\n cybersecurity: 'Cybersecurity',\n leadership_governance: 'Leadership & governance',\n esg_reputation: 'ESG & reputation',\n};\nconst dimensionOrder = Object.keys(dimensionLabels);\n\nfunction pick(row, keys, fallback = '') {\n for (const key of keys) {\n if (row[key] !== undefined && row[key] !== null && String(row[key]).trim() !== '') {\n return row[key];\n }\n }\n return fallback;\n}\n\nfunction slugify(value) {\n return String(value || 'vendor')\n .toLowerCase()\n .trim()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '') || 'vendor';\n}\n\nfunction normalizeRisk(value) {\n const normalized = String(value || '').trim().toUpperCase();\n return riskLevels.includes(normalized) ? normalized : 'LOW';\n}\n\nfunction normalizePriority(value, riskLevel) {\n const normalized = String(value || '').trim().toLowerCase();\n if (['high', 'medium', 'low'].includes(normalized)) return normalized;\n if (riskLevel === 'CRITICAL' || riskLevel === 'HIGH') return 'high';\n if (riskLevel === 'MEDIUM') return 'medium';\n return 'low';\n}\n\nfunction toBool(value) {\n if (typeof value === 'boolean') return value;\n return ['true', 'yes', '1', 'y'].includes(String(value || '').trim().toLowerCase());\n}\n\nfunction parseList(value) {\n if (Array.isArray(value)) return value.map(String).filter(Boolean);\n if (value === undefined || value === null || value === '') return [];\n try {\n const parsed = JSON.parse(value);\n if (Array.isArray(parsed)) return parsed.map(String).filter(Boolean);\n } catch {}\n return String(value).split(/[;,]/).map(item => item.trim()).filter(Boolean);\n}\n\nfunction dimensionKey(value) {\n const normalized = String(value || '').toLowerCase();\n if (normalized.includes('financial') || normalized.includes('credit')) return 'financial_health';\n if (normalized.includes('legal') || normalized.includes('regulatory') || normalized.includes('litigation')) return 'legal_regulatory';\n if (normalized.includes('cyber') || normalized.includes('breach') || normalized.includes('security')) return 'cybersecurity';\n if (normalized.includes('leadership') || normalized.includes('governance') || normalized.includes('executive')) return 'leadership_governance';\n if (normalized.includes('esg') || normalized.includes('reputation') || normalized.includes('labor')) return 'esg_reputation';\n return 'financial_health';\n}\n\nfunction dateOnly(value, fallbackDate) {\n const date = value ? new Date(value) : fallbackDate;\n if (Number.isNaN(date.getTime())) return fallbackDate.toISOString().slice(0, 10);\n return date.toISOString().slice(0, 10);\n}\n\nfunction relativeTime(value) {\n const date = value ? new Date(value) : now;\n if (Number.isNaN(date.getTime())) return 'just now';\n const minutes = Math.max(0, Math.round((now.getTime() - date.getTime()) / 60000));\n if (minutes < 1) return 'just now';\n if (minutes < 60) return minutes + ' minutes ago';\n const hours = Math.round(minutes / 60);\n if (hours < 48) return hours + ' hours ago';\n const days = Math.round(hours / 24);\n return days + ' days ago';\n}\n\nfunction sourceUrl(value) {\n const text = String(value || '').trim();\n return text.startsWith('http://') || text.startsWith('https://') ? text : 'https://parallel.ai';\n}\n\nfunction recommendationFor(level) {\n if (level === 'CRITICAL') return 'suspend_relationship';\n if (level === 'HIGH') return 'initiate_contingency';\n if (level === 'MEDIUM') return 'escalate_review';\n return 'continue_monitoring';\n}\n\nfunction auditTimestamp(row) {\n return String(pick(row, ['timestamp', 'assessment_date', 'created_at', 'date'], ''));\n}\n\nconst latestAuditByVendor = new Map();\nfor (const row of audit_log) {\n const vendorName = String(pick(row, ['vendor_name', 'vendorName', 'name'], '')).trim();\n if (!vendorName) continue;\n const key = vendorName.toLowerCase();\n const current = latestAuditByVendor.get(key);\n const nextTime = new Date(auditTimestamp(row)).getTime() || 0;\n const currentTime = current ? (new Date(auditTimestamp(current)).getTime() || 0) : -1;\n if (!current || nextTime >= currentTime) latestAuditByVendor.set(key, row);\n}\n\nfunction dimensionsFor(latestAudit, riskLevel) {\n const categories = parseList(pick(latestAudit || {}, ['categories', 'risk_categories', 'category'], ''));\n const activeKeys = new Set(categories.map(dimensionKey));\n return dimensionOrder.map(key => {\n const active = activeKeys.has(key);\n return {\n key,\n label: dimensionLabels[key],\n severity: active ? riskLevel : 'LOW',\n status: active ? 'watch' : 'stable',\n findings: active\n ? (pick(latestAudit || {}, ['summary', 'detail', 'event_summary'], dimensionLabels[key] + ' requires review.'))\n : 'No active findings in the current monitoring window.',\n };\n });\n}\n\nfunction monitorLensFor(vendorName) {\n return monitors\n .filter(row => String(pick(row, ['vendor_name', 'vendorName'], '')).toLowerCase() === vendorName.toLowerCase())\n .map(row => ({\n dimension: String(pick(row, ['risk_dimension', 'monitor_category', 'category'], 'general')),\n cadence: String(pick(row, ['cadence'], 'daily')),\n status: 'active',\n query: String(pick(row, ['query', 'monitor_query'], vendorName + ' vendor risk')),\n lastEvent: String(pick(row, ['last_event_at', 'updated_at', 'created_at'], 'No event yet')),\n }));\n}\n\nconst activeRegistry = registry.filter(row => {\n const active = String(pick(row, ['active'], 'true')).trim().toLowerCase();\n return !['false', 'no', '0'].includes(active);\n});\n\nconst vendors = activeRegistry.map(row => {\n const vendorName = String(pick(row, ['vendor_name', 'vendorName', 'name'], 'Unknown vendor')).trim();\n const latest = latestAuditByVendor.get(vendorName.toLowerCase());\n const riskLevel = normalizeRisk(pick(latest || {}, ['risk_level', 'riskLevel'], pick(row, ['risk_tier_override', 'risk_level'], 'LOW')));\n const latestDate = dateOnly(auditTimestamp(latest || {}), now);\n const nextDate = dateOnly(pick(row, ['next_research_date', 'nextResearchDate'], now.toISOString()), now);\n const adverseFlag = toBool(pick(latest || {}, ['adverse_flag', 'adverseFlag'], riskLevel === 'HIGH' || riskLevel === 'CRITICAL'));\n const summary = String(pick(latest || {}, ['summary', 'detail', 'event_summary'], vendorName + ' is currently assessed at ' + riskLevel + ' risk.'));\n const overrides = parseList(pick(latest || {}, ['triggered_overrides', 'triggeredOverrides'], pick(row, ['risk_tier_override'], '')));\n const domain = String(pick(row, ['vendor_domain', 'vendorDomain', 'domain'], slugify(vendorName) + '.com'));\n const normalizedDomain = domain.startsWith('http://') || domain.startsWith('https://') ? domain : 'https://' + domain;\n const score = Number(pick(latest || {}, ['score', 'risk_score'], pick(row, ['risk_score', 'riskScore', 'score'], riskScores[riskLevel]))) || riskScores[riskLevel];\n const monitorsForVendor = monitorLensFor(vendorName);\n\n return {\n id: slugify(vendorName),\n vendorName,\n vendorDomain: normalizedDomain,\n vendorCategory: String(pick(row, ['vendor_category', 'vendorCategory', 'category'], 'vendor')).toLowerCase().replace(/\\s+/g, '_'),\n monitoringPriority: normalizePriority(pick(row, ['monitoring_priority', 'monitoringPriority', 'priority'], ''), riskLevel),\n relationshipOwner: String(pick(row, ['relationship_owner', 'relationshipOwner', 'owner'], 'Procurement')),\n region: String(pick(row, ['region'], 'Global')),\n riskLevel,\n overallRiskLevel: riskLevel,\n score,\n actionRequired: riskLevel === 'HIGH' || riskLevel === 'CRITICAL',\n adverseFlag,\n recommendation: String(pick(latest || {}, ['recommendation'], recommendationFor(riskLevel))),\n summary,\n movement: String(pick(latest || {}, ['movement'], '+0 live snapshot')),\n lastAssessmentDate: latestDate,\n nextResearchDate: nextDate,\n triggeredOverrides: overrides.filter(value => value && riskLevels.indexOf(String(value).toUpperCase()) === -1),\n dimensions: dimensionsFor(latest, riskLevel),\n adverseEvents: adverseFlag ? [{\n title: String(pick(latest || {}, ['title', 'event_summary'], riskLevel + ' risk finding')),\n date: latestDate,\n category: String(parseList(pick(latest || {}, ['categories', 'category'], 'general'))[0] || 'general'),\n severity: riskLevel,\n description: summary,\n sourceUrl: sourceUrl(pick(latest || {}, ['source', 'source_url', 'sourceUrl'], '')),\n }] : [],\n evidence: latest ? [{\n title: String(pick(latest, ['title', 'summary'], 'Latest assessment')),\n publication: String(pick(latest, ['source', 'publication'], 'Parallel assessment')),\n publishedAt: latestDate,\n materiality: summary,\n href: sourceUrl(pick(latest, ['source_url', 'sourceUrl', 'source'], '')),\n }] : [],\n monitors: monitorsForVendor,\n };\n}).sort((left, right) => right.score - left.score);\n\nconst riskDistribution = riskLevels.map(level => ({\n label: level,\n count: vendors.filter(vendor => vendor.riskLevel === level).length,\n}));\n\nconst dueVendors = vendors.filter(vendor => {\n const next = new Date(vendor.nextResearchDate);\n return !Number.isNaN(next.getTime()) && next <= now;\n});\n\nconst researchedToday = audit_log.filter(row => dateOnly(auditTimestamp(row), now) === now.toISOString().slice(0, 10)).length;\nconst adverseCount = vendors.filter(vendor => vendor.adverseFlag).length;\nconst actionCount = vendors.filter(vendor => vendor.actionRequired).length;\nconst criticalCount = vendors.filter(vendor => vendor.riskLevel === 'CRITICAL').length;\nconst highCount = vendors.filter(vendor => vendor.riskLevel === 'HIGH').length;\nconst activeMonitorCount = monitors.length;\n\nconst sortedAudit = audit_log.slice().sort((left, right) => {\n return (new Date(auditTimestamp(right)).getTime() || 0) - (new Date(auditTimestamp(left)).getTime() || 0);\n});\n\nconst feed = sortedAudit.slice(0, 25).map(row => {\n const vendorName = String(pick(row, ['vendor_name', 'vendorName', 'name'], 'Unknown vendor'));\n const riskLevel = normalizeRisk(pick(row, ['risk_level', 'riskLevel', 'severity'], 'MEDIUM'));\n const summary = String(pick(row, ['summary', 'detail', 'event_summary'], vendorName + ' monitoring event.'));\n return {\n vendorName,\n title: String(pick(row, ['title', 'event_summary'], summary.split('.')[0] || 'Monitoring update')),\n severity: riskLevel,\n timestamp: relativeTime(auditTimestamp(row)),\n detail: summary,\n sourceUrl: sourceUrl(pick(row, ['source_url', 'sourceUrl', 'source'], '')),\n };\n});\n\nconst actionQueue = vendors\n .filter(vendor => vendor.actionRequired)\n .map(vendor => ({\n vendorName: vendor.vendorName,\n owner: vendor.riskLevel === 'CRITICAL' ? 'Security operations' : 'Procurement finance',\n deadline: vendor.riskLevel === 'CRITICAL' ? 'Due in 12h' : 'Due in 24h',\n action: vendor.riskLevel === 'CRITICAL'\n ? 'Validate exposure, review contingency supplier path, and notify accountable stakeholders.'\n : 'Update the vendor risk memo and confirm mitigation owner.',\n riskLevel: vendor.riskLevel,\n }));\n\nreturn [{\n json: {\n lastUpdated: now.toISOString(),\n metrics: [\n {\n label: 'Portfolio risk posture',\n value: criticalCount + ' CRITICAL / ' + highCount + ' HIGH',\n trend: actionCount + ' vendors require immediate review',\n tone: actionCount ? 'critical' : 'positive',\n },\n {\n label: 'Research cadence',\n value: dueVendors.length + ' due today',\n trend: researchedToday + ' audit log entries recorded today',\n tone: dueVendors.length ? 'warning' : 'positive',\n },\n {\n label: 'Monitor fleet health',\n value: activeMonitorCount + ' active',\n trend: 'Webhook healthy, live snapshot generated',\n tone: 'positive',\n },\n {\n label: 'Action queue',\n value: actionCount + ' escalations',\n trend: actionQueue.filter(item => item.deadline.includes('12h')).length + ' due in the next 12h',\n tone: actionCount ? 'default' : 'positive',\n },\n ],\n riskDistribution,\n researchSummary: {\n totalDue: dueVendors.length,\n totalResearched: researchedToday,\n totalFailed: 0,\n adverseCount,\n batchesExecuted: Math.ceil(Math.max(dueVendors.length, researchedToday) / 50),\n duration: 'live',\n },\n health: {\n totalMonitors: monitors.length,\n activeCount: monitors.length,\n failedCount: 0,\n orphanCount: 0,\n recreated: 0,\n webhookHealthy: true,\n },\n feed,\n actionQueue,\n vendors,\n }\n}];\n" + } + }, + { + "id": "portfolio-mutation-webhook-1", + "name": "Portfolio: Mutation Webhook", + "type": "n8n-nodes-base.webhook", + "position": [ + 100, + 3600 + ], + "typeVersion": 2, + "parameters": { + "path": "procurement-portfolio-mutation", + "httpMethod": "POST", + "responseMode": "lastNode", + "options": {} + } + }, + { + "id": "node-48", + "name": "Portfolio: Read Vendors", + "type": "n8n-nodes-base.googleSheets", + "position": [ + 340, + 3600 + ], + "typeVersion": 4.5, + "parameters": { + "operation": "read", + "documentId": { + "__rl": true, + "mode": "id", + "value": "={{ $vars.GOOGLE_SHEET_ID }}" + }, + "sheetName": { + "__rl": true, + "mode": "name", + "value": "Vendors" + }, + "options": {} + } + }, + { + "id": "node-49", + "name": "Portfolio: Read Registry", + "type": "n8n-nodes-base.googleSheets", + "position": [ + 580, + 3600 + ], + "typeVersion": 4.5, + "parameters": { + "operation": "read", + "documentId": { + "__rl": true, + "mode": "id", + "value": "={{ $vars.GOOGLE_SHEET_ID }}" + }, + "sheetName": { + "__rl": true, + "mode": "name", + "value": "Registry" + }, + "options": {} + } + }, + { + "id": "node-50", + "name": "Portfolio: Build Vendor Rows", + "type": "n8n-nodes-base.code", + "position": [ + 820, + 3600 + ], + "typeVersion": 2, + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "\nconst incoming = $('Portfolio: Mutation Webhook').first().json || {};\nconst headers = incoming.headers || {};\nconst body = incoming.body && typeof incoming.body === 'object' ? incoming.body : incoming;\nconst currentRows = $('Portfolio: Read Vendors').all().map(i => i.json);\nconst now = new Date().toISOString();\n\nconst seedVendors = [\n { vendorName: 'Microsoft', vendorDomain: 'https://microsoft.com', vendorCategory: 'technology', monitoringPriority: 'high' },\n { vendorName: 'Amazon Web Services', vendorDomain: 'https://aws.amazon.com', vendorCategory: 'technology', monitoringPriority: 'high' },\n { vendorName: 'Salesforce', vendorDomain: 'https://salesforce.com', vendorCategory: 'technology', monitoringPriority: 'high' },\n { vendorName: 'JPMorgan Chase', vendorDomain: 'https://jpmorganchase.com', vendorCategory: 'financial_services', monitoringPriority: 'high' },\n { vendorName: 'Goldman Sachs', vendorDomain: 'https://goldmansachs.com', vendorCategory: 'financial_services', monitoringPriority: 'medium' },\n { vendorName: 'UnitedHealth Group', vendorDomain: 'https://unitedhealthgroup.com', vendorCategory: 'healthcare', monitoringPriority: 'high' },\n { vendorName: 'Pfizer', vendorDomain: 'https://pfizer.com', vendorCategory: 'healthcare', monitoringPriority: 'medium' },\n { vendorName: 'Johnson & Johnson', vendorDomain: 'https://jnj.com', vendorCategory: 'healthcare', monitoringPriority: 'medium' },\n { vendorName: 'Siemens', vendorDomain: 'https://siemens.com', vendorCategory: 'manufacturing', monitoringPriority: 'medium' },\n { vendorName: 'Caterpillar', vendorDomain: 'https://caterpillar.com', vendorCategory: 'manufacturing', monitoringPriority: 'low' },\n { vendorName: 'Deloitte', vendorDomain: 'https://deloitte.com', vendorCategory: 'professional_services', monitoringPriority: 'medium' },\n { vendorName: 'Accenture', vendorDomain: 'https://accenture.com', vendorCategory: 'professional_services', monitoringPriority: 'medium' },\n { vendorName: 'Stripe', vendorDomain: 'https://stripe.com', vendorCategory: 'financial_services', monitoringPriority: 'high' },\n { vendorName: 'CrowdStrike', vendorDomain: 'https://crowdstrike.com', vendorCategory: 'technology', monitoringPriority: 'high' },\n { vendorName: '3M', vendorDomain: 'https://3m.com', vendorCategory: 'manufacturing', monitoringPriority: 'low' },\n];\n\nfunction pick(row, keys, fallback = '') {\n for (const key of keys) {\n if (row[key] !== undefined && row[key] !== null && String(row[key]).trim() !== '') {\n return row[key];\n }\n }\n return fallback;\n}\n\nfunction headerValue(name) {\n const target = name.toLowerCase();\n for (const [key, value] of Object.entries(headers)) {\n if (key.toLowerCase() === target) return Array.isArray(value) ? value[0] : value;\n }\n return '';\n}\n\nfunction isTruthy(value) {\n return ['true', 'yes', '1', 'y'].includes(String(value || '').trim().toLowerCase());\n}\n\nfunction isActive(row) {\n const value = String(pick(row, ['active'], 'TRUE')).trim().toLowerCase();\n return !['false', 'no', '0', 'inactive'].includes(value);\n}\n\nfunction normalizeDomain(value, name) {\n const fallback = String(name || 'vendor').toLowerCase().replace(/[^a-z0-9]+/g, '-') + '.example';\n const raw = String(value || fallback).trim();\n return raw.startsWith('http://') || raw.startsWith('https://') ? raw : 'https://' + raw;\n}\n\nfunction keyFor(row) {\n return String(pick(row, ['vendor_domain', 'vendorDomain', 'domain'], pick(row, ['vendor_name', 'vendorName', 'name'], '')))\n .trim()\n .toLowerCase();\n}\n\nfunction normalizePriority(value) {\n const normalized = String(value || '').trim().toLowerCase();\n return ['high', 'medium', 'low'].includes(normalized) ? normalized : 'medium';\n}\n\nfunction normalizeRisk(value) {\n const normalized = String(value || '').trim().toUpperCase();\n return ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'].includes(normalized) ? normalized : '';\n}\n\nfunction sheetRowFromInput(input) {\n const vendorName = String(input.vendorName || input.vendor_name || '').trim();\n if (!vendorName) throw new Error('Portfolio mutation vendorName is required.');\n const domain = normalizeDomain(input.vendorDomain || input.vendor_domain, vendorName);\n return {\n vendor_name: vendorName,\n vendor_domain: domain,\n vendor_category: String(input.vendorCategory || input.vendor_category || 'vendor').trim().toLowerCase().replace(/\\s+/g, '_'),\n risk_tier_override: normalizeRisk(input.riskLevel || input.risk_level),\n active: 'TRUE',\n monitoring_priority: normalizePriority(input.monitoringPriority || input.monitoring_priority),\n relationship_owner: String(input.relationshipOwner || input.relationship_owner || 'Procurement').trim(),\n region: String(input.region || 'Global').trim(),\n risk_score: input.score !== undefined ? String(input.score) : '',\n next_research_date: String(input.nextResearchDate || input.next_research_date || ''),\n last_synced_at: now,\n dashboard_managed: 'TRUE',\n };\n}\n\nfunction normalizeExistingRow(row) {\n const vendorName = String(pick(row, ['vendor_name', 'vendorName', 'name'], '')).trim();\n if (!vendorName) return null;\n return {\n vendor_name: vendorName,\n vendor_domain: normalizeDomain(pick(row, ['vendor_domain', 'vendorDomain', 'domain'], ''), vendorName),\n vendor_category: String(pick(row, ['vendor_category', 'vendorCategory', 'category'], 'vendor')).trim().toLowerCase().replace(/\\s+/g, '_'),\n risk_tier_override: String(pick(row, ['risk_tier_override', 'riskTierOverride', 'risk_level', 'riskLevel'], '')),\n active: isActive(row) ? 'TRUE' : 'FALSE',\n monitoring_priority: normalizePriority(pick(row, ['monitoring_priority', 'monitoringPriority', 'priority'], '')),\n relationship_owner: String(pick(row, ['relationship_owner', 'relationshipOwner', 'owner'], 'Procurement')),\n region: String(pick(row, ['region'], 'Global')),\n risk_score: String(pick(row, ['risk_score', 'riskScore', 'score'], '')),\n next_research_date: String(pick(row, ['next_research_date', 'nextResearchDate'], '')),\n last_synced_at: String(pick(row, ['last_synced_at', 'lastSyncedAt'], '')),\n dashboard_managed: isTruthy(pick(row, ['dashboard_managed', 'dashboardManaged'], '')) ? 'TRUE' : 'FALSE',\n };\n}\n\nfunction seedRow(input) {\n return {\n vendor_name: input.vendorName,\n vendor_domain: input.vendorDomain,\n vendor_category: input.vendorCategory,\n risk_tier_override: '',\n active: 'TRUE',\n monitoring_priority: input.monitoringPriority,\n relationship_owner: 'Procurement',\n region: 'Global',\n risk_score: '',\n next_research_date: '',\n last_synced_at: now,\n dashboard_managed: 'FALSE',\n };\n}\n\nconst expectedToken = String($vars?.PROCUREMENT_DASHBOARD_WRITE_TOKEN || '').trim();\nif (!expectedToken) {\n throw new Error('Set n8n variable PROCUREMENT_DASHBOARD_WRITE_TOKEN before enabling portfolio write-back.');\n}\n\nconst actualToken = String(headerValue('x-procurement-dashboard-token') || '').trim();\nif (actualToken !== expectedToken) {\n throw new Error('Unauthorized portfolio mutation.');\n}\n\nconst action = body.action;\nif (!['addVendor', 'uploadVendors', 'resetSeedVendors'].includes(action)) {\n throw new Error('Unsupported portfolio mutation action.');\n}\n\nconst rowsByKey = new Map();\nfor (const row of currentRows) {\n const normalized = normalizeExistingRow(row);\n if (normalized) rowsByKey.set(keyFor(normalized), normalized);\n}\n\nif (action === 'addVendor') {\n const row = sheetRowFromInput(body.vendor || {});\n rowsByKey.set(keyFor(row), row);\n}\n\nif (action === 'uploadVendors') {\n const vendors = Array.isArray(body.vendors) ? body.vendors : [];\n if (!vendors.length) throw new Error('uploadVendors requires at least one vendor.');\n for (const vendor of vendors) {\n const row = sheetRowFromInput(vendor || {});\n rowsByKey.set(keyFor(row), row);\n }\n}\n\nif (action === 'resetSeedVendors') {\n const seedRows = seedVendors.map(seedRow);\n const seedKeys = new Set(seedRows.map(keyFor));\n for (const row of rowsByKey.values()) {\n if (!seedKeys.has(keyFor(row)) && row.dashboard_managed === 'TRUE') {\n row.active = 'FALSE';\n row.last_synced_at = now;\n }\n }\n for (const row of seedRows) {\n rowsByKey.set(keyFor(row), row);\n }\n}\n\nreturn Array.from(rowsByKey.values()).map(row => ({ json: row }));\n" + } + }, + { + "id": "node-51", + "name": "Portfolio: Write Vendors", + "type": "n8n-nodes-base.googleSheets", + "position": [ + 1060, + 3600 + ], + "typeVersion": 4.5, + "parameters": { + "operation": "appendOrUpdate", + "documentId": { + "__rl": true, + "mode": "id", + "value": "={{ $vars.GOOGLE_SHEET_ID }}" + }, + "sheetName": { + "__rl": true, + "mode": "name", + "value": "Vendors" + }, + "options": {}, + "columns": { + "mappingMode": "autoMapInputData" + } + } + }, + { + "id": "node-52", + "name": "Portfolio: Build Registry Rows", + "type": "n8n-nodes-base.code", + "position": [ + 1300, + 3600 + ], + "typeVersion": 2, + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "\nconst vendorRows = $('Portfolio: Build Vendor Rows').all().map(i => i.json);\nconst registryRows = $('Portfolio: Read Registry').all().map(i => i.json);\nconst now = new Date().toISOString();\n\nfunction pick(row, keys, fallback = '') {\n for (const key of keys) {\n if (row[key] !== undefined && row[key] !== null && String(row[key]).trim() !== '') {\n return row[key];\n }\n }\n return fallback;\n}\n\nfunction normalizeDomain(value, name) {\n const fallback = String(name || 'vendor').toLowerCase().replace(/[^a-z0-9]+/g, '-') + '.example';\n const raw = String(value || fallback).trim();\n return raw.startsWith('http://') || raw.startsWith('https://') ? raw : 'https://' + raw;\n}\n\nfunction keyFor(row) {\n return String(pick(row, ['vendor_domain', 'vendorDomain', 'domain'], pick(row, ['vendor_name', 'vendorName', 'name'], '')))\n .trim()\n .toLowerCase();\n}\n\nconst registryByKey = new Map();\nfor (const row of registryRows) {\n const key = keyFor(row);\n if (key) registryByKey.set(key, row);\n}\n\nreturn vendorRows.map(row => {\n const previous = registryByKey.get(keyFor(row)) || {};\n const vendorName = String(pick(row, ['vendor_name', 'vendorName', 'name'], pick(previous, ['vendor_name', 'vendorName', 'name'], 'Unknown vendor')));\n const domain = normalizeDomain(pick(row, ['vendor_domain', 'vendorDomain', 'domain'], pick(previous, ['vendor_domain', 'vendorDomain', 'domain'], '')), vendorName);\n\n return {\n json: {\n vendor_name: vendorName,\n vendor_domain: domain,\n vendor_category: String(pick(row, ['vendor_category', 'vendorCategory', 'category'], pick(previous, ['vendor_category', 'vendorCategory', 'category'], 'vendor'))),\n risk_tier_override: String(pick(row, ['risk_tier_override', 'riskTierOverride'], pick(previous, ['risk_tier_override', 'riskTierOverride'], ''))),\n active: String(pick(row, ['active'], pick(previous, ['active'], 'TRUE'))),\n monitoring_priority: String(pick(row, ['monitoring_priority', 'monitoringPriority', 'priority'], pick(previous, ['monitoring_priority', 'monitoringPriority', 'priority'], 'medium'))),\n monitor_ids: String(pick(previous, ['monitor_ids', 'monitorIds'], '')),\n next_research_date: String(pick(row, ['next_research_date', 'nextResearchDate'], pick(previous, ['next_research_date', 'nextResearchDate'], ''))),\n last_synced_at: now,\n relationship_owner: String(pick(row, ['relationship_owner', 'relationshipOwner', 'owner'], pick(previous, ['relationship_owner', 'relationshipOwner', 'owner'], 'Procurement'))),\n region: String(pick(row, ['region'], pick(previous, ['region'], 'Global'))),\n risk_score: String(pick(row, ['risk_score', 'riskScore', 'score'], pick(previous, ['risk_score', 'riskScore', 'score'], ''))),\n dashboard_managed: String(pick(row, ['dashboard_managed', 'dashboardManaged'], pick(previous, ['dashboard_managed', 'dashboardManaged'], 'FALSE'))),\n },\n };\n});\n" + } + }, + { + "id": "node-53", + "name": "Portfolio: Write Registry", + "type": "n8n-nodes-base.googleSheets", + "position": [ + 1540, + 3600 + ], + "typeVersion": 4.5, + "parameters": { + "operation": "appendOrUpdate", + "documentId": { + "__rl": true, + "mode": "id", + "value": "={{ $vars.GOOGLE_SHEET_ID }}" + }, + "sheetName": { + "__rl": true, + "mode": "name", + "value": "Registry" + }, + "options": {}, + "columns": { + "mappingMode": "autoMapInputData" + } + } + }, + { + "id": "node-54", + "name": "Portfolio: Mutation Result", + "type": "n8n-nodes-base.code", + "position": [ + 1780, + 3600 + ], + "typeVersion": 2, + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "\nconst incoming = $('Portfolio: Mutation Webhook').first().json || {};\nconst body = incoming.body && typeof incoming.body === 'object' ? incoming.body : incoming;\nconst action = body.action || 'unknown';\nconst affected = action === 'uploadVendors'\n ? (Array.isArray(body.vendors) ? body.vendors.length : 0)\n : action === 'addVendor'\n ? 1\n : $('Portfolio: Build Vendor Rows').all().length;\n\nreturn [{\n json: {\n ok: true,\n action,\n affected,\n },\n}];\n" + } + } + ], + "connections": { + "Sync: Daily Midnight Trigger": { + "main": [ + [ + { + "node": "Sync: Read Vendor List", + "type": "main", + "index": 0 + } + ] + ] + }, + "Sync: Manual Trigger": { + "main": [ + [ + { + "node": "Sync: Read Vendor List", + "type": "main", + "index": 0 + } + ] + ] + }, + "Sync: Read Vendor List": { + "main": [ + [ + { + "node": "Sync: Read Previous Registry", + "type": "main", + "index": 0 + } + ] + ] + }, + "Sync: Read Previous Registry": { + "main": [ + [ + { + "node": "Sync: Compute Diff", + "type": "main", + "index": 0 + } + ] + ] + }, + "Sync: Compute Diff": { + "main": [ + [ + { + "node": "Sync: Loop Added Vendors", + "type": "main", + "index": 0 + }, + { + "node": "Sync: Loop Removed Vendors", + "type": "main", + "index": 0 + } + ] + ] + }, + "Sync: Loop Added Vendors": { + "main": [ + [ + { + "node": "Sync: Build Monitor Payload", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Sync: Update Registry", + "type": "main", + "index": 0 + } + ] + ] + }, + "Sync: Build Monitor Payload": { + "main": [ + [ + { + "node": "Sync: Create Monitor", + "type": "main", + "index": 0 + } + ] + ] + }, + "Sync: Create Monitor": { + "main": [ + [ + { + "node": "Sync: Loop Added Vendors", + "type": "main", + "index": 0 + } + ] + ] + }, + "Sync: Loop Removed Vendors": { + "main": [ + [ + { + "node": "Sync: Delete Monitor", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Sync: Update Registry", + "type": "main", + "index": 0 + } + ] + ] + }, + "Sync: Delete Monitor": { + "main": [ + [ + { + "node": "Sync: Loop Removed Vendors", + "type": "main", + "index": 0 + } + ] + ] + }, + "Research: Daily 6AM Trigger": { + "main": [ + [ + { + "node": "Research: Read Registry", + "type": "main", + "index": 0 + } + ] + ] + }, + "Research: Manual Trigger": { + "main": [ + [ + { + "node": "Research: Read Registry", + "type": "main", + "index": 0 + } + ] + ] + }, + "Research: Read Registry": { + "main": [ + [ + { + "node": "Research: Filter Due Vendors", + "type": "main", + "index": 0 + } + ] + ] + }, + "Research: Filter Due Vendors": { + "main": [ + [ + { + "node": "Research: Build Prompts", + "type": "main", + "index": 0 + } + ] + ] + }, + "Research: Build Prompts": { + "main": [ + [ + { + "node": "Research: Loop Vendors", + "type": "main", + "index": 0 + } + ] + ] + }, + "Research: Loop Vendors": { + "main": [ + [ + { + "node": "Research: Run Deep Research", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Research: Collect Results", + "type": "main", + "index": 0 + } + ] + ] + }, + "Research: Run Deep Research": { + "main": [ + [ + { + "node": "Research: Wait 90s", + "type": "main", + "index": 0 + } + ] + ] + }, + "Research: Wait 90s": { + "main": [ + [ + { + "node": "Research: Loop Vendors", + "type": "main", + "index": 0 + } + ] + ] + }, + "Research: Collect Results": { + "main": [ + [ + { + "node": "Scoring: Risk Scorer", + "type": "main", + "index": 0 + } + ] + ] + }, + "Monitor: Deploy Webhook": { + "main": [ + [ + { + "node": "Monitor: Generate Queries", + "type": "main", + "index": 0 + } + ] + ] + }, + "Monitor: Generate Queries": { + "main": [ + [ + { + "node": "Monitor: Loop Monitors", + "type": "main", + "index": 0 + } + ] + ] + }, + "Monitor: Loop Monitors": { + "main": [ + [ + { + "node": "Monitor: Create Monitor", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Monitor: Record Monitor IDs", + "type": "main", + "index": 0 + } + ] + ] + }, + "Monitor: Create Monitor": { + "main": [ + [ + { + "node": "Monitor: Loop Monitors", + "type": "main", + "index": 0 + } + ] + ] + }, + "Monitor: Event Trigger": { + "main": [ + [ + { + "node": "Monitor: Enrich & Classify Event", + "type": "main", + "index": 0 + } + ] + ] + }, + "Monitor: Enrich & Classify Event": { + "main": [ + [ + { + "node": "Scoring: Risk Scorer", + "type": "main", + "index": 0 + } + ] + ] + }, + "AdHoc: Slack Command": { + "main": [ + [ + { + "node": "AdHoc: Parse Command", + "type": "main", + "index": 0 + } + ] + ] + }, + "AdHoc: Parse Command": { + "main": [ + [ + { + "node": "AdHoc: Send Acknowledgment", + "type": "main", + "index": 0 + } + ] + ] + }, + "AdHoc: Send Acknowledgment": { + "main": [ + [ + { + "node": "AdHoc: Start Research Task", + "type": "main", + "index": 0 + } + ] + ] + }, + "AdHoc: Result Callback": { + "main": [ + [ + { + "node": "AdHoc: Tag Source", + "type": "main", + "index": 0 + } + ] + ] + }, + "AdHoc: Tag Source": { + "main": [ + [ + { + "node": "Scoring: Risk Scorer", + "type": "main", + "index": 0 + } + ] + ] + }, + "Scoring: Risk Scorer": { + "main": [ + [ + { + "node": "Scoring: Route by Risk Level", + "type": "main", + "index": 0 + } + ] + ] + }, + "Scoring: Route by Risk Level": { + "main": [ + [ + { + "node": "Scoring: Alert Critical", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Scoring: Alert High", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Scoring: Format Digest", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Scoring: Log Low", + "type": "main", + "index": 0 + } + ] + ] + }, + "Scoring: Alert Critical": { + "main": [ + [ + { + "node": "Scoring: Audit Log", + "type": "main", + "index": 0 + } + ] + ] + }, + "Scoring: Alert High": { + "main": [ + [ + { + "node": "Scoring: Audit Log", + "type": "main", + "index": 0 + } + ] + ] + }, + "Scoring: Format Digest": { + "main": [ + [ + { + "node": "Scoring: Audit Log", + "type": "main", + "index": 0 + } + ] + ] + }, + "Scoring: Log Low": { + "main": [ + [ + { + "node": "Scoring: Audit Log", + "type": "main", + "index": 0 + } + ] + ] + }, + "Scoring: Audit Log": { + "main": [ + [ + { + "node": "Scoring: Route Back", + "type": "main", + "index": 0 + } + ] + ] + }, + "Scoring: Route Back": { + "main": [ + [ + { + "node": "Research: Update Research Dates", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "AdHoc: Post Thread Reply", + "type": "main", + "index": 0 + } + ] + ] + }, + "Snapshot: Dashboard Webhook": { + "main": [ + [ + { + "node": "Snapshot: Read Registry", + "type": "main", + "index": 0 + } + ] + ] + }, + "Snapshot: Read Registry": { + "main": [ + [ + { + "node": "Snapshot: Read Audit Log", + "type": "main", + "index": 0 + } + ] + ] + }, + "Snapshot: Read Audit Log": { + "main": [ + [ + { + "node": "Snapshot: Read Monitors", + "type": "main", + "index": 0 + } + ] + ] + }, + "Snapshot: Read Monitors": { + "main": [ + [ + { + "node": "Snapshot: Build Payload", + "type": "main", + "index": 0 + } + ] + ] + }, + "Portfolio: Mutation Webhook": { + "main": [ + [ + { + "node": "Portfolio: Read Vendors", + "type": "main", + "index": 0 + } + ] + ] + }, + "Portfolio: Read Vendors": { + "main": [ + [ + { + "node": "Portfolio: Read Registry", + "type": "main", + "index": 0 + } + ] + ] + }, + "Portfolio: Read Registry": { + "main": [ + [ + { + "node": "Portfolio: Build Vendor Rows", + "type": "main", + "index": 0 + } + ] + ] + }, + "Portfolio: Build Vendor Rows": { + "main": [ + [ + { + "node": "Portfolio: Write Vendors", + "type": "main", + "index": 0 + } + ] + ] + }, + "Portfolio: Write Vendors": { + "main": [ + [ + { + "node": "Portfolio: Build Registry Rows", + "type": "main", + "index": 0 + } + ] + ] + }, + "Portfolio: Build Registry Rows": { + "main": [ + [ + { + "node": "Portfolio: Write Registry", + "type": "main", + "index": 0 + } + ] + ] + }, + "Portfolio: Write Registry": { + "main": [ + [ + { + "node": "Portfolio: Mutation Result", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + }, + "tags": [ + "n8n-procurement", + "vendor-risk" + ] +} \ No newline at end of file diff --git a/typescript-recipes/parallel-n8n-procurement/n8n-workflows/workflow1-vendor-sync.json b/typescript-recipes/parallel-n8n-procurement/n8n-workflows/workflow1-vendor-sync.json new file mode 100644 index 0000000..6d99fca --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/n8n-workflows/workflow1-vendor-sync.json @@ -0,0 +1,359 @@ +{ + "name": "Workflow 1: Vendor Ingestion & Sync", + "nodes": [ + { + "id": "node-1", + "name": "Daily Sync Trigger", + "type": "n8n-nodes-base.scheduleTrigger", + "position": [ + 100, + 300 + ], + "typeVersion": 1.2, + "parameters": { + "rule": { + "interval": [ + { + "field": "hours", + "hoursInterval": 24, + "triggerAtHour": 0 + } + ] + } + } + }, + { + "id": "node-2", + "name": "Manual Trigger", + "type": "n8n-nodes-base.manualTrigger", + "position": [ + 100, + 500 + ], + "typeVersion": 1, + "parameters": {} + }, + { + "id": "node-3", + "name": "Read Vendor List", + "type": "n8n-nodes-base.googleSheets", + "position": [ + 340, + 300 + ], + "typeVersion": 4.5, + "parameters": { + "operation": "read", + "documentId": { + "__rl": true, + "mode": "id", + "value": "={{ $vars.GOOGLE_SHEET_ID }}" + }, + "sheetName": { + "__rl": true, + "mode": "name", + "value": "Vendors" + }, + "options": {} + } + }, + { + "id": "node-4", + "name": "Read Previous Registry", + "type": "n8n-nodes-base.googleSheets", + "position": [ + 580, + 300 + ], + "typeVersion": 4.5, + "parameters": { + "operation": "read", + "documentId": { + "__rl": true, + "mode": "id", + "value": "={{ $vars.GOOGLE_SHEET_ID }}" + }, + "sheetName": { + "__rl": true, + "mode": "name", + "value": "Registry" + }, + "options": {} + } + }, + { + "id": "node-5", + "name": "Compute Diff", + "type": "n8n-nodes-base.code", + "position": [ + 820, + 300 + ], + "typeVersion": 2, + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "\nconst incoming = $('Read Vendor List').all().map(i => i.json);\nconst previous = $('Read Previous Registry').all().map(i => i.json);\n\nconst incomingMap = new Map(incoming.map(v => [v.vendor_domain, v]));\nconst previousMap = new Map(previous.map(v => [v.vendor_domain, v]));\n\nconst added = incoming.filter(v => !previousMap.has(v.vendor_domain));\nconst removed = previous.filter(v => !incomingMap.has(v.vendor_domain));\nconst modified = incoming.filter(v => {\n const prev = previousMap.get(v.vendor_domain);\n return prev && (prev.monitoring_priority !== v.monitoring_priority || prev.vendor_category !== v.vendor_category);\n});\n\nreturn [{ json: { added, removed, modified, unchanged_count: incoming.length - added.length - modified.length } }];\n" + } + }, + { + "id": "node-6", + "name": "Loop Added Vendors", + "type": "n8n-nodes-base.splitInBatches", + "position": [ + 1060, + 100 + ], + "typeVersion": 3, + "parameters": { + "batchSize": 1, + "options": {} + } + }, + { + "id": "node-7", + "name": "Build Monitor Payload", + "type": "n8n-nodes-base.code", + "position": [ + 1300, + 100 + ], + "typeVersion": 2, + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "\nconst vendor = $json;\nconst templates = [\n { dim: \"legal\", cat: \"Legal & Regulatory\", q: `\"${vendor.vendor_name}\" lawsuit OR litigation OR regulatory action` },\n { dim: \"cyber\", cat: \"Cybersecurity\", q: `\"${vendor.vendor_name}\" data breach OR cybersecurity incident` },\n { dim: \"financial\", cat: \"Financial Health\", q: `\"${vendor.vendor_name}\" bankruptcy OR financial distress OR credit downgrade` },\n { dim: \"leadership\", cat: \"Leadership & Governance\", q: `\"${vendor.vendor_name}\" CEO departure OR executive change OR merger` },\n { dim: \"esg\", cat: \"ESG & Reputation\", q: `\"${vendor.vendor_name}\" recall OR safety violation OR environmental fine` },\n];\nconst cadence = vendor.monitoring_priority === \"low\" ? \"weekly\" : \"daily\";\nconst dims = vendor.monitoring_priority === \"high\" ? templates\n : vendor.monitoring_priority === \"medium\" ? templates.slice(0, 3)\n : [templates[0], templates[2]];\n\nreturn dims.map(t => ({\n json: {\n monitorPayload: {\n query: t.q, cadence,\n metadata: { vendor_name: vendor.vendor_name, vendor_domain: vendor.vendor_domain, monitor_category: t.cat, risk_dimension: t.dim },\n }\n }\n}));\n" + } + }, + { + "id": "node-8", + "name": "Create Monitor", + "type": "n8n-nodes-base.httpRequest", + "position": [ + 1540, + 100 + ], + "typeVersion": 4.2, + "parameters": { + "method": "POST", + "url": "https://api.parallel.ai/v1alpha/monitors", + "authentication": "genericCredentialType", + "genericAuthType": "httpHeaderAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "x-api-key", + "value": "={{ $vars.PARALLEL_API_KEY }}" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify($json.monitorPayload) }}" + } + }, + { + "id": "node-9", + "name": "Loop Removed Vendors", + "type": "n8n-nodes-base.splitInBatches", + "position": [ + 1060, + 500 + ], + "typeVersion": 3, + "parameters": { + "batchSize": 1, + "options": {} + } + }, + { + "id": "node-10", + "name": "Delete Monitor", + "type": "n8n-nodes-base.httpRequest", + "position": [ + 1300, + 500 + ], + "typeVersion": 4.2, + "parameters": { + "method": "DELETE", + "url": "=https://api.parallel.ai/v1alpha/monitors/{{ $json.monitor_id }}", + "authentication": "genericCredentialType", + "genericAuthType": "httpHeaderAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "x-api-key", + "value": "={{ $vars.PARALLEL_API_KEY }}" + } + ] + } + } + }, + { + "id": "node-11", + "name": "Update Registry", + "type": "n8n-nodes-base.googleSheets", + "position": [ + 1780, + 300 + ], + "typeVersion": 4.5, + "parameters": { + "operation": "appendOrUpdate", + "documentId": { + "__rl": true, + "mode": "id", + "value": "={{ $vars.GOOGLE_SHEET_ID }}" + }, + "sheetName": { + "__rl": true, + "mode": "name", + "value": "Registry" + }, + "options": {}, + "columns": { + "mappingMode": "autoMapInputData" + } + } + } + ], + "connections": { + "Daily Sync Trigger": { + "main": [ + [ + { + "node": "Read Vendor List", + "type": "main", + "index": 0 + } + ] + ] + }, + "Manual Trigger": { + "main": [ + [ + { + "node": "Read Vendor List", + "type": "main", + "index": 0 + } + ] + ] + }, + "Read Vendor List": { + "main": [ + [ + { + "node": "Read Previous Registry", + "type": "main", + "index": 0 + } + ] + ] + }, + "Read Previous Registry": { + "main": [ + [ + { + "node": "Compute Diff", + "type": "main", + "index": 0 + } + ] + ] + }, + "Compute Diff": { + "main": [ + [ + { + "node": "Loop Added Vendors", + "type": "main", + "index": 0 + }, + { + "node": "Loop Removed Vendors", + "type": "main", + "index": 0 + } + ] + ] + }, + "Loop Added Vendors": { + "main": [ + [ + { + "node": "Build Monitor Payload", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Update Registry", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Monitor Payload": { + "main": [ + [ + { + "node": "Create Monitor", + "type": "main", + "index": 0 + } + ] + ] + }, + "Create Monitor": { + "main": [ + [ + { + "node": "Loop Added Vendors", + "type": "main", + "index": 0 + } + ] + ] + }, + "Loop Removed Vendors": { + "main": [ + [ + { + "node": "Delete Monitor", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Update Registry", + "type": "main", + "index": 0 + } + ] + ] + }, + "Delete Monitor": { + "main": [ + [ + { + "node": "Loop Removed Vendors", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + }, + "tags": [ + "n8n-procurement", + "vendor-risk" + ] +} \ No newline at end of file diff --git a/typescript-recipes/parallel-n8n-procurement/n8n-workflows/workflow2-deep-research.json b/typescript-recipes/parallel-n8n-procurement/n8n-workflows/workflow2-deep-research.json new file mode 100644 index 0000000..4c2ea58 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/n8n-workflows/workflow2-deep-research.json @@ -0,0 +1,454 @@ +{ + "name": "Workflow 2: Scheduled Deep Research", + "nodes": [ + { + "id": "node-1", + "name": "Daily 6AM Trigger", + "type": "n8n-nodes-base.scheduleTrigger", + "position": [ + 100, + 300 + ], + "typeVersion": 1.2, + "parameters": { + "rule": { + "interval": [ + { + "field": "hours", + "hoursInterval": 24, + "triggerAtHour": 6 + } + ] + } + } + }, + { + "id": "node-2", + "name": "Manual Trigger", + "type": "n8n-nodes-base.manualTrigger", + "position": [ + 100, + 500 + ], + "typeVersion": 1, + "parameters": {} + }, + { + "id": "node-3", + "name": "Read Registry", + "type": "n8n-nodes-base.googleSheets", + "position": [ + 340, + 300 + ], + "typeVersion": 4.5, + "parameters": { + "operation": "read", + "documentId": { + "__rl": true, + "mode": "id", + "value": "={{ $vars.GOOGLE_SHEET_ID }}" + }, + "sheetName": { + "__rl": true, + "mode": "name", + "value": "Registry" + }, + "options": {} + } + }, + { + "id": "node-4", + "name": "Filter Due Vendors", + "type": "n8n-nodes-base.code", + "position": [ + 580, + 300 + ], + "typeVersion": 2, + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "\nconst today = new Date().toISOString().slice(0, 10);\nconst vendors = $input.all().map(i => i.json);\nconst due = vendors.filter(v => {\n if (v.active === false || v.active === \"false\") return false;\n if (!v.next_research_date) return true;\n return v.next_research_date.slice(0, 10) <= today;\n});\nreturn due.map(v => ({ json: v }));\n" + } + }, + { + "id": "node-5", + "name": "Create Task Group", + "type": "n8n-nodes-base.httpRequest", + "position": [ + 820, + 300 + ], + "typeVersion": 4.2, + "parameters": { + "method": "POST", + "url": "https://api.parallel.ai/v1beta/tasks/groups", + "authentication": "genericCredentialType", + "genericAuthType": "httpHeaderAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "x-api-key", + "value": "={{ $vars.PARALLEL_API_KEY }}" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({}) }}" + } + }, + { + "id": "node-6", + "name": "Build Task Runs", + "type": "n8n-nodes-base.code", + "position": [ + 1060, + 300 + ], + "typeVersion": 2, + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "\nconst vendors = $('Filter Due Vendors').all().map(i => i.json);\nconst outputSchema = {\n type: \"json\",\n json_schema: {\n type: \"object\",\n properties: {\n vendor_name: { type: \"string\" },\n overall_risk_level: { type: \"string\", enum: [\"LOW\",\"MEDIUM\",\"HIGH\",\"CRITICAL\"] },\n financial_health: { type: \"object\", properties: { status: { type: \"string\" }, findings: { type: \"string\" }, severity: { type: \"string\" } }, required: [\"status\",\"findings\",\"severity\"] },\n legal_regulatory: { type: \"object\", properties: { status: { type: \"string\" }, findings: { type: \"string\" }, severity: { type: \"string\" } }, required: [\"status\",\"findings\",\"severity\"] },\n cybersecurity: { type: \"object\", properties: { status: { type: \"string\" }, findings: { type: \"string\" }, severity: { type: \"string\" } }, required: [\"status\",\"findings\",\"severity\"] },\n leadership_governance: { type: \"object\", properties: { status: { type: \"string\" }, findings: { type: \"string\" }, severity: { type: \"string\" } }, required: [\"status\",\"findings\",\"severity\"] },\n esg_reputation: { type: \"object\", properties: { status: { type: \"string\" }, findings: { type: \"string\" }, severity: { type: \"string\" } }, required: [\"status\",\"findings\",\"severity\"] },\n adverse_events: { type: \"array\", items: { type: \"object\" } },\n recommendation: { type: \"string\" },\n },\n required: [\"vendor_name\",\"overall_risk_level\",\"financial_health\",\"legal_regulatory\",\"cybersecurity\",\"leadership_governance\",\"esg_reputation\",\"adverse_events\",\"recommendation\"]\n }\n};\nconst inputs = vendors.map(v => ({\n input: \"Conduct a vendor risk assessment of \" + v.vendor_name + \" (\" + v.vendor_domain + \").\",\n processor: \"ultra8x\"\n}));\nreturn [{ json: { runsPayload: JSON.stringify({ inputs, default_task_spec: { output_schema: outputSchema } }) } }];\n" + } + }, + { + "id": "node-7", + "name": "Add Runs to Group", + "type": "n8n-nodes-base.httpRequest", + "position": [ + 1300, + 300 + ], + "typeVersion": 4.2, + "parameters": { + "method": "POST", + "url": "=https://api.parallel.ai/v1beta/tasks/groups/{{ $('Create Task Group').item.json.taskgroup_id }}/runs", + "authentication": "genericCredentialType", + "genericAuthType": "httpHeaderAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "x-api-key", + "value": "={{ $vars.PARALLEL_API_KEY }}" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $json.runsPayload }}" + } + }, + { + "id": "node-8", + "name": "Wait 60s", + "type": "n8n-nodes-base.wait", + "position": [ + 1540, + 300 + ], + "typeVersion": 1.1, + "parameters": { + "amount": 60, + "unit": "seconds" + } + }, + { + "id": "node-9", + "name": "Poll Group Status", + "type": "n8n-nodes-base.httpRequest", + "position": [ + 1780, + 300 + ], + "typeVersion": 4.2, + "parameters": { + "method": "GET", + "url": "=https://api.parallel.ai/v1beta/tasks/groups/{{ $('Create Task Group').item.json.taskgroup_id }}", + "authentication": "genericCredentialType", + "genericAuthType": "httpHeaderAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "x-api-key", + "value": "={{ $vars.PARALLEL_API_KEY }}" + } + ] + } + } + }, + { + "id": "node-10", + "name": "Is Complete?", + "type": "n8n-nodes-base.if", + "position": [ + 2020, + 300 + ], + "typeVersion": 2, + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "leftValue": "={{ $json.status.is_active }}", + "rightValue": "false", + "operator": { + "type": "string", + "operation": "equals" + }, + "id": "condition-0" + } + ], + "combinator": "and" + } + } + }, + { + "id": "node-11", + "name": "Get Results", + "type": "n8n-nodes-base.httpRequest", + "position": [ + 2260, + 300 + ], + "typeVersion": 4.2, + "parameters": { + "method": "GET", + "url": "=https://api.parallel.ai/v1beta/tasks/groups/{{ $('Create Task Group').item.json.taskgroup_id }}/runs?include_output=true", + "authentication": "genericCredentialType", + "genericAuthType": "httpHeaderAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "x-api-key", + "value": "={{ $vars.PARALLEL_API_KEY }}" + } + ] + } + } + }, + { + "id": "node-12", + "name": "Parse Results", + "type": "n8n-nodes-base.code", + "position": [ + 2500, + 300 + ], + "typeVersion": 2, + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "\nconst results = $input.all().map(i => i.json);\nconst vendors = $('Filter Due Vendors').all().map(i => i.json);\nconst parsed = results.filter(r => r.status === \"completed\" && r.output).map((r, i) => ({\n json: {\n vendor: vendors[i] || {},\n research_output: r.output.content || r.output,\n run_id: r.run_id,\n status: r.status,\n }\n}));\nreturn parsed;\n" + } + }, + { + "id": "node-13", + "name": "Score & Route (WF3)", + "type": "n8n-nodes-base.executeWorkflow", + "position": [ + 2740, + 300 + ], + "typeVersion": 1, + "parameters": { + "source": "parameter", + "workflowId": "" + } + }, + { + "id": "node-14", + "name": "Update Research Dates", + "type": "n8n-nodes-base.googleSheets", + "position": [ + 2980, + 300 + ], + "typeVersion": 4.5, + "parameters": { + "operation": "appendOrUpdate", + "documentId": { + "__rl": true, + "mode": "id", + "value": "={{ $vars.GOOGLE_SHEET_ID }}" + }, + "sheetName": { + "__rl": true, + "mode": "name", + "value": "Registry" + }, + "options": {}, + "columns": { + "mappingMode": "autoMapInputData" + } + } + } + ], + "connections": { + "Daily 6AM Trigger": { + "main": [ + [ + { + "node": "Read Registry", + "type": "main", + "index": 0 + } + ] + ] + }, + "Manual Trigger": { + "main": [ + [ + { + "node": "Read Registry", + "type": "main", + "index": 0 + } + ] + ] + }, + "Read Registry": { + "main": [ + [ + { + "node": "Filter Due Vendors", + "type": "main", + "index": 0 + } + ] + ] + }, + "Filter Due Vendors": { + "main": [ + [ + { + "node": "Create Task Group", + "type": "main", + "index": 0 + } + ] + ] + }, + "Create Task Group": { + "main": [ + [ + { + "node": "Build Task Runs", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Task Runs": { + "main": [ + [ + { + "node": "Add Runs to Group", + "type": "main", + "index": 0 + } + ] + ] + }, + "Add Runs to Group": { + "main": [ + [ + { + "node": "Wait 60s", + "type": "main", + "index": 0 + } + ] + ] + }, + "Wait 60s": { + "main": [ + [ + { + "node": "Poll Group Status", + "type": "main", + "index": 0 + } + ] + ] + }, + "Poll Group Status": { + "main": [ + [ + { + "node": "Is Complete?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Is Complete?": { + "main": [ + [ + { + "node": "Get Results", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Wait 60s", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get Results": { + "main": [ + [ + { + "node": "Parse Results", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parse Results": { + "main": [ + [ + { + "node": "Score & Route (WF3)", + "type": "main", + "index": 0 + } + ] + ] + }, + "Score & Route (WF3)": { + "main": [ + [ + { + "node": "Update Research Dates", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + }, + "tags": [ + "n8n-procurement", + "vendor-risk" + ] +} \ No newline at end of file diff --git a/typescript-recipes/parallel-n8n-procurement/n8n-workflows/workflow3-risk-scoring.json b/typescript-recipes/parallel-n8n-procurement/n8n-workflows/workflow3-risk-scoring.json new file mode 100644 index 0000000..e009567 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/n8n-workflows/workflow3-risk-scoring.json @@ -0,0 +1,341 @@ +{ + "name": "Workflow 3: Risk Scoring & Slack Delivery", + "nodes": [ + { + "id": "node-1", + "name": "Receive Research Output", + "type": "n8n-nodes-base.executeWorkflowTrigger", + "position": [ + 100, + 300 + ], + "typeVersion": 1, + "parameters": {} + }, + { + "id": "node-2", + "name": "Risk Scorer", + "type": "n8n-nodes-base.code", + "position": [ + 340, + 300 + ], + "typeVersion": 2, + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "\nconst input = $input.first().json;\nconst output = input.research_output || input;\n\n// Step 1: Severity aggregation\nconst dims = ['financial_health','legal_regulatory','cybersecurity','leadership_governance','esg_reputation'];\nconst counts = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 };\nconst categories = [];\nconst mediumCats = [];\n\nfor (const dim of dims) {\n const sev = (output[dim]?.severity || 'LOW').toUpperCase();\n counts[sev] = (counts[sev] || 0) + 1;\n if (sev === 'CRITICAL' || sev === 'HIGH') categories.push(dim);\n if (sev === 'MEDIUM') mediumCats.push(dim);\n}\n\n// Step 2: Risk level assignment\nlet risk_level, adverse_flag;\nif (counts.CRITICAL > 0) { risk_level = 'CRITICAL'; adverse_flag = true; }\nelse if (counts.HIGH >= 1) { risk_level = 'HIGH'; adverse_flag = true; }\nelse if (counts.MEDIUM >= 3) { risk_level = 'MEDIUM'; adverse_flag = new Set(mediumCats).size >= 2; }\nelse if (counts.MEDIUM >= 1) { risk_level = 'MEDIUM'; adverse_flag = false; }\nelse { risk_level = 'LOW'; adverse_flag = false; }\n\n// Step 3: Overrides\nconst overrides = [];\nif ((output.cybersecurity?.status || '').toUpperCase() === 'CRITICAL') {\n risk_level = 'CRITICAL'; adverse_flag = true; overrides.push('active_data_breach');\n}\nif ((output.legal_regulatory?.status || '').toUpperCase() === 'CRITICAL') {\n if (['LOW','MEDIUM'].includes(risk_level)) risk_level = 'HIGH';\n adverse_flag = true; overrides.push('active_government_litigation');\n}\n\n// Step 4: Derived fields\nconst action_required = risk_level === 'HIGH' || risk_level === 'CRITICAL';\nconst recMap = { LOW: 'continue_monitoring', MEDIUM: 'escalate_review', HIGH: 'initiate_contingency', CRITICAL: 'suspend_relationship' };\nconst recommendation = recMap[risk_level];\nconst vendor_name = output.vendor_name || input.vendor?.vendor_name || 'Unknown';\nconst summary = vendor_name + ' assessed at ' + risk_level + ' risk. ' + (adverse_flag ? 'Adverse conditions detected.' : 'No adverse conditions.');\n\nreturn [{\n json: {\n vendor_name, risk_level, adverse_flag, action_required, recommendation,\n summary, categories, severity_counts: counts, triggered_overrides: overrides,\n assessment_date: new Date().toISOString().slice(0, 10),\n source: input.source || 'deep_research',\n }\n}];\n" + } + }, + { + "id": "node-3", + "name": "Route by Risk Level", + "type": "n8n-nodes-base.switch", + "position": [ + 580, + 300 + ], + "typeVersion": 3.2, + "parameters": { + "rules": { + "values": [ + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "leftValue": "={{ $json.risk_level }}", + "rightValue": "CRITICAL", + "operator": { + "type": "string", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "CRITICAL" + }, + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "leftValue": "={{ $json.risk_level }}", + "rightValue": "HIGH", + "operator": { + "type": "string", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "HIGH" + }, + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "leftValue": "={{ $json.risk_level }}", + "rightValue": "MEDIUM", + "operator": { + "type": "string", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "MEDIUM" + }, + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "leftValue": "={{ $json.risk_level }}", + "rightValue": "LOW", + "operator": { + "type": "string", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "LOW" + } + ] + }, + "options": { + "fallbackOutput": "extra" + } + } + }, + { + "id": "node-4", + "name": "Alert Critical", + "type": "n8n-nodes-base.slack", + "position": [ + 820, + -100 + ], + "typeVersion": 2.2, + "parameters": { + "resource": "message", + "operation": "post", + "channel": { + "__rl": true, + "mode": "name", + "value": "#procurement-critical" + }, + "text": "={{ \"\\ud83d\\udd34 CRITICAL: \" + $json.vendor_name + \" — \" + $json.summary }}", + "otherOptions": {} + } + }, + { + "id": "node-5", + "name": "Alert High", + "type": "n8n-nodes-base.slack", + "position": [ + 820, + 100 + ], + "typeVersion": 2.2, + "parameters": { + "resource": "message", + "operation": "post", + "channel": { + "__rl": true, + "mode": "name", + "value": "#procurement-alerts" + }, + "text": "={{ \"\\ud83d\\udfe0 HIGH: \" + $json.vendor_name + \" — \" + $json.summary }}", + "otherOptions": {} + } + }, + { + "id": "node-6", + "name": "Format Digest Entry", + "type": "n8n-nodes-base.code", + "position": [ + 820, + 300 + ], + "typeVersion": 2, + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "\nconst data = $input.first().json;\nreturn [{ json: { ...data, digest_formatted: true } }];\n" + } + }, + { + "id": "node-7", + "name": "Log Low Risk", + "type": "n8n-nodes-base.code", + "position": [ + 820, + 500 + ], + "typeVersion": 2, + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "return [$input.first()];" + } + }, + { + "id": "node-8", + "name": "Audit Log", + "type": "n8n-nodes-base.googleSheets", + "position": [ + 1060, + 300 + ], + "typeVersion": 4.5, + "parameters": { + "operation": "appendOrUpdate", + "documentId": { + "__rl": true, + "mode": "id", + "value": "={{ $vars.GOOGLE_SHEET_ID }}" + }, + "sheetName": { + "__rl": true, + "mode": "name", + "value": "Audit Log" + }, + "options": {}, + "columns": { + "mappingMode": "autoMapInputData" + } + } + } + ], + "connections": { + "Receive Research Output": { + "main": [ + [ + { + "node": "Risk Scorer", + "type": "main", + "index": 0 + } + ] + ] + }, + "Risk Scorer": { + "main": [ + [ + { + "node": "Route by Risk Level", + "type": "main", + "index": 0 + } + ] + ] + }, + "Route by Risk Level": { + "main": [ + [ + { + "node": "Alert Critical", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Alert High", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Format Digest Entry", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Log Low Risk", + "type": "main", + "index": 0 + } + ] + ] + }, + "Alert Critical": { + "main": [ + [ + { + "node": "Audit Log", + "type": "main", + "index": 0 + } + ] + ] + }, + "Alert High": { + "main": [ + [ + { + "node": "Audit Log", + "type": "main", + "index": 0 + } + ] + ] + }, + "Format Digest Entry": { + "main": [ + [ + { + "node": "Audit Log", + "type": "main", + "index": 0 + } + ] + ] + }, + "Log Low Risk": { + "main": [ + [ + { + "node": "Audit Log", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + }, + "tags": [ + "n8n-procurement", + "vendor-risk" + ] +} \ No newline at end of file diff --git a/typescript-recipes/parallel-n8n-procurement/n8n-workflows/workflow4-monitors.json b/typescript-recipes/parallel-n8n-procurement/n8n-workflows/workflow4-monitors.json new file mode 100644 index 0000000..2f7c98d --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/n8n-workflows/workflow4-monitors.json @@ -0,0 +1,285 @@ +{ + "name": "Workflow 4: Monitor Deployment & Event Routing", + "nodes": [ + { + "id": "node-1", + "name": "Deploy Trigger", + "type": "n8n-nodes-base.executeWorkflowTrigger", + "position": [ + 100, + 100 + ], + "typeVersion": 1, + "parameters": {} + }, + { + "id": "node-2", + "name": "Generate Monitor Queries", + "type": "n8n-nodes-base.code", + "position": [ + 340, + 100 + ], + "typeVersion": 2, + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "\nconst vendor = $input.first().json;\nconst templates = [\n { dim: \"legal\", cat: \"Legal & Regulatory\", q: '\"' + vendor.vendor_name + '\" lawsuit OR litigation OR regulatory action OR SEC investigation OR enforcement' },\n { dim: \"cyber\", cat: \"Cybersecurity\", q: '\"' + vendor.vendor_name + '\" data breach OR cybersecurity incident OR ransomware OR vulnerability disclosure' },\n { dim: \"financial\", cat: \"Financial Health\", q: '\"' + vendor.vendor_name + '\" bankruptcy OR financial distress OR credit downgrade OR debt default OR layoffs' },\n { dim: \"leadership\", cat: \"Leadership & Governance\", q: '\"' + vendor.vendor_name + '\" CEO departure OR executive change OR acquisition OR merger OR leadership' },\n { dim: \"esg\", cat: \"ESG & Reputation\", q: '\"' + vendor.vendor_name + '\" recall OR safety violation OR environmental fine OR labor dispute OR ESG controversy' },\n];\nconst cadence = vendor.monitoring_priority === \"low\" ? \"weekly\" : \"daily\";\nconst selected = vendor.monitoring_priority === \"high\" ? templates\n : vendor.monitoring_priority === \"medium\" ? templates.slice(0, 3)\n : [templates[0], templates[2]];\n\nreturn selected.map(t => ({\n json: {\n monitorPayload: {\n query: t.q, cadence,\n metadata: { vendor_name: vendor.vendor_name, vendor_domain: vendor.vendor_domain, monitor_category: t.cat, risk_dimension: t.dim },\n output_schema: {\n type: \"json\",\n json_schema: { type: \"object\", properties: { event_summary: { type: \"string\" }, severity: { type: \"string\" }, adverse: { type: \"boolean\" }, event_type: { type: \"string\" } }, required: [\"event_summary\",\"severity\",\"adverse\",\"event_type\"] }\n }\n }\n }\n}));\n" + } + }, + { + "id": "node-3", + "name": "Loop Monitors", + "type": "n8n-nodes-base.splitInBatches", + "position": [ + 580, + 100 + ], + "typeVersion": 3, + "parameters": { + "batchSize": 1, + "options": {} + } + }, + { + "id": "node-4", + "name": "Create Monitor", + "type": "n8n-nodes-base.httpRequest", + "position": [ + 820, + 100 + ], + "typeVersion": 4.2, + "parameters": { + "method": "POST", + "url": "https://api.parallel.ai/v1alpha/monitors", + "authentication": "genericCredentialType", + "genericAuthType": "httpHeaderAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "x-api-key", + "value": "={{ $vars.PARALLEL_API_KEY }}" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify($json.monitorPayload) }}" + } + }, + { + "id": "node-5", + "name": "Record Monitor IDs", + "type": "n8n-nodes-base.googleSheets", + "position": [ + 1060, + 100 + ], + "typeVersion": 4.5, + "parameters": { + "operation": "appendOrUpdate", + "documentId": { + "__rl": true, + "mode": "id", + "value": "={{ $vars.GOOGLE_SHEET_ID }}" + }, + "sheetName": { + "__rl": true, + "mode": "name", + "value": "Monitors" + }, + "options": {}, + "columns": { + "mappingMode": "autoMapInputData" + } + } + }, + { + "id": "node-6", + "name": "Monitor Event Webhook", + "type": "n8n-nodes-base.webhook", + "position": [ + 100, + 500 + ], + "typeVersion": 2, + "parameters": { + "path": "/webhook/monitor-events", + "httpMethod": "POST", + "responseMode": "onReceived" + } + }, + { + "id": "node-7", + "name": "Parse Webhook Payload", + "type": "n8n-nodes-base.code", + "position": [ + 340, + 500 + ], + "typeVersion": 2, + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "\nconst payload = $input.first().json;\nreturn [{ json: { monitor_id: payload.data.monitor_id, event_group_id: payload.data.event.event_group_id, metadata: payload.data.metadata || {} } }];\n" + } + }, + { + "id": "node-8", + "name": "Fetch Event Details", + "type": "n8n-nodes-base.httpRequest", + "position": [ + 580, + 500 + ], + "typeVersion": 4.2, + "parameters": { + "method": "GET", + "url": "=https://api.parallel.ai/v1alpha/monitors/{{ $json.monitor_id }}/event_groups/{{ $json.event_group_id }}", + "authentication": "genericCredentialType", + "genericAuthType": "httpHeaderAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "x-api-key", + "value": "={{ $vars.PARALLEL_API_KEY }}" + } + ] + } + } + }, + { + "id": "node-9", + "name": "Enrich & Classify Event", + "type": "n8n-nodes-base.code", + "position": [ + 820, + 500 + ], + "typeVersion": 2, + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "\nconst eventData = $input.first().json;\nconst webhookData = $('Parse Webhook Payload').item.json;\nconst events = eventData.events || [];\nconst eventEntry = events.find(e => e.type === 'event');\nlet output = {};\nif (eventEntry && eventEntry.output && typeof eventEntry.output === 'object') {\n output = eventEntry.output;\n} else if (eventEntry && typeof eventEntry.output === 'string') {\n output = { event_summary: eventEntry.output, severity: 'LOW', adverse: false, event_type: 'unknown' };\n}\nreturn [{ json: { ...webhookData, ...output, source: 'monitor_event', event_date: eventEntry?.event_date, source_urls: eventEntry?.source_urls } }];\n" + } + }, + { + "id": "node-10", + "name": "Score Event (WF3)", + "type": "n8n-nodes-base.executeWorkflow", + "position": [ + 1060, + 500 + ], + "typeVersion": 1, + "parameters": { + "source": "parameter", + "workflowId": "" + } + } + ], + "connections": { + "Deploy Trigger": { + "main": [ + [ + { + "node": "Generate Monitor Queries", + "type": "main", + "index": 0 + } + ] + ] + }, + "Generate Monitor Queries": { + "main": [ + [ + { + "node": "Loop Monitors", + "type": "main", + "index": 0 + } + ] + ] + }, + "Loop Monitors": { + "main": [ + [ + { + "node": "Create Monitor", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Record Monitor IDs", + "type": "main", + "index": 0 + } + ] + ] + }, + "Create Monitor": { + "main": [ + [ + { + "node": "Loop Monitors", + "type": "main", + "index": 0 + } + ] + ] + }, + "Monitor Event Webhook": { + "main": [ + [ + { + "node": "Parse Webhook Payload", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parse Webhook Payload": { + "main": [ + [ + { + "node": "Fetch Event Details", + "type": "main", + "index": 0 + } + ] + ] + }, + "Fetch Event Details": { + "main": [ + [ + { + "node": "Enrich & Classify Event", + "type": "main", + "index": 0 + } + ] + ] + }, + "Enrich & Classify Event": { + "main": [ + [ + { + "node": "Score Event (WF3)", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + }, + "tags": [ + "n8n-procurement", + "vendor-risk" + ] +} \ No newline at end of file diff --git a/typescript-recipes/parallel-n8n-procurement/n8n-workflows/workflow5-adhoc.json b/typescript-recipes/parallel-n8n-procurement/n8n-workflows/workflow5-adhoc.json new file mode 100644 index 0000000..9cad66f --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/n8n-workflows/workflow5-adhoc.json @@ -0,0 +1,259 @@ +{ + "name": "Workflow 5: Ad-Hoc Research via Slack", + "nodes": [ + { + "id": "node-1", + "name": "Slack Command", + "type": "n8n-nodes-base.webhook", + "position": [ + 100, + 300 + ], + "typeVersion": 2, + "parameters": { + "path": "/webhook/slack-command", + "httpMethod": "POST", + "responseMode": "onReceived" + } + }, + { + "id": "node-2", + "name": "Parse Command", + "type": "n8n-nodes-base.code", + "position": [ + 340, + 300 + ], + "typeVersion": 2, + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "\nconst payload = $input.first().json;\nconst vendor_name = (payload.text || '').trim();\nif (!vendor_name) throw new Error('Vendor name is required. Usage: /vendor-research {vendor_name}');\n\nconst prompt = 'Conduct a comprehensive vendor risk assessment of \"' + vendor_name + '\". ' +\n 'Investigate financial health, legal & regulatory, cybersecurity, leadership & governance, ESG & reputation. ' +\n 'Classify each finding by severity (LOW/MEDIUM/HIGH/CRITICAL) and include source URLs.';\n\nconst outputSchema = {\n type: \"json\",\n json_schema: {\n type: \"object\",\n properties: {\n vendor_name: { type: \"string\" },\n overall_risk_level: { type: \"string\", enum: [\"LOW\",\"MEDIUM\",\"HIGH\",\"CRITICAL\"] },\n financial_health: { type: \"object\", properties: { status: { type: \"string\" }, findings: { type: \"string\" }, severity: { type: \"string\" } } },\n legal_regulatory: { type: \"object\", properties: { status: { type: \"string\" }, findings: { type: \"string\" }, severity: { type: \"string\" } } },\n cybersecurity: { type: \"object\", properties: { status: { type: \"string\" }, findings: { type: \"string\" }, severity: { type: \"string\" } } },\n leadership_governance: { type: \"object\", properties: { status: { type: \"string\" }, findings: { type: \"string\" }, severity: { type: \"string\" } } },\n esg_reputation: { type: \"object\", properties: { status: { type: \"string\" }, findings: { type: \"string\" }, severity: { type: \"string\" } } },\n adverse_events: { type: \"array\", items: { type: \"object\" } },\n recommendation: { type: \"string\" },\n },\n required: [\"vendor_name\",\"overall_risk_level\",\"recommendation\"]\n }\n};\n\nreturn [{ json: {\n vendor_name,\n channel_id: payload.channel_id || payload.channel,\n user_name: payload.user_name || payload.user,\n response_url: payload.response_url,\n taskPayload: JSON.stringify({\n input: prompt,\n processor: \"ultra8x\",\n task_spec: { output_schema: outputSchema },\n webhook: { url: $vars.N8N_WEBHOOK_BASE_URL + \"/webhook/adhoc-result\", events: [\"task_run.status\"] }\n })\n} }];\n" + } + }, + { + "id": "node-3", + "name": "Send Acknowledgment", + "type": "n8n-nodes-base.slack", + "position": [ + 580, + 300 + ], + "typeVersion": 2.2, + "parameters": { + "resource": "message", + "operation": "post", + "channel": { + "__rl": true, + "mode": "name", + "value": "={{ $json.channel_id }}" + }, + "text": "={{ \"\\ud83d\\udd0d Starting deep research on *\" + $json.vendor_name + \"*. This typically takes 15-30 minutes...\" }}", + "otherOptions": {} + } + }, + { + "id": "node-4", + "name": "Start Research Task", + "type": "n8n-nodes-base.httpRequest", + "position": [ + 820, + 300 + ], + "typeVersion": 4.2, + "parameters": { + "method": "POST", + "url": "https://api.parallel.ai/v1/tasks/runs", + "authentication": "genericCredentialType", + "genericAuthType": "httpHeaderAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "x-api-key", + "value": "={{ $vars.PARALLEL_API_KEY }}" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $json.taskPayload }}" + }, + "notes": "Creates a single deep research run with webhook callback" + }, + { + "id": "node-5", + "name": "Result Callback", + "type": "n8n-nodes-base.webhook", + "position": [ + 100, + 700 + ], + "typeVersion": 2, + "parameters": { + "path": "/webhook/adhoc-result", + "httpMethod": "POST", + "responseMode": "onReceived" + } + }, + { + "id": "node-6", + "name": "Extract Run ID", + "type": "n8n-nodes-base.code", + "position": [ + 340, + 700 + ], + "typeVersion": 2, + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "const d = $input.first().json;\nreturn [{ json: { run_id: d.run_id || d.data?.run_id, status: d.status || d.data?.status } }];" + } + }, + { + "id": "node-7", + "name": "Get Research Result", + "type": "n8n-nodes-base.httpRequest", + "position": [ + 580, + 700 + ], + "typeVersion": 4.2, + "parameters": { + "method": "GET", + "url": "=https://api.parallel.ai/v1/tasks/runs/{{ $json.run_id }}/result", + "authentication": "genericCredentialType", + "genericAuthType": "httpHeaderAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "x-api-key", + "value": "={{ $vars.PARALLEL_API_KEY }}" + } + ] + } + } + }, + { + "id": "node-8", + "name": "Score Result (WF3)", + "type": "n8n-nodes-base.executeWorkflow", + "position": [ + 820, + 700 + ], + "typeVersion": 1, + "parameters": { + "source": "parameter", + "workflowId": "" + } + }, + { + "id": "node-9", + "name": "Post Thread Reply", + "type": "n8n-nodes-base.slack", + "position": [ + 1060, + 700 + ], + "typeVersion": 2.2, + "parameters": { + "resource": "message", + "operation": "post", + "channel": { + "__rl": true, + "mode": "name", + "value": "={{ $json.channel_id }}" + }, + "text": "={{ $json.text }}", + "otherOptions": {} + } + } + ], + "connections": { + "Slack Command": { + "main": [ + [ + { + "node": "Parse Command", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parse Command": { + "main": [ + [ + { + "node": "Send Acknowledgment", + "type": "main", + "index": 0 + } + ] + ] + }, + "Send Acknowledgment": { + "main": [ + [ + { + "node": "Start Research Task", + "type": "main", + "index": 0 + } + ] + ] + }, + "Result Callback": { + "main": [ + [ + { + "node": "Extract Run ID", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extract Run ID": { + "main": [ + [ + { + "node": "Get Research Result", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get Research Result": { + "main": [ + [ + { + "node": "Score Result (WF3)", + "type": "main", + "index": 0 + } + ] + ] + }, + "Score Result (WF3)": { + "main": [ + [ + { + "node": "Post Thread Reply", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + }, + "tags": [ + "n8n-procurement", + "vendor-risk" + ] +} \ No newline at end of file diff --git a/typescript-recipes/parallel-n8n-procurement/package-lock.json b/typescript-recipes/parallel-n8n-procurement/package-lock.json new file mode 100644 index 0000000..7970cc7 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/package-lock.json @@ -0,0 +1,1762 @@ +{ + "name": "parallel-n8n-procurement", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "parallel-n8n-procurement", + "version": "0.1.0", + "dependencies": { + "axios": "^1.7.0", + "dotenv": "^16.4.0", + "zod": "^3.24.0" + }, + "devDependencies": { + "@types/node": "^20", + "typescript": "^5.5.0", + "vitest": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.37", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", + "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/typescript-recipes/parallel-n8n-procurement/package.json b/typescript-recipes/parallel-n8n-procurement/package.json new file mode 100644 index 0000000..a1bb332 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/package.json @@ -0,0 +1,27 @@ +{ + "name": "parallel-n8n-procurement", + "version": "0.1.0", + "private": true, + "description": "Vendor Risk Monitoring system: Parallel AI Task/Monitor APIs + n8n orchestration + Slack alerts", + "type": "module", + "engines": { + "node": ">=20.0.0" + }, + "scripts": { + "build": "tsc", + "check": "tsc --noEmit", + "generate:workflows": "npm run build && node dist/src/workflows/generate-all.js ./n8n-workflows", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "axios": "^1.7.0", + "dotenv": "^16.4.0", + "zod": "^3.24.0" + }, + "devDependencies": { + "@types/node": "^20", + "typescript": "^5.5.0", + "vitest": "^2.1.0" + } +} diff --git a/typescript-recipes/parallel-n8n-procurement/parallel_procurement.md b/typescript-recipes/parallel-n8n-procurement/parallel_procurement.md new file mode 100644 index 0000000..d83c71f --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/parallel_procurement.md @@ -0,0 +1,392 @@ +# Parallel Procurement: AI-Powered Vendor Risk Monitoring + +## The Problem No One Talks About + +Your company depends on vendors. Dozens of them. Maybe hundreds. + +One of those vendors will have a bad day. A data breach. A lawsuit. A CEO resignation. A credit downgrade. It happens constantly. The question is whether you find out before it affects you or after. + +Most teams find out after. They learn about it from a news article. A client mentions it on a call. Someone stumbles across a headline on LinkedIn. By that point, the damage is already unfolding. + +This isn't a failure of effort. It's a failure of infrastructure. The tools don't exist to watch every vendor, across every risk dimension, every day. So teams default to quarterly reviews. They open a spreadsheet, assign a few analysts, and spend weeks manually researching vendors one by one. By the time the spreadsheet is complete, the findings are already stale. + +Here's what that looks like in practice: + +**The research doesn't scale.** A single vendor risk assessment takes 2-4 hours of analyst time. That's reading news coverage, checking regulatory databases, scanning financial filings, reviewing cybersecurity disclosures. At 50 vendors, that's 100-200 hours per quarter. At 200 vendors, it's a full-time job for multiple people. Most teams don't have those people. + +**The scoring is inconsistent.** Analyst A reads the same news as Analyst B and reaches a different conclusion. There's no shared rubric. No standard severity framework. No way to compare one vendor's risk profile to another's. The scoring reflects who did the work, not what the data says. + +**The alerts don't exist.** Between review cycles, nothing happens. No one is watching. A vendor could file for bankruptcy on a Tuesday and the team wouldn't know until the next quarterly review. That's not a gap. That's a canyon. + +**The audit trail is a myth.** When the board asks "how do we monitor vendor risk?" the answer is usually a spreadsheet with last quarter's date on it. There's no continuous record. No timestamps. No traceability from finding to classification to action. + +**Knowledge walks out the door.** The analyst who spent three years building vendor relationships and institutional knowledge leaves. Their context leaves with them. The next person starts from scratch. + +The result is predictable. Risk events get caught late. Responses are reactive. The organization absorbs losses that were preventable. Financial losses. Legal exposure. Reputational damage. Operational disruption. All because no one was watching. + +--- + +## What Parallel Procurement Does + +Parallel Procurement replaces the quarterly vendor review spreadsheet with an always-on intelligence operation. + +It watches your entire vendor portfolio. Every day. Across six risk dimensions. It scores every finding using consistent, transparent rules. It routes alerts to the right people in Slack. It logs every assessment for audit. It does this automatically, without human intervention, for as many vendors as you have. + +The system has five capabilities that work together as a pipeline. + +### What changes for your team + +| Before | After | +|--------|-------| +| Research a vendor manually in 2-4 hours | AI research report in minutes | +| Review vendors quarterly | Daily research cycles + continuous real-time monitoring | +| Risk scored subjectively by whoever is available | Deterministic rules applied the same way every time | +| Alerts are ad-hoc (someone notices and pings the team) | Structured alerts routed by severity to dedicated channels | +| Audit trail is a spreadsheet with last quarter's date | Every assessment logged with timestamp, rationale, and source | +| 50 vendors is the practical ceiling | 3,000+ vendors with the same team and infrastructure | + +The difference is operational. Your team stops spending time collecting information and starts spending time acting on it. + +--- + +## How It Works + +The system runs as a single automated workflow. You import it into n8n, connect your credentials, and activate it. From that point on, it operates independently. Here's what happens inside. + +### 1. Vendor Sync + +Your team maintains a Google Sheet. One tab. Six columns. Vendor name, website, category, priority level, active flag, and an optional risk override. + +That's the entire input surface. Everything else is automated. + +Every few hours, the system reads this sheet. It compares the current list against its internal registry. It computes a diff. It figures out exactly what changed. + +A new row appeared? The system recognizes a new vendor. It deploys a tailored set of monitors for that vendor through Parallel AI's Monitor API. The number and type of monitors depend on the vendor's priority level. High-priority vendors get five monitors across five risk dimensions. Medium-priority vendors get three. Low-priority vendors get two. + +A row was removed? The system deletes all associated monitors. No orphaned resources. No manual cleanup. + +A vendor's priority changed from low to high? The system removes the old monitor set and deploys a new one with broader coverage and higher cadence. The vendor is now being watched more closely. This happens without anyone asking. + +A vendor's category changed? Logged. An override was added? Respected in all future scoring. The diff engine tracks every change type: additions, removals, modifications of priority, category, override, and active status. + +The registry tab in Google Sheets is updated with the current state. Monitor IDs are recorded. Sync timestamps are written. The system always knows what it's watching and why. + +### 2. Deep Research + +Once a day, at 2 AM UTC, the research engine wakes up. + +It reads the registry. It filters for vendors that are due for research. Each vendor has a `next_research_date` field. If that date has passed, the vendor goes into the research queue. If the field is empty, the vendor is included immediately. Inactive vendors are skipped. + +The due vendors are split into batches. Each batch contains up to 50 vendors. Each batch is submitted to Parallel AI's Task API as a Task Group. + +For every vendor in the batch, the system sends a structured research prompt. The prompt asks Parallel AI to investigate six risk dimensions: + +**Financial Health.** Is the company financially stable? Look at revenue trends, credit ratings, debt levels, liquidity, bankruptcy risk, funding status, and credit downgrades. Are there signs of financial distress? + +**Legal and Regulatory.** Is the company facing legal trouble? Look at active lawsuits, regulatory enforcement actions, SEC investigations, sanctions exposure, OFAC listings, and compliance violations. Is there pending litigation that could materially impact operations? + +**Cybersecurity.** Has the company been breached? Look at data breach history, vulnerability disclosures, ransomware incidents, SOC 2 certification status, ISO 27001 compliance, and penetration test findings. What is the company's security posture? + +**Leadership and Governance.** Is the leadership stable? Look at executive turnover, CEO departures, board reshuffles, activist investor activity, mergers and acquisitions, and governance controversies. Are there signs of organizational instability? + +**ESG and Reputation.** Is the company a reputational risk? Look at environmental violations, labor disputes, workplace safety issues, product recalls, public controversies, ESG rating changes, and media sentiment. Are there issues that could reflect poorly on your organization by association? + +**Adverse Events.** Is there breaking news? Look at any sudden material changes, emergency disclosures, or negative developments that don't fit neatly into the five dimensions above. This is the catch-all for things that just happened. + +Parallel AI researches each dimension across public sources. It doesn't run keyword searches. It doesn't clip headlines. It synthesizes information from news coverage, regulatory filings, financial databases, security advisories, and other public records. It returns a structured JSON report with status assessments, finding summaries, severity ratings, source URLs, and an overall recommendation. + +The system polls the Task API until all runs complete. Failed runs don't block the batch. The system collects what succeeded and moves on. Failed vendors keep their existing `next_research_date` so they'll be picked up again in the next cycle. Nothing is lost. + +Completed results are parsed, scored, routed to Slack, and logged. The `next_research_date` for each successful vendor is advanced by 7 days. The rotation continues indefinitely. + +### 3. Risk Scoring + +Every research result passes through a scoring engine before anything is sent to Slack or logged. + +This engine is entirely rule-based. It is not AI. It does not use machine learning. It does not hallucinate. It applies a fixed set of deterministic rules to the structured research output and produces a classification. + +The rules are straightforward. + +**Severity aggregation.** The engine reads the severity rating from each of the five risk dimensions. It counts how many are CRITICAL, HIGH, MEDIUM, and LOW. + +**Risk level assignment.** Based on those counts: + +- If any dimension is rated CRITICAL, the vendor is classified **CRITICAL**. Adverse flag is set. +- If one or more dimensions are rated HIGH, the vendor is classified **HIGH**. Adverse flag is set. +- If three or more dimensions are rated MEDIUM and they span at least two different categories, the vendor is classified **MEDIUM** with adverse conditions. +- If one or two dimensions are rated MEDIUM, the vendor is classified **MEDIUM** without adverse conditions. +- If all dimensions are rated LOW, the vendor is classified **LOW**. No adverse flag. No escalation. + +**Override rules.** Three overrides are applied after the base scoring: + +1. If the cybersecurity dimension has a status of CRITICAL (indicating an active data breach), the vendor is forced to CRITICAL regardless of the base score. +2. If the legal/regulatory dimension has a status of CRITICAL (indicating active government litigation), the vendor is forced to at least HIGH. +3. If the vendor has a `risk_tier_override` set in the Google Sheet, that value acts as a floor. The system will never score the vendor lower than the override. A vendor with an override of HIGH will never be classified as MEDIUM or LOW, even if the research finds nothing concerning. + +**Recommendation mapping.** Each risk level maps to a specific recommendation: + +- **LOW** maps to `continue_monitoring`. Keep watching. No action needed. +- **MEDIUM** maps to `escalate_review`. Bring this vendor to the attention of the review committee. +- **HIGH** maps to `initiate_contingency`. Begin activating backup plans. Identify alternative vendors. +- **CRITICAL** maps to `suspend_relationship`. Consider immediately pausing or terminating the vendor relationship. + +The `action_required` flag is set to `true` for HIGH and CRITICAL. This flag drives whether the alert goes to the critical channel or the digest. + +Every scoring decision is traceable. The output includes the exact severity counts, which categories triggered the classification, which overrides fired, and a human-readable summary. When someone asks "why is this vendor flagged CRITICAL?" the answer is in the data. + +### 4. Continuous Monitoring + +The daily research cycle is comprehensive but periodic. It runs once a day. Events don't wait for schedules. + +That's where monitors come in. + +When a vendor is added to the system, the Vendor Sync process deploys a set of persistent monitors through Parallel AI's Monitor API. Each monitor watches for a specific type of event related to that vendor. The monitors run continuously. They don't wait for a cron job. + +Each monitor has a search query tailored to the vendor and risk dimension. For example, a legal monitor for Acme Corp runs a query like: `"Acme Corp" lawsuit OR litigation OR regulatory action OR SEC investigation OR enforcement`. A cybersecurity monitor runs: `"Acme Corp" data breach OR cybersecurity incident OR ransomware OR vulnerability disclosure`. + +There are five query templates. Each covers one risk dimension: + +| Dimension | What it watches for | +|-----------|-------------------| +| Legal & Regulatory | Lawsuits, litigation, regulatory actions, SEC investigations, enforcement | +| Cybersecurity | Data breaches, ransomware, vulnerability disclosures, security incidents | +| Financial Health | Bankruptcy, financial distress, credit downgrades, debt defaults, layoffs | +| Leadership & Governance | CEO departures, executive changes, acquisitions, mergers | +| ESG & Reputation | Recalls, safety violations, environmental fines, labor disputes, ESG controversies | + +Not every vendor gets all five. The allocation depends on priority: + +| Priority | Monitors | Cadence | +|----------|----------|---------| +| High | All 5 dimensions | Daily | +| Medium | Legal, Cyber, Financial | Daily | +| Low | Legal, Financial | Weekly | + +High-priority vendors get the broadest coverage at the highest frequency. Low-priority vendors get the essentials at a lower cadence. This keeps the monitor portfolio efficient without leaving gaps. + +When a monitor detects a relevant event, Parallel AI sends a webhook to the system. The system receives the event, enriches it with vendor context from the registry, and checks it against a deduplication cache. + +The dedup cache prevents alert fatigue. Its key is a combination of vendor domain, event type, and severity. If the same event has been seen within the last 24 hours, it's skipped. This matters because a major news story often triggers multiple monitors for the same vendor. Without dedup, a single data breach could generate five separate alerts. With dedup, it generates one. + +Events that pass the dedup check are scored through the same risk scoring engine used for deep research. The scored event is routed to Slack and logged to the audit trail. + +The monitor fleet is self-healing. Every day at 6 AM, a health checker runs. It lists all active monitors. It cross-references them against the vendor registry. It identifies orphans -- monitors whose vendor is no longer active. It identifies failures -- monitors that are no longer running. It deletes orphans. It recreates failed monitors with the same configuration. It pings the webhook endpoint to verify it's reachable. It sends a health report to the ops channel with counts: total monitors, active, failed, orphaned, recreated, webhook status. + +No one has to maintain the monitor fleet. It maintains itself. + +### 5. On-Demand Research + +Sometimes you can't wait for the daily cycle. + +A contract is being negotiated. The board wants a risk assessment on a potential acquisition target. A client asks about the security posture of a subprocessor. Legal needs a quick check on a vendor before signing an amendment. + +The system provides a Slack slash command: `/vendor-research [company name or domain]`. + +Type it in any channel. The system acknowledges immediately: "Starting deep research. This typically takes 15-30 minutes." Then it fires a single research task to Parallel AI with the same prompt and output schema used in the daily batch research. When the result comes back via webhook, the system scores it, formats a full report, and posts it as a thread reply in the channel where you asked. + +The report includes the risk level, the adverse flag, the recommendation, severity breakdowns across all five dimensions, which categories triggered the classification, and the full assessment summary. + +This turns every Slack channel into a vendor intelligence terminal. Any team member can run a research query at any time. No portal. No ticket. No waiting for the next quarterly review. + +--- + +## Alert Routing + +Not every finding needs the same response. A vendor sliding into moderate financial difficulty is different from a vendor disclosing an active data breach. The system treats them differently. + +Alerts are routed to four Slack channels based on the scoring engine's classification. + +**#procurement-critical** receives CRITICAL and HIGH alerts. These are immediate. They arrive in real time with full detail: which vendor, what was found, which risk dimensions are affected, what the recommendation is. CRITICAL alerts carry a 24-hour review deadline. HIGH alerts carry 48 hours. This is the channel your incident response team watches. + +**#procurement-alerts** receives standard notifications. Monitor event alerts for non-critical findings. Vendor onboarding confirmations. Status updates. These are important for awareness but don't require immediate action. + +**#procurement-digest** receives the weekly summary. MEDIUM-risk findings are not sent individually. They're batched and delivered as a digest, grouped by risk level, with total vendor counts and adverse finding summaries. This prevents the medium-severity noise that causes teams to mute channels and miss the important stuff. + +**#vendor-risk-ops** is the operations channel. It receives health check reports (how many monitors are active, how many failed, how many were recreated). It receives research run summaries (how many vendors were due, how many succeeded, how many failed, how many adverse findings). It receives error notifications when something breaks. This channel is for the team running the system, not the team consuming the intelligence. + +The routing is automatic. The scoring engine determines the destination. No human triage. No forwarding. No copy-pasting between channels. + +--- + +## The Audit Trail + +Every assessment the system produces is logged. Every single one. + +Scheduled research results. Real-time monitor events. Ad-hoc slash command reports. All of them write an entry to the Audit Log tab in Google Sheets. + +Each entry contains: + +- **Timestamp.** When the assessment was produced. ISO 8601 format. Precise to the second. +- **Vendor name.** Which vendor was assessed. +- **Risk level.** The classification assigned: LOW, MEDIUM, HIGH, or CRITICAL. +- **Adverse flag.** Whether adverse conditions were detected. +- **Categories.** Which risk dimensions triggered the classification. Comma-separated. +- **Summary.** A human-readable narrative of the assessment. +- **Run ID.** The Parallel AI task group or event group identifier. Traceable back to the source data. +- **Source.** Whether the assessment came from `deep_research` (scheduled or ad-hoc) or `monitor_event` (real-time detection). + +This audit trail is not optional. It's not a feature you turn on. It's built into the pipeline. Every path through the system -- research, monitoring, ad-hoc -- writes to the same log with the same fields. + +Why this matters: + +**Regulatory compliance.** SOC 2, ISO 27001, NIST CSF, and most procurement governance frameworks require documented evidence of continuous vendor risk assessment. This audit trail provides it. Every vendor, every assessment, every timestamp, every rationale. The record is complete and continuous, not quarterly snapshots. + +**Trend analysis.** When a vendor's risk level changes over time, you can see it. The log shows when a vendor moved from LOW to MEDIUM, when it jumped to HIGH, when it came back down. Patterns emerge. A vendor that repeatedly triggers medium-severity findings across multiple dimensions may warrant a conversation even if no single finding crosses the threshold. + +**Accountability.** When a stakeholder asks "who flagged this vendor?" the answer isn't a person. It's a system with traceable rules. The scoring engine applied rule X because dimension Y had severity Z. The override fired because the vendor's cybersecurity status was CRITICAL. The trail is complete. The logic is reproducible. The same input produces the same output every time. + +**Institutional knowledge.** People leave. Teams restructure. Analysts rotate. The audit trail doesn't. Three years from now, you can look up every assessment ever made for a vendor. The context doesn't walk out the door. + +--- + +## Why This Approach Works + +### It's a system, not a tool + +Most vendor risk products give you a dashboard. You log in, you look at data, you make decisions. That model requires humans to check the dashboard. Humans forget. Humans get busy. Dashboards go stale. + +Parallel Procurement is different. It runs without you. Research happens on schedule. Monitors watch in real time. Alerts fire automatically. The audit trail writes itself. The system does the work. Your team does the thinking. + +The value isn't in the interface. There is no interface. The value is in the pipeline that runs 24/7 and only surfaces what requires human attention. + +### It lives where your team works + +The entire output surface is Slack. Your team already has Slack open. They already read channels. They already respond to notifications. + +There is no new application. No portal to bookmark. No login to remember. No browser tab to keep open. Critical alerts appear in the channel your team watches. On-demand research is a slash command. The system is invisible until it has something to say. + +This matters for adoption. Tools that require behavior change fail. Tools that meet people where they already are succeed. + +### It scales without effort + +Adding a vendor takes 30 seconds. Open the Google Sheet. Type a name, a domain, a category, and a priority. Save. The system picks it up on the next sync cycle. Monitors are deployed. Research is scheduled. Alerts are routed. No configuration. No onboarding workflow. No capacity planning. + +Removing a vendor is the same. Delete the row. The system cleans up monitors, stops research, and moves on. + +The system handles 15 vendors the same way it handles 3,000. The infrastructure doesn't change. The team doesn't change. The cost scales linearly with the number of vendors, not exponentially. + +### Scoring is deterministic + +This is a deliberate design choice. + +AI is excellent at research. It synthesizes information from thousands of sources, identifies relevant findings, and structures them into a coherent report. That's the hard part. That's where Parallel AI adds value. + +But risk classification is a policy decision. It should be consistent. It should be auditable. It should be explainable to a board of directors. + +The scoring engine uses fixed rules. Any CRITICAL dimension means the vendor is CRITICAL. Any HIGH dimension means the vendor is HIGH. Three or more MEDIUM dimensions across two categories means MEDIUM with adverse. These rules don't change based on who's running the system or what day it is. + +When a stakeholder asks "why was this vendor flagged?" the answer is traceable. Dimension X had severity Y. Override Z was triggered. The recommendation follows from the risk level. There is no black box. There is no "the AI decided." + +This separation -- AI for research, rules for scoring -- gives you the best of both worlds. Intelligence at scale. Governance you can trust. + +### It runs on commodity infrastructure + +The system is built on three services you probably already have: + +- **Google Sheets** for the vendor registry, audit log, and monitor tracking. No database. No migrations. No DBA. +- **Slack** for alerts, digests, and on-demand research. No custom UI. No frontend to deploy. +- **n8n** for workflow orchestration. Open source. Self-hostable. Or use n8n Cloud. + +Parallel AI provides the intelligence layer -- the research and monitoring capabilities. Everything else is standard, inspectable, and replaceable. + +There is no proprietary platform. No vendor lock-in on the orchestration layer. The workflows are importable JSON files. You can read them, modify them, extend them, or rewrite them. The Google Sheet is a Google Sheet. The Slack messages are Slack messages. Nothing is opaque. + +### It maintains itself + +The monitor fleet runs a daily health check. It finds monitors that stopped working and recreates them. It finds monitors for vendors that are no longer active and deletes them. It pings its own webhook endpoint to make sure it's reachable. It reports the results to the ops channel. + +The research orchestrator tracks failures and sends run summaries when things go wrong. If a batch has failures or adverse findings, the ops channel gets a notification with counts and details. + +Failed vendors aren't dropped. Their research dates aren't advanced. They stay in the queue and get picked up again on the next cycle. + +The system degrades gracefully and recovers automatically. It doesn't need babysitting. + +--- + +## Who Should Use This + +**Procurement teams with 20+ vendors.** You've outgrown the quarterly spreadsheet. You need continuous coverage but don't have the headcount to do it manually. This system automates the research and gives your analysts time back to focus on the vendors that actually need human attention. + +**Third-party risk management teams.** Your regulatory framework requires continuous vendor monitoring. SOC 2 auditors want to see evidence of ongoing assessment, not a snapshot from three months ago. This system provides that evidence automatically, with a complete audit trail. + +**Security teams worried about supply chain risk.** You've seen what happens when a vendor gets breached and you find out from Twitter. Real-time monitors catch these events as they happen. Alerts hit Slack in minutes, not days. + +**Finance leaders managing vendor concentration risk.** A critical supplier's financial distress can disrupt your operations before you know it's happening. Weekly financial health monitoring across every vendor gives you early warning. + +**Any organization that's been surprised by a vendor.** A breach you learned about from the press. A bankruptcy that disrupted deliveries. A regulatory action that created legal exposure. If any of these have happened to you, the cost of not monitoring is already clear. This system exists so it doesn't happen again. + +--- + +## Getting Started + +Setup takes 30 minutes. You need three accounts: + +1. **Parallel AI** -- provides the research and monitoring intelligence +2. **Google** -- for the vendor registry and audit log (any Google account) +3. **Slack** -- for alerts and on-demand research (admin access to create channels) + +The system ships with everything pre-built: + +- **A single combined workflow** (56 nodes, zero cross-workflow wiring) ready to import into n8n +- **Google Sheets templates** with 15 seed vendors across five industries so you can test immediately +- **A step-by-step setup guide** covering credential configuration, environment variables, and activation testing + +Import the workflow. Connect your credentials. Set four environment variables. Activate. Run the first sync manually. Watch the Registry tab populate. Watch Slack light up. + +You'll have vendor risk intelligence running within the hour. + +--- + +## Positioning Summary + +### One-liner + +Automated vendor risk intelligence that watches your supply chain continuously, scores risk consistently, and delivers actionable alerts to Slack. + +### Elevator pitch + +Parallel Procurement replaces quarterly vendor reviews with continuous AI-powered monitoring. It researches every vendor in your portfolio across six risk dimensions. It scores findings using transparent, deterministic rules. It routes alerts to Slack by severity. It maintains a complete audit trail for compliance. + +Your team goes from reactive to proactive. From learning about vendor problems on the news to catching them as they develop. From quarterly spreadsheets to daily intelligence. From inconsistent analyst judgment to reproducible, auditable scoring. + +Setup takes 30 minutes. It scales from 15 vendors to 3,000 with no additional effort. It runs autonomously after activation. + +### Key differentiators for sales conversations + +1. **Time to value.** 30-minute setup. First results within the hour. No implementation project. No professional services engagement. No 6-month rollout. Import a workflow, connect three credentials, press play. + +2. **Research depth.** Six risk dimensions per vendor, synthesized from across the web by Parallel AI. This is not keyword alerting. Not news clipping. Not a Google Alert. It's structured, sourced intelligence with severity ratings and recommendations. + +3. **Deterministic scoring.** Rules-based risk classification. Auditable. Explainable. Reproducible. When a procurement leader asks why a vendor was flagged, the answer is traceable to specific findings and specific rules. No black box. + +4. **Dual coverage model.** Scheduled deep research (comprehensive, periodic, daily) plus continuous monitoring (real-time, event-driven, always on). Most solutions offer one or the other. This system does both, through the same scoring engine, into the same audit log. + +5. **Slack-native delivery.** Zero adoption friction. Alerts, digests, on-demand research, and ops notifications all happen in Slack. No new tool to deploy. No training. No behavior change. + +6. **Transparent infrastructure.** Built on Google Sheets + n8n + Slack. No proprietary platform. No database to manage. Fully inspectable, fully extensible. The workflows are JSON files you can read. + +7. **Self-healing operations.** Monitor health checks. Automatic orphan cleanup. Failed monitor recreation. Research retry on failure. Error reporting to ops. The system maintains itself. + +### Target buyer personas + +| Persona | Their pain | Your message | +|---------|-----------|-------------| +| **VP of Procurement** | "We can't research 200 vendors quarterly with 3 analysts." | Automate the research. Your team focuses on decisions, not data collection. 200 vendors researched daily, automatically. | +| **Chief Risk Officer** | "We need continuous monitoring for SOC 2 / regulatory compliance." | Every vendor assessed on schedule. Complete audit trail. Continuous, not periodic. Auditor-ready from day one. | +| **Head of IT / Security** | "We found out about our vendor's breach from Twitter." | Real-time monitors catch events as they happen. Alerts hit Slack in minutes. Not days. Not quarters. Minutes. | +| **CFO / COO** | "A supplier bankruptcy caught us off guard and disrupted operations." | Financial health monitoring across every vendor, every week. Early warning before distress becomes disruption. | + +### Competitive positioning + +| Traditional TPRM platforms | Parallel Procurement | +|---------------------------|---------------------| +| 3-6 month implementation | 30-minute setup | +| $50K-$500K annual contracts | Pay-per-use AI research | +| Annual or quarterly assessments | Daily research + continuous monitoring | +| Portal-based (another tool to check) | Slack-native (alerts come to you) | +| Proprietary risk scores (black box) | Transparent, rule-based scoring | +| Requires dedicated risk team to operate | Runs autonomously after activation | +| Static questionnaire-based assessments | AI-synthesized web intelligence | +| Manual vendor onboarding | Add a row to a spreadsheet | +| No real-time detection | Persistent monitors with webhook alerting | +| Audit trail requires export/configuration | Every assessment logged automatically | diff --git a/typescript-recipes/parallel-n8n-procurement/sample-setup.excalidraw b/typescript-recipes/parallel-n8n-procurement/sample-setup.excalidraw new file mode 100644 index 0000000..9231679 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/sample-setup.excalidraw @@ -0,0 +1,164 @@ +{ + "type": "excalidraw", + "version": 2, + "source": "https://excalidraw.com", + "elements": [ + + {"id":"title","type":"text","x":600,"y":-20,"width":900,"height":40,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":1,"version":1,"versionNonce":1,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"Parallel Procurement — Live System Snapshot (15 Vendors, 57 Monitors, 5 Research Agents)","fontSize":24,"fontFamily":2,"textAlign":"center","verticalAlign":"top","containerId":null,"originalText":"Parallel Procurement — Live System Snapshot (15 Vendors, 57 Monitors, 5 Research Agents)","autoResize":true,"lineHeight":1.25}, + + {"id":"vendors_bg","type":"rectangle","x":20,"y":40,"width":340,"height":860,"angle":0,"strokeColor":"#495057","backgroundColor":"#f8f9fa","fillStyle":"solid","strokeWidth":1,"strokeStyle":"dashed","roughness":0,"opacity":20,"groupIds":[],"frameId":null,"roundness":{"type":3},"seed":10,"version":1,"versionNonce":10,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false}, + {"id":"vendors_title","type":"text","x":40,"y":50,"width":300,"height":20,"angle":0,"strokeColor":"#495057","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":11,"version":1,"versionNonce":11,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"YOUR VENDOR PORTFOLIO","fontSize":16,"fontFamily":2,"textAlign":"left","verticalAlign":"top","containerId":null,"originalText":"YOUR VENDOR PORTFOLIO","autoResize":true,"lineHeight":1.25}, + + {"id":"v_ms","type":"rectangle","x":40,"y":85,"width":145,"height":40,"angle":0,"strokeColor":"#1971c2","backgroundColor":"#dbeafe","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":3},"seed":100,"version":1,"versionNonce":100,"isDeleted":false,"boundElements":[{"id":"v_ms_t","type":"text"},{"id":"a_ms_r1","type":"arrow"},{"id":"a_ms_m1","type":"arrow"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"v_ms_t","type":"text","x":50,"y":92,"width":125,"height":26,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":101,"version":1,"versionNonce":101,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"Microsoft HIGH","fontSize":13,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"v_ms","originalText":"Microsoft HIGH","autoResize":true,"lineHeight":1.25}, + + {"id":"v_aws","type":"rectangle","x":195,"y":85,"width":145,"height":40,"angle":0,"strokeColor":"#1971c2","backgroundColor":"#dbeafe","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":3},"seed":102,"version":1,"versionNonce":102,"isDeleted":false,"boundElements":[{"id":"v_aws_t","type":"text"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"v_aws_t","type":"text","x":205,"y":92,"width":125,"height":26,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":103,"version":1,"versionNonce":103,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"AWS HIGH","fontSize":13,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"v_aws","originalText":"AWS HIGH","autoResize":true,"lineHeight":1.25}, + + {"id":"v_sf","type":"rectangle","x":40,"y":135,"width":145,"height":40,"angle":0,"strokeColor":"#1971c2","backgroundColor":"#dbeafe","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":3},"seed":104,"version":1,"versionNonce":104,"isDeleted":false,"boundElements":[{"id":"v_sf_t","type":"text"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"v_sf_t","type":"text","x":50,"y":142,"width":125,"height":26,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":105,"version":1,"versionNonce":105,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"Salesforce HIGH","fontSize":13,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"v_sf","originalText":"Salesforce HIGH","autoResize":true,"lineHeight":1.25}, + + {"id":"v_cs","type":"rectangle","x":195,"y":135,"width":145,"height":40,"angle":0,"strokeColor":"#1971c2","backgroundColor":"#dbeafe","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":3},"seed":106,"version":1,"versionNonce":106,"isDeleted":false,"boundElements":[{"id":"v_cs_t","type":"text"},{"id":"a_cs_r1","type":"arrow"},{"id":"a_cs_m1","type":"arrow"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"v_cs_t","type":"text","x":205,"y":142,"width":125,"height":26,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":107,"version":1,"versionNonce":107,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"CrowdStrike HIGH","fontSize":13,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"v_cs","originalText":"CrowdStrike HIGH","autoResize":true,"lineHeight":1.25}, + + {"id":"v_jpm","type":"rectangle","x":40,"y":205,"width":145,"height":40,"angle":0,"strokeColor":"#2f9e44","backgroundColor":"#d3f9d8","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":3},"seed":110,"version":1,"versionNonce":110,"isDeleted":false,"boundElements":[{"id":"v_jpm_t","type":"text"},{"id":"a_jpm_m1","type":"arrow"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"v_jpm_t","type":"text","x":50,"y":212,"width":125,"height":26,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":111,"version":1,"versionNonce":111,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"JPMorgan HIGH","fontSize":13,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"v_jpm","originalText":"JPMorgan HIGH","autoResize":true,"lineHeight":1.25}, + + {"id":"v_gs","type":"rectangle","x":195,"y":205,"width":145,"height":40,"angle":0,"strokeColor":"#2f9e44","backgroundColor":"#d3f9d8","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":3},"seed":112,"version":1,"versionNonce":112,"isDeleted":false,"boundElements":[{"id":"v_gs_t","type":"text"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"v_gs_t","type":"text","x":205,"y":212,"width":125,"height":26,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":113,"version":1,"versionNonce":113,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"Goldman Sachs MED","fontSize":13,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"v_gs","originalText":"Goldman Sachs MED","autoResize":true,"lineHeight":1.25}, + + {"id":"v_str","type":"rectangle","x":40,"y":255,"width":145,"height":40,"angle":0,"strokeColor":"#2f9e44","backgroundColor":"#d3f9d8","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":3},"seed":114,"version":1,"versionNonce":114,"isDeleted":false,"boundElements":[{"id":"v_str_t","type":"text"},{"id":"a_str_r1","type":"arrow"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"v_str_t","type":"text","x":50,"y":262,"width":125,"height":26,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":115,"version":1,"versionNonce":115,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"Stripe HIGH","fontSize":13,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"v_str","originalText":"Stripe HIGH","autoResize":true,"lineHeight":1.25}, + + {"id":"v_uhg","type":"rectangle","x":40,"y":325,"width":145,"height":40,"angle":0,"strokeColor":"#c92a2a","backgroundColor":"#fce4ec","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":3},"seed":120,"version":1,"versionNonce":120,"isDeleted":false,"boundElements":[{"id":"v_uhg_t","type":"text"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"v_uhg_t","type":"text","x":50,"y":332,"width":125,"height":26,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":121,"version":1,"versionNonce":121,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"UnitedHealth HIGH","fontSize":13,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"v_uhg","originalText":"UnitedHealth HIGH","autoResize":true,"lineHeight":1.25}, + + {"id":"v_pfe","type":"rectangle","x":195,"y":325,"width":145,"height":40,"angle":0,"strokeColor":"#c92a2a","backgroundColor":"#fce4ec","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":3},"seed":122,"version":1,"versionNonce":122,"isDeleted":false,"boundElements":[{"id":"v_pfe_t","type":"text"},{"id":"a_pfe_m1","type":"arrow"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"v_pfe_t","type":"text","x":205,"y":332,"width":125,"height":26,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":123,"version":1,"versionNonce":123,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"Pfizer MED","fontSize":13,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"v_pfe","originalText":"Pfizer MED","autoResize":true,"lineHeight":1.25}, + + {"id":"v_jnj","type":"rectangle","x":40,"y":375,"width":145,"height":40,"angle":0,"strokeColor":"#c92a2a","backgroundColor":"#fce4ec","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":3},"seed":124,"version":1,"versionNonce":124,"isDeleted":false,"boundElements":[{"id":"v_jnj_t","type":"text"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"v_jnj_t","type":"text","x":50,"y":382,"width":125,"height":26,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":125,"version":1,"versionNonce":125,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"J&J MED","fontSize":13,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"v_jnj","originalText":"J&J MED","autoResize":true,"lineHeight":1.25}, + + {"id":"v_sie","type":"rectangle","x":40,"y":445,"width":145,"height":40,"angle":0,"strokeColor":"#495057","backgroundColor":"#f1f3f5","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":3},"seed":130,"version":1,"versionNonce":130,"isDeleted":false,"boundElements":[{"id":"v_sie_t","type":"text"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"v_sie_t","type":"text","x":50,"y":452,"width":125,"height":26,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":131,"version":1,"versionNonce":131,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"Siemens MED","fontSize":13,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"v_sie","originalText":"Siemens MED","autoResize":true,"lineHeight":1.25}, + + {"id":"v_cat","type":"rectangle","x":195,"y":445,"width":145,"height":40,"angle":0,"strokeColor":"#495057","backgroundColor":"#f1f3f5","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":3},"seed":132,"version":1,"versionNonce":132,"isDeleted":false,"boundElements":[{"id":"v_cat_t","type":"text"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"v_cat_t","type":"text","x":205,"y":452,"width":125,"height":26,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":133,"version":1,"versionNonce":133,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"Caterpillar LOW","fontSize":13,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"v_cat","originalText":"Caterpillar LOW","autoResize":true,"lineHeight":1.25}, + + {"id":"v_mmm","type":"rectangle","x":40,"y":495,"width":145,"height":40,"angle":0,"strokeColor":"#495057","backgroundColor":"#f1f3f5","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":3},"seed":134,"version":1,"versionNonce":134,"isDeleted":false,"boundElements":[{"id":"v_mmm_t","type":"text"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"v_mmm_t","type":"text","x":50,"y":502,"width":125,"height":26,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":135,"version":1,"versionNonce":135,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"3M LOW","fontSize":13,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"v_mmm","originalText":"3M LOW","autoResize":true,"lineHeight":1.25}, + + {"id":"v_del","type":"rectangle","x":40,"y":565,"width":145,"height":40,"angle":0,"strokeColor":"#6741d9","backgroundColor":"#e8dff5","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":3},"seed":140,"version":1,"versionNonce":140,"isDeleted":false,"boundElements":[{"id":"v_del_t","type":"text"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"v_del_t","type":"text","x":50,"y":572,"width":125,"height":26,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":141,"version":1,"versionNonce":141,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"Deloitte MED","fontSize":13,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"v_del","originalText":"Deloitte MED","autoResize":true,"lineHeight":1.25}, + + {"id":"v_acn","type":"rectangle","x":195,"y":565,"width":145,"height":40,"angle":0,"strokeColor":"#6741d9","backgroundColor":"#e8dff5","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":3},"seed":142,"version":1,"versionNonce":142,"isDeleted":false,"boundElements":[{"id":"v_acn_t","type":"text"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"v_acn_t","type":"text","x":205,"y":572,"width":125,"height":26,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":143,"version":1,"versionNonce":143,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"Accenture MED","fontSize":13,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"v_acn","originalText":"Accenture MED","autoResize":true,"lineHeight":1.25}, + + {"id":"agents_bg","type":"rectangle","x":420,"y":40,"width":780,"height":860,"angle":0,"strokeColor":"#6741d9","backgroundColor":"#f3d9fa","fillStyle":"solid","strokeWidth":1,"strokeStyle":"dashed","roughness":0,"opacity":12,"groupIds":[],"frameId":null,"roundness":{"type":3},"seed":200,"version":1,"versionNonce":200,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false}, + {"id":"agents_title","type":"text","x":440,"y":50,"width":500,"height":20,"angle":0,"strokeColor":"#6741d9","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":201,"version":1,"versionNonce":201,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"PARALLEL AI — AGENTS ACROSS THE WEB","fontSize":16,"fontFamily":2,"textAlign":"left","verticalAlign":"top","containerId":null,"originalText":"PARALLEL AI — AGENTS ACROSS THE WEB","autoResize":true,"lineHeight":1.25}, + + {"id":"r_label","type":"text","x":440,"y":78,"width":300,"height":16,"angle":0,"strokeColor":"#6741d9","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":70,"groupIds":[],"frameId":null,"roundness":null,"seed":202,"version":1,"versionNonce":202,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"Deep Research Agents (daily 2 AM batch)","fontSize":12,"fontFamily":2,"textAlign":"left","verticalAlign":"top","containerId":null,"originalText":"Deep Research Agents (daily 2 AM batch)","autoResize":true,"lineHeight":1.25}, + + {"id":"r1","type":"rectangle","x":440,"y":100,"width":350,"height":110,"angle":0,"strokeColor":"#6741d9","backgroundColor":"#d0bfff","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":3},"seed":210,"version":1,"versionNonce":210,"isDeleted":false,"boundElements":[{"id":"r1_t","type":"text"},{"id":"a_ms_r1","type":"arrow"},{"id":"a_r1_score","type":"arrow"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"r1_t","type":"text","x":450,"y":107,"width":330,"height":96,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":211,"version":1,"versionNonce":211,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"Agent: Microsoft\nFinancial: LOW | Legal: LOW | Cyber: LOW\nLeadership: LOW | ESG: LOW\nAdverse events: none\nResult: LOW — continue_monitoring","fontSize":12,"fontFamily":3,"textAlign":"left","verticalAlign":"top","containerId":"r1","originalText":"Agent: Microsoft\nFinancial: LOW | Legal: LOW | Cyber: LOW\nLeadership: LOW | ESG: LOW\nAdverse events: none\nResult: LOW — continue_monitoring","autoResize":true,"lineHeight":1.25}, + + {"id":"r2","type":"rectangle","x":440,"y":220,"width":350,"height":110,"angle":0,"strokeColor":"#c92a2a","backgroundColor":"#ffc9c9","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":3},"seed":212,"version":1,"versionNonce":212,"isDeleted":false,"boundElements":[{"id":"r2_t","type":"text"},{"id":"a_cs_r1","type":"arrow"},{"id":"a_r2_score","type":"arrow"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"r2_t","type":"text","x":450,"y":227,"width":330,"height":96,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":213,"version":1,"versionNonce":213,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"Agent: CrowdStrike\nFinancial: LOW | Legal: LOW | Cyber: CRITICAL\nLeadership: LOW | ESG: LOW\nAdverse: Vulnerability disclosure in endpoint platform\nResult: CRITICAL — suspend_relationship","fontSize":12,"fontFamily":3,"textAlign":"left","verticalAlign":"top","containerId":"r2","originalText":"Agent: CrowdStrike\nFinancial: LOW | Legal: LOW | Cyber: CRITICAL\nLeadership: LOW | ESG: LOW\nAdverse: Vulnerability disclosure in endpoint platform\nResult: CRITICAL — suspend_relationship","autoResize":true,"lineHeight":1.25}, + + {"id":"r3","type":"rectangle","x":440,"y":340,"width":350,"height":110,"angle":0,"strokeColor":"#e67700","backgroundColor":"#ffe8cc","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":3},"seed":214,"version":1,"versionNonce":214,"isDeleted":false,"boundElements":[{"id":"r3_t","type":"text"},{"id":"a_str_r1","type":"arrow"},{"id":"a_r3_score","type":"arrow"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"r3_t","type":"text","x":450,"y":347,"width":330,"height":96,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":215,"version":1,"versionNonce":215,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"Agent: Stripe\nFinancial: LOW | Legal: LOW | Cyber: LOW\nLeadership: LOW | ESG: LOW\nAdverse events: none\nResult: LOW — continue_monitoring","fontSize":12,"fontFamily":3,"textAlign":"left","verticalAlign":"top","containerId":"r3","originalText":"Agent: Stripe\nFinancial: LOW | Legal: LOW | Cyber: LOW\nLeadership: LOW | ESG: LOW\nAdverse events: none\nResult: LOW — continue_monitoring","autoResize":true,"lineHeight":1.25}, + + {"id":"r4_label","type":"text","x":445,"y":460,"width":340,"height":30,"angle":0,"strokeColor":"#6741d9","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":60,"groupIds":[],"frameId":null,"roundness":null,"seed":216,"version":1,"versionNonce":216,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"... + 12 more agents researching in parallel\n(JPMorgan, UnitedHealth, Pfizer, AWS, Salesforce, ...)","fontSize":11,"fontFamily":2,"textAlign":"left","verticalAlign":"top","containerId":null,"originalText":"... + 12 more agents researching in parallel\n(JPMorgan, UnitedHealth, Pfizer, AWS, Salesforce, ...)","autoResize":true,"lineHeight":1.25}, + + {"id":"m_label","type":"text","x":440,"y":510,"width":350,"height":16,"angle":0,"strokeColor":"#6741d9","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":70,"groupIds":[],"frameId":null,"roundness":null,"seed":300,"version":1,"versionNonce":300,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"57 Persistent Monitors (always watching)","fontSize":12,"fontFamily":2,"textAlign":"left","verticalAlign":"top","containerId":null,"originalText":"57 Persistent Monitors (always watching)","autoResize":true,"lineHeight":1.25}, + + {"id":"m1","type":"rectangle","x":440,"y":535,"width":240,"height":35,"angle":0,"strokeColor":"#2f9e44","backgroundColor":"#ebfbee","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":3},"seed":310,"version":1,"versionNonce":310,"isDeleted":false,"boundElements":[{"id":"m1_t","type":"text"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"m1_t","type":"text","x":448,"y":540,"width":224,"height":25,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":311,"version":1,"versionNonce":311,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"MS — cyber: watching...","fontSize":11,"fontFamily":3,"textAlign":"left","verticalAlign":"middle","containerId":"m1","originalText":"MS — cyber: watching...","autoResize":true,"lineHeight":1.25}, + + {"id":"m2","type":"rectangle","x":690,"y":535,"width":240,"height":35,"angle":0,"strokeColor":"#2f9e44","backgroundColor":"#ebfbee","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":3},"seed":312,"version":1,"versionNonce":312,"isDeleted":false,"boundElements":[{"id":"m2_t","type":"text"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"m2_t","type":"text","x":698,"y":540,"width":224,"height":25,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":313,"version":1,"versionNonce":313,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"MS — legal: watching...","fontSize":11,"fontFamily":3,"textAlign":"left","verticalAlign":"middle","containerId":"m2","originalText":"MS — legal: watching...","autoResize":true,"lineHeight":1.25}, + + {"id":"m3","type":"rectangle","x":440,"y":578,"width":240,"height":35,"angle":0,"strokeColor":"#2f9e44","backgroundColor":"#ebfbee","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":3},"seed":314,"version":1,"versionNonce":314,"isDeleted":false,"boundElements":[{"id":"m3_t","type":"text"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"m3_t","type":"text","x":448,"y":583,"width":224,"height":25,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":315,"version":1,"versionNonce":315,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"JPM — financial: watching...","fontSize":11,"fontFamily":3,"textAlign":"left","verticalAlign":"middle","containerId":"m3","originalText":"JPM — financial: watching...","autoResize":true,"lineHeight":1.25}, + + {"id":"m4","type":"rectangle","x":690,"y":578,"width":240,"height":35,"angle":0,"strokeColor":"#2f9e44","backgroundColor":"#ebfbee","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":3},"seed":316,"version":1,"versionNonce":316,"isDeleted":false,"boundElements":[{"id":"m4_t","type":"text"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"m4_t","type":"text","x":698,"y":583,"width":224,"height":25,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":317,"version":1,"versionNonce":317,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"STR — cyber: watching...","fontSize":11,"fontFamily":3,"textAlign":"left","verticalAlign":"middle","containerId":"m4","originalText":"STR — cyber: watching...","autoResize":true,"lineHeight":1.25}, + + {"id":"m5","type":"rectangle","x":440,"y":621,"width":240,"height":35,"angle":0,"strokeColor":"#c92a2a","backgroundColor":"#ffc9c9","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":3},"seed":318,"version":1,"versionNonce":318,"isDeleted":false,"boundElements":[{"id":"m5_t","type":"text"},{"id":"a_pfe_m1","type":"arrow"},{"id":"a_m5_score","type":"arrow"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"m5_t","type":"text","x":448,"y":626,"width":224,"height":25,"angle":0,"strokeColor":"#c92a2a","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":319,"version":1,"versionNonce":319,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"PFE — legal: EVENT DETECTED","fontSize":11,"fontFamily":3,"textAlign":"left","verticalAlign":"middle","containerId":"m5","originalText":"PFE — legal: EVENT DETECTED","autoResize":true,"lineHeight":1.25}, + + {"id":"m6","type":"rectangle","x":690,"y":621,"width":240,"height":35,"angle":0,"strokeColor":"#e67700","backgroundColor":"#fff3bf","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":3},"seed":320,"version":1,"versionNonce":320,"isDeleted":false,"boundElements":[{"id":"m6_t","type":"text"},{"id":"a_jpm_m1","type":"arrow"},{"id":"a_m6_score","type":"arrow"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"m6_t","type":"text","x":698,"y":626,"width":224,"height":25,"angle":0,"strokeColor":"#e67700","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":321,"version":1,"versionNonce":321,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"JPM — legal: EVENT DETECTED","fontSize":11,"fontFamily":3,"textAlign":"left","verticalAlign":"middle","containerId":"m6","originalText":"JPM — legal: EVENT DETECTED","autoResize":true,"lineHeight":1.25}, + + {"id":"m_more","type":"text","x":440,"y":665,"width":400,"height":30,"angle":0,"strokeColor":"#868e96","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":60,"groupIds":[],"frameId":null,"roundness":null,"seed":330,"version":1,"versionNonce":330,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"+ 51 more monitors: CrowdStrike (5), AWS (5), Salesforce (5),\nGoldman Sachs (3), J&J (3), Siemens (3), Deloitte (3), Accenture (3), ...","fontSize":10,"fontFamily":2,"textAlign":"left","verticalAlign":"top","containerId":null,"originalText":"+ 51 more monitors: CrowdStrike (5), AWS (5), Salesforce (5),\nGoldman Sachs (3), J&J (3), Siemens (3), Deloitte (3), Accenture (3), ...","autoResize":true,"lineHeight":1.25}, + + {"id":"adhoc_label","type":"text","x":440,"y":710,"width":300,"height":16,"angle":0,"strokeColor":"#e67700","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":70,"groupIds":[],"frameId":null,"roundness":null,"seed":400,"version":1,"versionNonce":400,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"Ad-Hoc Query (on-demand from Slack)","fontSize":12,"fontFamily":2,"textAlign":"left","verticalAlign":"top","containerId":null,"originalText":"Ad-Hoc Query (on-demand from Slack)","autoResize":true,"lineHeight":1.25}, + + {"id":"adhoc_cmd","type":"rectangle","x":440,"y":735,"width":350,"height":35,"angle":0,"strokeColor":"#e67700","backgroundColor":"#fff3bf","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":3},"seed":410,"version":1,"versionNonce":410,"isDeleted":false,"boundElements":[{"id":"adhoc_cmd_t","type":"text"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"adhoc_cmd_t","type":"text","x":450,"y":740,"width":330,"height":25,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":411,"version":1,"versionNonce":411,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"/vendor-research Goldman Sachs","fontSize":14,"fontFamily":3,"textAlign":"left","verticalAlign":"middle","containerId":"adhoc_cmd","originalText":"/vendor-research Goldman Sachs","autoResize":true,"lineHeight":1.25}, + + {"id":"adhoc_agent","type":"rectangle","x":440,"y":780,"width":350,"height":80,"angle":0,"strokeColor":"#6741d9","backgroundColor":"#d0bfff","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":3},"seed":412,"version":1,"versionNonce":412,"isDeleted":false,"boundElements":[{"id":"adhoc_agent_t","type":"text"},{"id":"a_adhoc_score","type":"arrow"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"adhoc_agent_t","type":"text","x":450,"y":787,"width":330,"height":66,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":413,"version":1,"versionNonce":413,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"Agent researching Goldman Sachs...\nFinancial: MEDIUM | Legal: LOW | Cyber: LOW\nResult: MEDIUM — escalate_review\nPosting thread reply to #procurement...","fontSize":12,"fontFamily":3,"textAlign":"left","verticalAlign":"top","containerId":"adhoc_agent","originalText":"Agent researching Goldman Sachs...\nFinancial: MEDIUM | Legal: LOW | Cyber: LOW\nResult: MEDIUM — escalate_review\nPosting thread reply to #procurement...","autoResize":true,"lineHeight":1.25}, + + {"id":"score_box","type":"rectangle","x":850,"y":160,"width":330,"height":200,"angle":0,"strokeColor":"#c92a2a","backgroundColor":"#ffe3e3","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":3},"seed":500,"version":1,"versionNonce":500,"isDeleted":false,"boundElements":[{"id":"score_t","type":"text"},{"id":"a_r1_score","type":"arrow"},{"id":"a_r2_score","type":"arrow"},{"id":"a_r3_score","type":"arrow"},{"id":"a_m5_score","type":"arrow"},{"id":"a_m6_score","type":"arrow"},{"id":"a_adhoc_score","type":"arrow"},{"id":"a_score_ch1","type":"arrow"},{"id":"a_score_ch2","type":"arrow"},{"id":"a_score_ch3","type":"arrow"},{"id":"a_score_audit","type":"arrow"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"score_t","type":"text","x":860,"y":168,"width":310,"height":184,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":501,"version":1,"versionNonce":501,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"RISK SCORING ENGINE\n\nany CRITICAL dim = CRITICAL\nany HIGH dim = HIGH\n3+ MEDIUM (2+ cats) = MEDIUM adverse\n1-2 MEDIUM = MEDIUM\nall LOW = LOW\n\nOverrides:\ncyber CRITICAL -> force CRITICAL\nlegal CRITICAL -> force min HIGH\nrisk_tier_override -> floor","fontSize":12,"fontFamily":3,"textAlign":"left","verticalAlign":"top","containerId":"score_box","originalText":"RISK SCORING ENGINE\n\nany CRITICAL dim = CRITICAL\nany HIGH dim = HIGH\n3+ MEDIUM (2+ cats) = MEDIUM adverse\n1-2 MEDIUM = MEDIUM\nall LOW = LOW\n\nOverrides:\ncyber CRITICAL -> force CRITICAL\nlegal CRITICAL -> force min HIGH\nrisk_tier_override -> floor","autoResize":true,"lineHeight":1.25}, + + {"id":"output_bg","type":"rectangle","x":1260,"y":40,"width":440,"height":860,"angle":0,"strokeColor":"#e67700","backgroundColor":"#fff9db","fillStyle":"solid","strokeWidth":1,"strokeStyle":"dashed","roughness":0,"opacity":15,"groupIds":[],"frameId":null,"roundness":{"type":3},"seed":600,"version":1,"versionNonce":600,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false}, + {"id":"output_title","type":"text","x":1280,"y":50,"width":350,"height":20,"angle":0,"strokeColor":"#e67700","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":601,"version":1,"versionNonce":601,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"WHAT YOUR TEAM SEES","fontSize":16,"fontFamily":2,"textAlign":"left","verticalAlign":"top","containerId":null,"originalText":"WHAT YOUR TEAM SEES","autoResize":true,"lineHeight":1.25}, + + {"id":"ch1","type":"rectangle","x":1280,"y":80,"width":400,"height":130,"angle":0,"strokeColor":"#c92a2a","backgroundColor":"#ffc9c9","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":3},"seed":610,"version":1,"versionNonce":610,"isDeleted":false,"boundElements":[{"id":"ch1_t","type":"text"},{"id":"a_score_ch1","type":"arrow"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"ch1_t","type":"text","x":1290,"y":88,"width":380,"height":114,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":611,"version":1,"versionNonce":611,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"#procurement-critical\n\nCRITICAL: CrowdStrike\nActive vulnerability disclosure affecting\nendpoint protection platform.\nsuspend_relationship\nReview within 24 hours.","fontSize":13,"fontFamily":3,"textAlign":"left","verticalAlign":"top","containerId":"ch1","originalText":"#procurement-critical\n\nCRITICAL: CrowdStrike\nActive vulnerability disclosure affecting\nendpoint protection platform.\nsuspend_relationship\nReview within 24 hours.","autoResize":true,"lineHeight":1.25}, + + {"id":"ch2","type":"rectangle","x":1280,"y":225,"width":400,"height":120,"angle":0,"strokeColor":"#e67700","backgroundColor":"#ffe8cc","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":3},"seed":612,"version":1,"versionNonce":612,"isDeleted":false,"boundElements":[{"id":"ch2_t","type":"text"},{"id":"a_score_ch2","type":"arrow"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"ch2_t","type":"text","x":1290,"y":233,"width":380,"height":104,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":613,"version":1,"versionNonce":613,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"#procurement-alerts\n\nHIGH: Pfizer\nFDA regulatory action on manufacturing\ncompliance. initiate_contingency\nReview within 48 hours.","fontSize":13,"fontFamily":3,"textAlign":"left","verticalAlign":"top","containerId":"ch2","originalText":"#procurement-alerts\n\nHIGH: Pfizer\nFDA regulatory action on manufacturing\ncompliance. initiate_contingency\nReview within 48 hours.","autoResize":true,"lineHeight":1.25}, + + {"id":"ch3","type":"rectangle","x":1280,"y":360,"width":400,"height":110,"angle":0,"strokeColor":"#e67700","backgroundColor":"#fff3bf","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":3},"seed":614,"version":1,"versionNonce":614,"isDeleted":false,"boundElements":[{"id":"ch3_t","type":"text"},{"id":"a_score_ch3","type":"arrow"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"ch3_t","type":"text","x":1290,"y":368,"width":380,"height":94,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":615,"version":1,"versionNonce":615,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"#procurement-digest\n\nWeekly Digest: 15 vendors assessed\n1 CRITICAL | 1 HIGH | 3 MEDIUM | 10 LOW\n1 adverse finding detected\nJPMorgan: SEC inquiry (MEDIUM, adverse)","fontSize":13,"fontFamily":3,"textAlign":"left","verticalAlign":"top","containerId":"ch3","originalText":"#procurement-digest\n\nWeekly Digest: 15 vendors assessed\n1 CRITICAL | 1 HIGH | 3 MEDIUM | 10 LOW\n1 adverse finding detected\nJPMorgan: SEC inquiry (MEDIUM, adverse)","autoResize":true,"lineHeight":1.25}, + + {"id":"ch4","type":"rectangle","x":1280,"y":485,"width":400,"height":100,"angle":0,"strokeColor":"#1971c2","backgroundColor":"#e7f5ff","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":3},"seed":616,"version":1,"versionNonce":616,"isDeleted":false,"boundElements":[{"id":"ch4_t","type":"text"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"ch4_t","type":"text","x":1290,"y":493,"width":380,"height":84,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":617,"version":1,"versionNonce":617,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"#vendor-risk-ops\n\nResearch run: 15/15 completed, 0 failed\nHealth check: 57 monitors active\n0 failed | 0 orphaned | Webhook OK","fontSize":13,"fontFamily":3,"textAlign":"left","verticalAlign":"top","containerId":"ch4","originalText":"#vendor-risk-ops\n\nResearch run: 15/15 completed, 0 failed\nHealth check: 57 monitors active\n0 failed | 0 orphaned | Webhook OK","autoResize":true,"lineHeight":1.25}, + + {"id":"audit_box","type":"rectangle","x":1280,"y":610,"width":400,"height":270,"angle":0,"strokeColor":"#2f9e44","backgroundColor":"#d3f9d8","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":3},"seed":700,"version":1,"versionNonce":700,"isDeleted":false,"boundElements":[{"id":"audit_t","type":"text"},{"id":"a_score_audit","type":"arrow"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"audit_t","type":"text","x":1290,"y":618,"width":380,"height":254,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":701,"version":1,"versionNonce":701,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"AUDIT LOG (Google Sheets)\n\n02:14 CrowdStrike CRITICAL adverse\n Active vulnerability disclosure\n source: deep_research\n\n02:14 Pfizer HIGH adverse\n FDA regulatory action\n source: deep_research\n\n02:15 Microsoft LOW clean\n No adverse conditions\n source: deep_research\n\n09:41 Goldman Sachs MEDIUM clean\n Moderate financial findings\n source: adhoc\n\n14:22 JPMorgan MEDIUM adverse\n SEC inquiry reported\n source: monitor_event","fontSize":11,"fontFamily":3,"textAlign":"left","verticalAlign":"top","containerId":"audit_box","originalText":"AUDIT LOG (Google Sheets)\n\n02:14 CrowdStrike CRITICAL adverse\n Active vulnerability disclosure\n source: deep_research\n\n02:14 Pfizer HIGH adverse\n FDA regulatory action\n source: deep_research\n\n02:15 Microsoft LOW clean\n No adverse conditions\n source: deep_research\n\n09:41 Goldman Sachs MEDIUM clean\n Moderate financial findings\n source: adhoc\n\n14:22 JPMorgan MEDIUM adverse\n SEC inquiry reported\n source: monitor_event","autoResize":true,"lineHeight":1.25}, + + {"id":"a_ms_r1","type":"arrow","x":190,"y":105,"width":245,"height":50,"angle":0,"strokeColor":"#6741d9","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"dashed","roughness":1,"opacity":60,"groupIds":[],"frameId":null,"roundness":{"type":2},"seed":1000,"version":1,"versionNonce":1000,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"points":[[0,0],[245,50]],"lastCommittedPoint":null,"startBinding":{"elementId":"v_ms","focus":0,"gap":5},"endBinding":{"elementId":"r1","focus":-0.5,"gap":5},"startArrowhead":null,"endArrowhead":"arrow"}, + + {"id":"a_cs_r1","type":"arrow","x":345,"y":155,"width":90,"height":120,"angle":0,"strokeColor":"#c92a2a","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":80,"groupIds":[],"frameId":null,"roundness":{"type":2},"seed":1001,"version":1,"versionNonce":1001,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"points":[[0,0],[90,120]],"lastCommittedPoint":null,"startBinding":{"elementId":"v_cs","focus":0,"gap":5},"endBinding":{"elementId":"r2","focus":-0.5,"gap":5},"startArrowhead":null,"endArrowhead":"arrow"}, + + {"id":"a_str_r1","type":"arrow","x":190,"y":275,"width":245,"height":120,"angle":0,"strokeColor":"#6741d9","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"dashed","roughness":1,"opacity":60,"groupIds":[],"frameId":null,"roundness":{"type":2},"seed":1002,"version":1,"versionNonce":1002,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"points":[[0,0],[245,120]],"lastCommittedPoint":null,"startBinding":{"elementId":"v_str","focus":0,"gap":5},"endBinding":{"elementId":"r3","focus":-0.5,"gap":5},"startArrowhead":null,"endArrowhead":"arrow"}, + + {"id":"a_pfe_m1","type":"arrow","x":345,"y":345,"width":90,"height":293,"angle":0,"strokeColor":"#c92a2a","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":80,"groupIds":[],"frameId":null,"roundness":{"type":2},"seed":1003,"version":1,"versionNonce":1003,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"points":[[0,0],[90,293]],"lastCommittedPoint":null,"startBinding":{"elementId":"v_pfe","focus":0,"gap":5},"endBinding":{"elementId":"m5","focus":-0.5,"gap":5},"startArrowhead":null,"endArrowhead":"arrow"}, + + {"id":"a_jpm_m1","type":"arrow","x":190,"y":225,"width":495,"height":413,"angle":0,"strokeColor":"#e67700","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":80,"groupIds":[],"frameId":null,"roundness":{"type":2},"seed":1004,"version":1,"versionNonce":1004,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"points":[[0,0],[495,413]],"lastCommittedPoint":null,"startBinding":{"elementId":"v_jpm","focus":0,"gap":5},"endBinding":{"elementId":"m6","focus":-0.5,"gap":5},"startArrowhead":null,"endArrowhead":"arrow"}, + + {"id":"a_r1_score","type":"arrow","x":795,"y":155,"width":50,"height":60,"angle":0,"strokeColor":"#2f9e44","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":80,"groupIds":[],"frameId":null,"roundness":{"type":2},"seed":1010,"version":1,"versionNonce":1010,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"points":[[0,0],[50,60]],"lastCommittedPoint":null,"startBinding":{"elementId":"r1","focus":0.5,"gap":5},"endBinding":{"elementId":"score_box","focus":-0.5,"gap":5},"startArrowhead":null,"endArrowhead":"arrow"}, + + {"id":"a_r2_score","type":"arrow","x":795,"y":275,"width":50,"height":-15,"angle":0,"strokeColor":"#c92a2a","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":2},"seed":1011,"version":1,"versionNonce":1011,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"points":[[0,0],[50,-15]],"lastCommittedPoint":null,"startBinding":{"elementId":"r2","focus":0.5,"gap":5},"endBinding":{"elementId":"score_box","focus":0,"gap":5},"startArrowhead":null,"endArrowhead":"arrow"}, + + {"id":"a_r3_score","type":"arrow","x":795,"y":395,"width":50,"height":-40,"angle":0,"strokeColor":"#6741d9","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"dashed","roughness":1,"opacity":60,"groupIds":[],"frameId":null,"roundness":{"type":2},"seed":1012,"version":1,"versionNonce":1012,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"points":[[0,0],[50,-40]],"lastCommittedPoint":null,"startBinding":{"elementId":"r3","focus":0.5,"gap":5},"endBinding":{"elementId":"score_box","focus":0.5,"gap":5},"startArrowhead":null,"endArrowhead":"arrow"}, + + {"id":"a_m5_score","type":"arrow","x":685,"y":638,"width":160,"height":-280,"angle":0,"strokeColor":"#c92a2a","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":2},"seed":1013,"version":1,"versionNonce":1013,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"points":[[0,0],[160,-280]],"lastCommittedPoint":null,"startBinding":{"elementId":"m5","focus":0,"gap":5},"endBinding":{"elementId":"score_box","focus":0.5,"gap":5},"startArrowhead":null,"endArrowhead":"arrow"}, + + {"id":"a_m6_score","type":"arrow","x":935,"y":638,"width":0,"height":-280,"angle":0,"strokeColor":"#e67700","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":80,"groupIds":[],"frameId":null,"roundness":{"type":2},"seed":1014,"version":1,"versionNonce":1014,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"points":[[0,0],[0,-280]],"lastCommittedPoint":null,"startBinding":{"elementId":"m6","focus":0,"gap":5},"endBinding":{"elementId":"score_box","focus":0.5,"gap":5},"startArrowhead":null,"endArrowhead":"arrow"}, + + {"id":"a_adhoc_score","type":"arrow","x":795,"y":820,"width":220,"height":-460,"angle":0,"strokeColor":"#e67700","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"dashed","roughness":1,"opacity":70,"groupIds":[],"frameId":null,"roundness":{"type":2},"seed":1015,"version":1,"versionNonce":1015,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"points":[[0,0],[220,-460]],"lastCommittedPoint":null,"startBinding":{"elementId":"adhoc_agent","focus":0.5,"gap":5},"endBinding":{"elementId":"score_box","focus":0.8,"gap":5},"startArrowhead":null,"endArrowhead":"arrow"}, + + {"id":"a_score_ch1","type":"arrow","x":1185,"y":200,"width":90,"height":-55,"angle":0,"strokeColor":"#c92a2a","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":2},"seed":1020,"version":1,"versionNonce":1020,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"points":[[0,0],[90,-55]],"lastCommittedPoint":null,"startBinding":{"elementId":"score_box","focus":-0.5,"gap":5},"endBinding":{"elementId":"ch1","focus":0,"gap":5},"startArrowhead":null,"endArrowhead":"arrow"}, + + {"id":"a_score_ch2","type":"arrow","x":1185,"y":260,"width":90,"height":25,"angle":0,"strokeColor":"#e67700","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":2},"seed":1021,"version":1,"versionNonce":1021,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"points":[[0,0],[90,25]],"lastCommittedPoint":null,"startBinding":{"elementId":"score_box","focus":0,"gap":5},"endBinding":{"elementId":"ch2","focus":0,"gap":5},"startArrowhead":null,"endArrowhead":"arrow"}, + + {"id":"a_score_ch3","type":"arrow","x":1185,"y":320,"width":90,"height":95,"angle":0,"strokeColor":"#e67700","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"dashed","roughness":1,"opacity":70,"groupIds":[],"frameId":null,"roundness":{"type":2},"seed":1022,"version":1,"versionNonce":1022,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"points":[[0,0],[90,95]],"lastCommittedPoint":null,"startBinding":{"elementId":"score_box","focus":0.5,"gap":5},"endBinding":{"elementId":"ch3","focus":0,"gap":5},"startArrowhead":null,"endArrowhead":"arrow"}, + + {"id":"a_score_audit","type":"arrow","x":1100,"y":365,"width":175,"height":370,"angle":0,"strokeColor":"#2f9e44","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":80,"groupIds":[],"frameId":null,"roundness":{"type":2},"seed":1023,"version":1,"versionNonce":1023,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"points":[[0,0],[175,370]],"lastCommittedPoint":null,"startBinding":{"elementId":"score_box","focus":0.8,"gap":5},"endBinding":{"elementId":"audit_box","focus":-0.5,"gap":5},"startArrowhead":null,"endArrowhead":"arrow"}, + + {"id":"a_ms_m1","type":"arrow","x":190,"y":105,"width":245,"height":450,"angle":0,"strokeColor":"#1971c2","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"dashed","roughness":1,"opacity":30,"groupIds":[],"frameId":null,"roundness":{"type":2},"seed":1030,"version":1,"versionNonce":1030,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"points":[[0,0],[245,450]],"lastCommittedPoint":null,"startBinding":{"elementId":"v_ms","focus":0,"gap":5},"endBinding":{"elementId":"m1","focus":-0.5,"gap":5},"startArrowhead":null,"endArrowhead":"arrow"}, + + {"id":"a_cs_m1","type":"arrow","x":345,"y":155,"width":90,"height":400,"angle":0,"strokeColor":"#1971c2","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"dashed","roughness":1,"opacity":30,"groupIds":[],"frameId":null,"roundness":{"type":2},"seed":1031,"version":1,"versionNonce":1031,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"points":[[0,0],[90,400]],"lastCommittedPoint":null,"startBinding":{"elementId":"v_cs","focus":0,"gap":5},"endBinding":null,"startArrowhead":null,"endArrowhead":"arrow"} + + ], + "appState": { + "gridSize": null, + "viewBackgroundColor": "#ffffff" + }, + "files": {} +} diff --git a/typescript-recipes/parallel-n8n-procurement/sample-setup.md b/typescript-recipes/parallel-n8n-procurement/sample-setup.md new file mode 100644 index 0000000..bd57472 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/sample-setup.md @@ -0,0 +1,393 @@ +# Parallel Procurement — Live System Mockup + +A snapshot of the system running for 15 vendors. Dozens of AI agents working simultaneously across the web. Information flowing to your team in Slack. + +## System in Action + +```mermaid +flowchart LR + subgraph VENDORS["YOUR VENDOR PORTFOLIO (15 vendors)"] + direction TB + + subgraph TECH["Technology"] + MS["Microsoft\nHIGH"] + AWS["AWS\nHIGH"] + SF["Salesforce\nHIGH"] + CS["CrowdStrike\nHIGH"] + end + + subgraph FIN["Financial Services"] + JPM["JPMorgan Chase\nHIGH"] + GS["Goldman Sachs\nMEDIUM"] + STR["Stripe\nHIGH"] + end + + subgraph HEALTH["Healthcare"] + UHG["UnitedHealth\nHIGH"] + PFE["Pfizer\nMEDIUM"] + JNJ["Johnson & Johnson\nMEDIUM"] + end + + subgraph MFG["Manufacturing"] + SIE["Siemens\nMEDIUM"] + CAT["Caterpillar\nLOW"] + MMM["3M\nLOW"] + end + + subgraph SVC["Professional Services"] + DEL["Deloitte\nMEDIUM"] + ACN["Accenture\nMEDIUM"] + end + end + + subgraph PARALLEL["PARALLEL AI — AGENTS ACROSS THE WEB"] + direction TB + + subgraph RESEARCH["Deep Research Agents (daily batch)"] + direction LR + R_MS["Researching Microsoft\n6 dimensions\nultra8x processor"] + R_STR["Researching Stripe\n6 dimensions\nultra8x processor"] + R_UHG["Researching UnitedHealth\n6 dimensions\nultra8x processor"] + R_JPM["Researching JPMorgan\n6 dimensions\nultra8x processor"] + R_CS["Researching CrowdStrike\n6 dimensions\nultra8x processor"] + end + + subgraph DIMS["What each agent investigates"] + D1["Financial Health\nearnings, credit, debt"] + D2["Legal & Regulatory\nlawsuits, SEC, sanctions"] + D3["Cybersecurity\nbreaches, CVEs, SOC2"] + D4["Leadership\nexec changes, M&A"] + D5["ESG & Reputation\nrecalls, labor, fines"] + D6["Adverse Events\nbreaking news"] + end + + subgraph MONITORS["47 Persistent Monitors (always watching)"] + direction TB + M1["CrowdStrike — cyber\ndata breach OR ransomware\nDAILY"] + M2["CrowdStrike — legal\nlawsuit OR SEC investigation\nDAILY"] + M3["Microsoft — cyber\ndata breach OR ransomware\nDAILY"] + M4["Microsoft — leadership\nCEO departure OR acquisition\nDAILY"] + M5["JPMorgan — financial\nbankruptcy OR credit downgrade\nDAILY"] + M6["JPMorgan — legal\nlawsuit OR SEC investigation\nDAILY"] + M7["Stripe — cyber\ndata breach OR ransomware\nDAILY"] + M8["Stripe — financial\nbankruptcy OR credit downgrade\nDAILY"] + M9["Pfizer — legal\nFDA action OR enforcement\nDAILY"] + M10["Goldman Sachs — legal\nlawsuit OR SEC investigation\nDAILY"] + M11["Caterpillar — legal\nlawsuit OR regulatory action\nWEEKLY"] + M12["3M — financial\nbankruptcy OR credit downgrade\nWEEKLY"] + M13["Siemens — cyber\ndata breach OR ransomware\nDAILY"] + M14["Deloitte — financial\nfinancial distress OR layoffs\nDAILY"] + end + + subgraph ADHOC["Ad-Hoc Agent (on-demand)"] + SLASH["/vendor-research Goldman Sachs"] + AGENT["Agent researching\nGoldman Sachs..."] + REPLY["Thread reply:\nGoldman Sachs assessed at\nMEDIUM risk. No adverse."] + end + end + + subgraph SCORING["RISK SCORING ENGINE"] + direction TB + RULES["Deterministic Rules\nany CRITICAL dim = CRITICAL\nany HIGH dim = HIGH\n3+ MEDIUM = MEDIUM adverse\nelse = LOW"] + OVERRIDES["Overrides\ncyber CRITICAL = force CRITICAL\nlegal CRITICAL = force min HIGH\nrisk_tier_override = floor"] + end + + subgraph DELIVERY["SLACK — YOUR TEAM SEES THIS"] + direction TB + CH1["#procurement-critical\nCRITICAL: CrowdStrike\nActive vulnerability disclosure\naffecting endpoint platform.\nReview within 24 hours."] + CH2["#procurement-alerts\nHIGH: Pfizer\nFDA regulatory action on\nmanufacturing compliance.\nReview within 48 hours."] + CH3["#procurement-digest\nWeekly Digest: 15 vendors\n1 CRITICAL, 1 HIGH\n3 MEDIUM, 10 LOW\n1 adverse finding"] + CH4["#vendor-risk-ops\nHealth Check: 47 monitors\nactive. 0 failed.\n0 orphaned. Webhook OK."] + end + + subgraph AUDIT["AUDIT LOG (Google Sheets)"] + direction TB + A1["2026-03-05 02:14 | CrowdStrike | CRITICAL | true\nActive vulnerability disclosure | deep_research"] + A2["2026-03-05 02:14 | Pfizer | HIGH | true\nFDA regulatory action | deep_research"] + A3["2026-03-05 02:15 | Microsoft | LOW | false\nNo adverse conditions | deep_research"] + A4["2026-03-05 09:41 | Goldman Sachs | MEDIUM | false\nModerate financial findings | adhoc"] + A5["2026-03-05 14:22 | JPMorgan | MEDIUM | true\nSEC inquiry reported | monitor_event"] + end + + %% === VENDOR TO RESEARCH CONNECTIONS === + MS --> R_MS + STR --> R_STR + UHG --> R_UHG + JPM --> R_JPM + CS --> R_CS + + %% === VENDOR TO MONITOR CONNECTIONS === + CS -.-> M1 + CS -.-> M2 + MS -.-> M3 + MS -.-> M4 + JPM -.-> M5 + JPM -.-> M6 + STR -.-> M7 + STR -.-> M8 + PFE -.-> M9 + GS -.-> M10 + CAT -.-> M11 + MMM -.-> M12 + SIE -.-> M13 + DEL -.-> M14 + + %% === RESEARCH DIMENSION FAN-OUT === + R_MS --> D1 + R_MS --> D2 + R_MS --> D3 + R_MS --> D4 + R_MS --> D5 + R_MS --> D6 + + %% === RESEARCH TO SCORING === + R_MS ==> RULES + R_STR ==> RULES + R_UHG ==> RULES + R_JPM ==> RULES + R_CS ==> RULES + + %% === MONITOR EVENTS TO SCORING === + M1 -- "EVENT DETECTED" --> RULES + M6 -- "EVENT DETECTED" --> RULES + M9 -- "EVENT DETECTED" --> RULES + + %% === AD-HOC FLOW === + SLASH --> AGENT + AGENT --> RULES + RULES --> REPLY + + %% === SCORING TO DELIVERY === + RULES --> OVERRIDES + OVERRIDES -- "CRITICAL" --> CH1 + OVERRIDES -- "HIGH" --> CH2 + OVERRIDES -- "MEDIUM" --> CH3 + OVERRIDES -- "ops" --> CH4 + + %% === SCORING TO AUDIT === + OVERRIDES --> A1 + OVERRIDES --> A2 + OVERRIDES --> A3 + OVERRIDES --> A4 + OVERRIDES --> A5 + + %% === STYLES === + classDef tech fill:#dbeafe,stroke:#1971c2,color:#1e1e1e + classDef fin fill:#d3f9d8,stroke:#2f9e44,color:#1e1e1e + classDef health fill:#fce4ec,stroke:#c92a2a,color:#1e1e1e + classDef mfg fill:#f1f3f5,stroke:#495057,color:#1e1e1e + classDef svc fill:#e8dff5,stroke:#6741d9,color:#1e1e1e + classDef agent fill:#d0bfff,stroke:#6741d9,color:#1e1e1e + classDef monitor fill:#e8dff5,stroke:#6741d9,color:#1e1e1e + classDef scoring fill:#ffe3e3,stroke:#c92a2a,color:#1e1e1e + classDef slack fill:#fff3bf,stroke:#e67700,color:#1e1e1e + classDef critical fill:#ffc9c9,stroke:#c92a2a,color:#1e1e1e + classDef audit fill:#d3f9d8,stroke:#2f9e44,color:#1e1e1e + + class MS,AWS,SF,CS tech + class JPM,GS,STR fin + class UHG,PFE,JNJ health + class SIE,CAT,MMM mfg + class DEL,ACN svc + class R_MS,R_STR,R_UHG,R_JPM,R_CS,AGENT agent + class D1,D2,D3,D4,D5,D6 agent + class M1,M2,M3,M4,M5,M6,M7,M8,M9,M10,M11,M12,M13,M14 monitor + class RULES,OVERRIDES scoring + class CH1 critical + class CH2,CH3,SLASH,REPLY slack + class CH4 tech + class A1,A2,A3,A4,A5 audit +``` + +## Monitor Portfolio Breakdown + +```mermaid +flowchart TB + subgraph HIGH_VENDORS["HIGH PRIORITY — 5 monitors each, daily"] + direction LR + H1["Microsoft\n5 monitors"] + H2["AWS\n5 monitors"] + H3["Salesforce\n5 monitors"] + H4["CrowdStrike\n5 monitors"] + H5["JPMorgan\n5 monitors"] + H6["UnitedHealth\n5 monitors"] + H7["Stripe\n5 monitors"] + end + + subgraph MED_VENDORS["MEDIUM PRIORITY — 3 monitors each, daily"] + direction LR + M1["Goldman Sachs\n3 monitors"] + M2["Pfizer\n3 monitors"] + M3["J&J\n3 monitors"] + M4["Siemens\n3 monitors"] + M5["Deloitte\n3 monitors"] + M6["Accenture\n3 monitors"] + end + + subgraph LOW_VENDORS["LOW PRIORITY — 2 monitors each, weekly"] + direction LR + L1["Caterpillar\n2 monitors"] + L2["3M\n2 monitors"] + end + + TOTAL["TOTAL: 35 + 18 + 4 = 57 monitors\nrunning continuously"] + + HIGH_VENDORS --> TOTAL + MED_VENDORS --> TOTAL + LOW_VENDORS --> TOTAL + + classDef high fill:#ffc9c9,stroke:#c92a2a + classDef med fill:#fff3bf,stroke:#e67700 + classDef low fill:#d3f9d8,stroke:#2f9e44 + classDef total fill:#d0bfff,stroke:#6741d9 + + class H1,H2,H3,H4,H5,H6,H7 high + class M1,M2,M3,M4,M5,M6 med + class L1,L2 low + class TOTAL total +``` + +## Research Batch Detail + +```mermaid +flowchart LR + subgraph BATCH["Daily Research Batch — 2 AM UTC"] + direction TB + B1["Batch 1: 15 vendors\nTask Group tg_abc123"] + end + + subgraph AGENTS["Parallel AI Agents (simultaneous)"] + direction TB + A1["Microsoft\nFinancial: stable\nLegal: clean\nCyber: LOW\nLeadership: stable\nESG: clean"] + A2["CrowdStrike\nFinancial: stable\nLegal: clean\nCyber: CRITICAL\nLeadership: stable\nESG: clean"] + A3["Pfizer\nFinancial: stable\nLegal: HIGH\nCyber: LOW\nLeadership: stable\nESG: LOW"] + A4["Stripe\nFinancial: stable\nLegal: clean\nCyber: LOW\nLeadership: stable\nESG: clean"] + A5["JPMorgan\nFinancial: MEDIUM\nLegal: MEDIUM\nCyber: LOW\nLeadership: stable\nESG: LOW"] + A6["... 10 more vendors\nresearching in parallel"] + end + + subgraph RESULTS["Scoring Results"] + direction TB + R1["Microsoft = LOW\ncontinue_monitoring"] + R2["CrowdStrike = CRITICAL\nsuspend_relationship\nOverride: active_data_breach"] + R3["Pfizer = HIGH\ninitiate_contingency"] + R4["Stripe = LOW\ncontinue_monitoring"] + R5["JPMorgan = MEDIUM\nescalate_review\nadverse: true (2 categories)"] + end + + BATCH --> A1 + BATCH --> A2 + BATCH --> A3 + BATCH --> A4 + BATCH --> A5 + BATCH --> A6 + + A1 --> R1 + A2 --> R2 + A3 --> R3 + A4 --> R4 + A5 --> R5 + + R2 -- "CRITICAL alert" --> SLACK1["#procurement-critical\nImmediate action required"] + R3 -- "HIGH alert" --> SLACK2["#procurement-alerts\nReview within 48h"] + R5 -- "MEDIUM digest" --> SLACK3["#procurement-digest\nBatched weekly"] + R1 -- "LOW logged" --> LOG["Audit Log only"] + R4 -- "LOW logged" --> LOG + + classDef batch fill:#a5d8ff,stroke:#1971c2 + classDef agent fill:#d0bfff,stroke:#6741d9 + classDef critical fill:#ffc9c9,stroke:#c92a2a + classDef high fill:#ffe8cc,stroke:#e67700 + classDef medium fill:#fff3bf,stroke:#e67700 + classDef low fill:#d3f9d8,stroke:#2f9e44 + classDef slack fill:#fff3bf,stroke:#e67700 + + class B1 batch + class A1,A2,A3,A4,A5,A6 agent + class R2 critical + class R3 high + class R5 medium + class R1,R4 low + class SLACK1 critical + class SLACK2 high + class SLACK3 slack + class LOG low +``` + +## Event Detection Flow + +```mermaid +sequenceDiagram + participant Web as Public Web + participant Mon as Parallel AI Monitor + participant Sys as n8n Workflow + participant Score as Risk Scorer + participant Slack as Slack + participant Log as Audit Log + + Note over Web: Pfizer announces FDA
regulatory action + + Web->>Mon: News detected by monitor:
"Pfizer" regulatory action OR enforcement + + Mon->>Sys: Webhook: monitor.event.detected
monitor_id: mon_pfizer_legal
severity: HIGH + + Sys->>Sys: Enrich with vendor context
Pfizer | healthcare | MEDIUM priority + + Sys->>Sys: Dedup check:
pfizer.com:legal:HIGH
Not seen in 24h — proceed + + Sys->>Score: Score event + + Score->>Score: Legal dimension: HIGH
risk_level = HIGH
adverse_flag = true
action_required = true
recommendation = initiate_contingency + + Score->>Slack: Route to #procurement-alerts
"HIGH: Pfizer — FDA regulatory
action on manufacturing.
Review within 48 hours." + + Score->>Log: Append audit entry
2026-03-05 14:22 | Pfizer | HIGH
source: monitor_event + + Note over Web: Same story picked up
by ESG monitor 2 hours later + + Web->>Mon: News detected by monitor:
"Pfizer" safety violation + + Mon->>Sys: Webhook: monitor.event.detected
monitor_id: mon_pfizer_esg
severity: HIGH + + Sys->>Sys: Dedup check:
pfizer.com:esg:HIGH
Already seen — SKIP + + Note over Sys: Duplicate suppressed.
No alert fatigue. +``` + +## Ad-Hoc Research Flow + +```mermaid +sequenceDiagram + participant User as Procurement Analyst + participant Slack as Slack + participant Sys as n8n Workflow + participant AI as Parallel AI + participant Score as Risk Scorer + participant Log as Audit Log + + User->>Slack: /vendor-research Goldman Sachs + + Slack->>Sys: POST /webhook/slack-command
text: "Goldman Sachs"
channel: #procurement + + Sys->>Slack: "Starting deep research on
Goldman Sachs. 15-30 minutes..." + + Sys->>AI: POST /v1/tasks/runs
processor: ultra8x
webhook: /webhook/adhoc-result + + Note over AI: Agent researches Goldman Sachs
across 6 risk dimensions...
Scans news, filings, databases + + AI->>Sys: Webhook callback
run_id: run_gs_001
status: completed + + Sys->>AI: GET /v1/tasks/runs/run_gs_001/result + + AI->>Sys: Structured JSON result
financial: MEDIUM
legal: LOW
cyber: LOW
leadership: LOW
esg: LOW + + Sys->>Score: Score with source: adhoc + + Score->>Score: 1 MEDIUM dimension
risk_level = MEDIUM
adverse = false
recommendation = escalate_review + + Score->>Slack: Thread reply in #procurement:
"Goldman Sachs assessed at MEDIUM risk.
1 medium finding (financial_health).
No adverse conditions.
Recommendation: escalate_review" + + Score->>Log: Append audit entry
source: adhoc + + User->>User: Reviews findings.
Decides to proceed
with contract renewal. +``` diff --git a/typescript-recipes/parallel-n8n-procurement/src/config/index.ts b/typescript-recipes/parallel-n8n-procurement/src/config/index.ts new file mode 100644 index 0000000..327aa00 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/src/config/index.ts @@ -0,0 +1,61 @@ +import { z } from "zod"; +import "dotenv/config"; + +// ── Config Schema ────────────────────────────────────────────────────────── + +const ConfigSchema = z.object({ + // Required — no defaults + PARALLEL_API_KEY: z.string().min(1, "PARALLEL_API_KEY is required"), + GOOGLE_SHEET_ID: z.string().min(1, "GOOGLE_SHEET_ID is required"), + SLACK_WEBHOOK_URL: z.string().url("SLACK_WEBHOOK_URL must be a valid URL"), + N8N_WEBHOOK_BASE_URL: z.string().url("N8N_WEBHOOK_BASE_URL must be a valid URL"), + + // With defaults + PARALLEL_BASE_URL: z.string().url().default("https://api.parallel.ai"), + RESEARCH_CRON: z.string().default("0 6 * * *"), + SYNC_CRON: z.string().default("0 0 * * *"), + BATCH_SIZE: z.coerce.number().int().positive().default(50), + RESEARCH_PROCESSOR: z.string().default("ultra8x"), + MONITOR_CADENCE_HIGH: z + .enum(["hourly", "daily", "weekly", "every_two_weeks"]) + .default("daily"), + MONITOR_CADENCE_STD: z + .enum(["hourly", "daily", "weekly", "every_two_weeks"]) + .default("weekly"), + MONITORS_PER_VENDOR_HIGH: z.coerce.number().int().positive().default(5), + MONITORS_PER_VENDOR_STD: z.coerce.number().int().positive().default(2), + + // Slack channel routing + SLACK_CHANNEL_CRITICAL: z.string().optional(), + SLACK_CHANNEL_ALERT: z.string().optional(), + SLACK_CHANNEL_DIGEST: z.string().optional(), +}); + +// ── Type Export ───────────────────────────────────────────────────────────── + +export type AppConfig = z.infer; + +// ── Loader ───────────────────────────────────────────────────────────────── + +let _config: AppConfig | null = null; + +export function loadConfig(): AppConfig { + if (_config) return _config; + + const result = ConfigSchema.safeParse(process.env); + if (!result.success) { + const formatted = result.error.issues + .map((issue) => ` - ${issue.path.join(".")}: ${issue.message}`) + .join("\n"); + throw new Error( + `Configuration validation failed:\n${formatted}\n\nCheck your .env file or environment variables.` + ); + } + + _config = result.data; + return _config; +} + +export function resetConfig(): void { + _config = null; +} diff --git a/typescript-recipes/parallel-n8n-procurement/src/models/health-check.ts b/typescript-recipes/parallel-n8n-procurement/src/models/health-check.ts new file mode 100644 index 0000000..ad2f415 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/src/models/health-check.ts @@ -0,0 +1,30 @@ +// ── Cleanup Result ───────────────────────────────────────────────────────── + +export interface CleanupResult { + deleted: number; + failed: number; + errors: string[]; +} + +// ── Recreation Result ────────────────────────────────────────────────────── + +export interface RecreationResult { + recreated: number; + failed: number; + new_monitor_ids: string[]; + errors: string[]; +} + +// ── Health Check Report ──────────────────────────────────────────────────── + +export interface HealthCheckReport { + timestamp: string; + total_monitors: number; + active_count: number; + failed_count: number; + orphan_count: number; + orphans_deleted: number; + monitors_recreated: number; + webhook_healthy: boolean; + errors: string[]; +} diff --git a/typescript-recipes/parallel-n8n-procurement/src/models/monitor-api.ts b/typescript-recipes/parallel-n8n-procurement/src/models/monitor-api.ts new file mode 100644 index 0000000..29bf72d --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/src/models/monitor-api.ts @@ -0,0 +1,140 @@ +import { z } from "zod"; + +// ── Enums ────────────────────────────────────────────────────────────────── + +export const MonitorStatusSchema = z.enum(["active", "canceled"]); + +export const MonitorCadenceSchema = z.enum(["daily", "weekly"]); + +export const MonitorEventTypeSchema = z.enum(["event", "error", "completion"]); + +// ── Monitor Webhook ──────────────────────────────────────────────────────── + +export const MonitorWebhookSchema = z.object({ + url: z.string().url(), + event_types: z.array(z.string()).default(["monitor.event.detected"]), +}); + +// ── Monitor Metadata (PRD per-vendor portfolio) ──────────────────────────── + +export const MonitorMetadataSchema = z + .object({ + vendor_name: z.string(), + vendor_domain: z.string(), + monitor_category: z.string(), + risk_dimension: z.string(), + }) + .catchall(z.unknown()); + +// ── Monitor Output Schema (PRD Section 5.3 flat schema) ─────────────────── + +export const MonitorOutputSchemaDefinition = z.object({ + event_summary: z.string(), + severity: z.string(), + adverse: z.boolean(), + event_type: z.string(), +}); + +// ── Monitor ──────────────────────────────────────────────────────────────── + +export const MonitorSchema = z + .object({ + monitor_id: z.string(), + query: z.string(), + status: MonitorStatusSchema, + cadence: z.string(), + metadata: z.record(z.unknown()).optional(), + webhook: z + .union([z.string(), MonitorWebhookSchema]) + .optional() + .nullable(), + output_schema: z.record(z.unknown()).optional(), + created_at: z.string().optional(), + last_run_at: z.string().optional().nullable(), + }) + .passthrough(); + +// ── Monitor List Response ────────────────────────────────────────────────── + +export const MonitorListResponseSchema = z + .object({ + monitors: z.array(MonitorSchema), + total_count: z.number().optional(), + offset: z.number().optional(), + limit: z.number().optional(), + }) + .passthrough(); + +// ── Monitor Event ────────────────────────────────────────────────────────── + +export const MonitorEventSchema = z + .object({ + type: MonitorEventTypeSchema, + event_id: z.string().optional(), + event_group_id: z.string().optional(), + monitor_id: z.string().optional(), + event_date: z.string().optional(), + output: z.union([z.string(), z.record(z.unknown())]).optional(), + source_urls: z.array(z.string()).optional(), + error: z.string().optional(), + message: z.string().optional(), + }) + .passthrough(); + +// ── Event Group Details ──────────────────────────────────────────────────── + +export const EventGroupDetailsSchema = z + .object({ + event_group_id: z.string(), + monitor_id: z.string(), + events: z.array(MonitorEventSchema), + metadata: z.record(z.unknown()).optional(), + }) + .passthrough(); + +// ── Monitor Webhook Payload (inbound from Parallel) ─────────────────────── + +export const MonitorWebhookPayloadSchema = z + .object({ + type: z.string(), + data: z.object({ + monitor_id: z.string(), + event: z + .object({ + event_group_id: z.string(), + }) + .passthrough(), + metadata: z.record(z.unknown()).optional(), + }), + }) + .passthrough(); + +// ── Derived TypeScript Types ─────────────────────────────────────────────── + +export type MonitorStatus = z.infer; +export type MonitorCadence = z.infer; +export type MonitorEventType = z.infer; +export type MonitorWebhook = z.infer; +export type MonitorMetadata = z.infer; +export type MonitorOutputSchemaType = z.infer; +export type Monitor = z.infer; +export type MonitorListResponse = z.infer; +export type MonitorEvent = z.infer; +export type EventGroupDetails = z.infer; +export type MonitorWebhookPayload = z.infer; + +// ── Request Types ────────────────────────────────────────────────────────── + +export interface MonitorCreateInput { + query: string; + cadence: MonitorCadence; + webhook?: MonitorWebhook; + metadata?: MonitorMetadata; + output_schema?: Record; +} + +export interface MonitorUpdateInput { + cadence?: MonitorCadence; + webhook?: MonitorWebhook; + metadata?: Record; +} diff --git a/typescript-recipes/parallel-n8n-procurement/src/models/monitor-events.ts b/typescript-recipes/parallel-n8n-procurement/src/models/monitor-events.ts new file mode 100644 index 0000000..ce03123 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/src/models/monitor-events.ts @@ -0,0 +1,47 @@ +import type { RiskTier } from "./vendor.js"; +import type { RiskAssessment } from "./risk-assessment.js"; + +// ── Monitor Registry Context ─────────────────────────────────────────────── + +export interface MonitorRegistryContext { + vendor_name: string; + vendor_domain: string; + risk_dimension: string; + monitoring_priority: string; + monitor_category: string; +} + +// ── Enriched Event ───────────────────────────────────────────────────────── + +export interface EnrichedEvent { + // Raw event fields + event_id?: string; + event_group_id: string; + monitor_id: string; + event_date?: string; + source_urls?: string[]; + + // Vendor context + vendor_name: string; + vendor_domain: string; + risk_dimension: string; + monitoring_priority: string; + monitor_category: string; + + // Parsed output (Section 5.3) + event_summary: string; + severity: RiskTier; + adverse: boolean; + event_type: string; +} + +// ── Event Handler Result ─────────────────────────────────────────────────── + +export interface EventHandlerResult { + processed: boolean; + duplicate: boolean; + assessment?: RiskAssessment; + vendor_domain?: string; + event_group_id: string; + error?: string; +} diff --git a/typescript-recipes/parallel-n8n-procurement/src/models/monitor-query.ts b/typescript-recipes/parallel-n8n-procurement/src/models/monitor-query.ts new file mode 100644 index 0000000..d9e41be --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/src/models/monitor-query.ts @@ -0,0 +1,59 @@ +import { z } from "zod"; +import { MonitorCadenceSchema } from "./monitor-api.js"; +import { VendorSchema } from "./vendor.js"; + +// ── Risk Dimension ───────────────────────────────────────────────────────── + +export const RiskDimensionSchema = z.enum([ + "legal", + "cyber", + "financial", + "leadership", + "esg", +]); + +// ── Monitor Query Set ────────────────────────────────────────────────────── + +export const MonitorQuerySetSchema = z.object({ + query: z.string(), + risk_dimension: RiskDimensionSchema, + cadence: MonitorCadenceSchema, + monitor_category: z.string(), +}); + +// ── Monitor Registry Entry ───────────────────────────────────────────────── + +export const MonitorRegistryEntrySchema = z.object({ + monitor_id: z.string(), + vendor_domain: z.string(), + risk_dimension: RiskDimensionSchema, +}); + +// ── Reconcile Result ─────────────────────────────────────────────────────── + +export const ReconcileResultSchema = z.object({ + to_create: z.array( + z.object({ + vendor: VendorSchema, + queries: z.array(MonitorQuerySetSchema), + }), + ), + to_delete: z.array( + z.object({ + vendor_domain: z.string(), + monitor_ids: z.array(z.string()), + }), + ), + unchanged: z.array( + z.object({ + vendor_domain: z.string(), + }), + ), +}); + +// ── Derived TypeScript Types ─────────────────────────────────────────────── + +export type RiskDimension = z.infer; +export type MonitorQuerySet = z.infer; +export type MonitorRegistryEntry = z.infer; +export type ReconcileResult = z.infer; diff --git a/typescript-recipes/parallel-n8n-procurement/src/models/research-run.ts b/typescript-recipes/parallel-n8n-procurement/src/models/research-run.ts new file mode 100644 index 0000000..359a0e5 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/src/models/research-run.ts @@ -0,0 +1,43 @@ +import type { RiskTier, Vendor } from "./vendor.js"; +import type { DeepResearchOutput, RiskAssessment } from "./risk-assessment.js"; + +// ── Batch Result ─────────────────────────────────────────────────────────── + +export interface BatchResult { + batch_index: number; + taskgroup_id: string; + results: Map; + failures: Array<{ vendor_domain: string; run_id: string; error: string }>; +} + +// ── Processed Results ────────────────────────────────────────────────────── + +export interface ProcessedResults { + assessments: Array<{ vendor: Vendor; assessment: RiskAssessment }>; + errors: Array<{ vendor_domain: string; error: string }>; +} + +// ── Research Run Summary ─────────────────────────────────────────────────── + +export interface ResearchRunSummary { + total_due: number; + total_researched: number; + total_failed: number; + risk_counts: Record; + adverse_count: number; + batches_executed: number; + duration_ms: number; +} + +// ── Audit Log Entry ──────────────────────────────────────────────────────── + +export interface AuditLogEntry { + timestamp: string; + vendor_name: string; + risk_level: RiskTier; + adverse_flag: boolean; + categories: string; + summary: string; + run_id: string; + source: "deep_research" | "monitor_event"; +} diff --git a/typescript-recipes/parallel-n8n-procurement/src/models/risk-assessment.ts b/typescript-recipes/parallel-n8n-procurement/src/models/risk-assessment.ts new file mode 100644 index 0000000..cf039b5 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/src/models/risk-assessment.ts @@ -0,0 +1,102 @@ +import { z } from "zod"; +import { RiskTierSchema } from "./vendor.js"; + +// ── Risk Dimension (shared shape for research output) ────────────────────── + +export const RiskDimensionOutputSchema = z.object({ + status: z.string(), + findings: z.string(), + severity: RiskTierSchema, +}); + +// ── Adverse Event ────────────────────────────────────────────────────────── + +export const AdverseEventSchema = z.object({ + title: z.string(), + date: z.string(), + category: z.string(), + severity: RiskTierSchema, + source_url: z.string().optional(), + description: z.string(), +}); + +// ── Deep Research Output (matches research-prompt-builder schema) ────────── + +export const DeepResearchOutputSchema = z.object({ + vendor_name: z.string(), + assessment_date: z.string(), + overall_risk_level: RiskTierSchema, + financial_health: RiskDimensionOutputSchema, + legal_regulatory: RiskDimensionOutputSchema, + cybersecurity: RiskDimensionOutputSchema, + leadership_governance: RiskDimensionOutputSchema, + esg_reputation: RiskDimensionOutputSchema, + adverse_events: z.array(AdverseEventSchema), + recommendation: z.string(), +}); + +// ── Monitor Event Output (Section 5.3) ───────────────────────────────────── + +export const MonitorEventOutputSchema = z.object({ + event_summary: z.string(), + severity: RiskTierSchema, + adverse: z.boolean(), + event_type: z.string(), +}); + +// ── Vendor Overrides (scoring inputs) ────────────────────────────────────── + +export const VendorOverridesSchema = z.object({ + risk_tier_override: RiskTierSchema.optional(), +}); + +// ── Vendor Context (for monitor event scoring) ───────────────────────────── + +export const VendorContextSchema = z.object({ + vendor_name: z.string(), + vendor_domain: z.string(), + monitoring_priority: z.string(), +}); + +// ── Severity Counts ──────────────────────────────────────────────────────── + +export const SeverityCountsSchema = z.object({ + critical: z.number().int().nonnegative(), + high: z.number().int().nonnegative(), + medium: z.number().int().nonnegative(), + low: z.number().int().nonnegative(), +}); + +// ── Recommendation Enum ──────────────────────────────────────────────────── + +export const RecommendationSchema = z.enum([ + "continue_monitoring", + "escalate_review", + "initiate_contingency", + "suspend_relationship", +]); + +// ── Risk Assessment (scorer output) ──────────────────────────────────────── + +export const RiskAssessmentSchema = z.object({ + risk_level: RiskTierSchema, + adverse_flag: z.boolean(), + risk_categories: z.array(z.string()), + summary: z.string(), + action_required: z.boolean(), + recommendation: RecommendationSchema, + severity_counts: SeverityCountsSchema, + triggered_overrides: z.array(z.string()), +}); + +// ── Derived TypeScript Types ─────────────────────────────────────────────── + +export type RiskDimensionOutput = z.infer; +export type AdverseEvent = z.infer; +export type DeepResearchOutput = z.infer; +export type MonitorEventOutput = z.infer; +export type VendorOverrides = z.infer; +export type VendorContext = z.infer; +export type SeverityCounts = z.infer; +export type Recommendation = z.infer; +export type RiskAssessment = z.infer; diff --git a/typescript-recipes/parallel-n8n-procurement/src/models/slack-command.ts b/typescript-recipes/parallel-n8n-procurement/src/models/slack-command.ts new file mode 100644 index 0000000..5734a93 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/src/models/slack-command.ts @@ -0,0 +1,39 @@ +// ── Slack Slash Command (inbound from Slack) ────────────────────────────── + +export interface SlackSlashCommandPayload { + command: string; + text: string; + user_id: string; + user_name: string; + channel_id: string; + channel_name: string; + response_url: string; + trigger_id: string; +} + +// ── Parsed Command ───────────────────────────────────────────────────────── + +export interface ParsedCommand { + vendor_name: string; + requesting_user: string; + channel_id: string; + response_url: string; +} + +// ── Task Webhook Payload (inbound from Parallel) ─────────────────────────── + +export interface TaskWebhookPayload { + run_id: string; + status: string; + channel_id?: string; + thread_ts?: string; + vendor_name?: string; +} + +// ── Slack API Response ───────────────────────────────────────────────────── + +export interface SlackResponse { + ok: boolean; + ts?: string; + error?: string; +} diff --git a/typescript-recipes/parallel-n8n-procurement/src/models/slack.ts b/typescript-recipes/parallel-n8n-procurement/src/models/slack.ts new file mode 100644 index 0000000..cb90c8f --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/src/models/slack.ts @@ -0,0 +1,15 @@ +// Slack Block Kit types (outbound payloads — no Zod validation needed) + +export type SlackBlock = Record; + +export interface SlackMessage { + channel: string; + text: string; + blocks: SlackBlock[]; + thread_ts?: string; +} + +export interface SlackRoute { + message: SlackMessage; + channel: string; +} diff --git a/typescript-recipes/parallel-n8n-procurement/src/models/task-api.ts b/typescript-recipes/parallel-n8n-procurement/src/models/task-api.ts new file mode 100644 index 0000000..30cd0e2 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/src/models/task-api.ts @@ -0,0 +1,162 @@ +import { z } from "zod"; + +// ── Error Classes ────────────────────────────────────────────────────────── + +export class ParallelApiError extends Error { + public readonly status: number; + public readonly responseBody: string; + + constructor(message: string, status: number, responseBody: string = "") { + super(message); + this.name = "ParallelApiError"; + this.status = status; + this.responseBody = responseBody; + } +} + +export class RunNotCompleteError extends Error { + public readonly runId: string; + public readonly currentStatus: string; + + constructor(runId: string, currentStatus: string) { + super( + `Run ${runId} is not complete (current status: ${currentStatus})` + ); + this.name = "RunNotCompleteError"; + this.runId = runId; + this.currentStatus = currentStatus; + } +} + +export class TaskGroupTimeoutError extends Error { + public readonly taskGroupId: string; + public readonly elapsedMs: number; + + constructor(taskGroupId: string, elapsedMs: number) { + super( + `Task group ${taskGroupId} timed out after ${Math.round(elapsedMs / 1000)}s` + ); + this.name = "TaskGroupTimeoutError"; + this.taskGroupId = taskGroupId; + this.elapsedMs = elapsedMs; + } +} + +// ── Enums ────────────────────────────────────────────────────────────────── + +export const TaskRunStatusSchema = z.enum([ + "queued", + "running", + "completed", + "failed", + "cancelled", +]); + +// ── Webhook Config ───────────────────────────────────────────────────────── + +export const WebhookConfigSchema = z.object({ + url: z.string().url(), + events: z.array(z.string()).default(["task_run.status"]), +}); + +// ── Task Run Schemas ─────────────────────────────────────────────────────── + +export const TaskRunSchema = z + .object({ + run_id: z.string(), + status: TaskRunStatusSchema, + is_active: z.boolean().optional(), + error: z.string().optional(), + }) + .passthrough(); + +export const BasisCitationSchema = z.object({ + url: z.string(), + title: z.string().nullish(), + excerpts: z.array(z.string()).nullish(), +}); + +export const BasisEntrySchema = z.object({ + field: z.string(), + reasoning: z.string().nullish(), + citations: z.array(BasisCitationSchema).optional(), + confidence: z.string().nullish(), +}); + +export const TaskRunOutputSchema = z.object({ + type: z.enum(["text", "json"]), + content: z.union([z.string(), z.record(z.unknown())]), + basis: z.array(BasisEntrySchema).optional(), +}); + +export const TaskRunResultSchema = z + .object({ + output: TaskRunOutputSchema, + }) + .passthrough(); + +// ── Task Run Input ───────────────────────────────────────────────────────── + +export const TaskRunInputSchema = z.object({ + input: z.union([z.string(), z.record(z.unknown())]), + processor: z.string().optional(), +}); + +// ── Task Group Schemas ───────────────────────────────────────────────────── + +export const TaskGroupSchema = z + .object({ + taskgroup_id: z.string(), + }) + .passthrough(); + +export const TaskGroupStatusSchema = z + .object({ + taskgroup_id: z.string(), + status: z.object({ + is_active: z.boolean(), + num_task_runs: z.number(), + task_run_status_counts: z.record(z.number()).default({}), + }), + }) + .passthrough(); + +export const TaskGroupRunSchema = z + .object({ + run_id: z.string(), + status: TaskRunStatusSchema, + output: TaskRunOutputSchema.optional(), + error: z.string().optional(), + }) + .passthrough(); + +export const TaskGroupResultsSchema = z.array(TaskGroupRunSchema); + +// ── Derived TypeScript Types ─────────────────────────────────────────────── + +export type TaskRunStatus = z.infer; +export type WebhookConfig = z.infer; +export type TaskRun = z.infer; +export type BasisCitation = z.infer; +export type BasisEntry = z.infer; +export type TaskRunOutput = z.infer; +export type TaskRunResult = z.infer; +export type TaskRunInput = z.infer; +export type TaskGroup = z.infer; +export type TaskGroupStatus = z.infer; +export type TaskGroupRun = z.infer; +export type TaskGroupResults = z.infer; + +// ── Request Parameter Types ──────────────────────────────────────────────── + +export interface OutputSchema { + type: "text" | "json"; + json_schema?: Record; +} + +export interface CreateRunParams { + input: string | Record; + processor?: string; + outputSchema?: OutputSchema; + webhook?: WebhookConfig; +} diff --git a/typescript-recipes/parallel-n8n-procurement/src/models/vendor-diff.ts b/typescript-recipes/parallel-n8n-procurement/src/models/vendor-diff.ts new file mode 100644 index 0000000..d780714 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/src/models/vendor-diff.ts @@ -0,0 +1,40 @@ +import { z } from "zod"; +import { VendorSchema } from "./vendor.js"; + +// ── Modified Vendor ──────────────────────────────────────────────────────── + +export const ModifiedVendorSchema = z.object({ + vendor: VendorSchema, + previous: VendorSchema, + changes: z.array(z.string()), +}); + +// ── Vendor Diff ──────────────────────────────────────────────────────────── + +export const VendorDiffSchema = z.object({ + added: z.array(VendorSchema), + removed: z.array(VendorSchema), + unchanged: z.array(VendorSchema), + modified: z.array(ModifiedVendorSchema), +}); + +// ── Diff Result ──────────────────────────────────────────────────────────── + +export const DiffErrorSchema = z.object({ + vendor_domain: z.string(), + error: z.string(), +}); + +export const DiffResultSchema = z.object({ + monitors_created: z.map(z.string(), z.array(z.string())), + monitors_deleted: z.array(z.string()), + monitors_adjusted: z.array(z.string()), + errors: z.array(DiffErrorSchema), +}); + +// ── Derived TypeScript Types ─────────────────────────────────────────────── + +export type ModifiedVendor = z.infer; +export type VendorDiff = z.infer; +export type DiffError = z.infer; +export type DiffResult = z.infer; diff --git a/typescript-recipes/parallel-n8n-procurement/src/models/vendor.ts b/typescript-recipes/parallel-n8n-procurement/src/models/vendor.ts new file mode 100644 index 0000000..df4b293 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/src/models/vendor.ts @@ -0,0 +1,52 @@ +import { z } from "zod"; + +// ── Enums ────────────────────────────────────────────────────────────────── + +export const VendorCategorySchema = z.enum([ + "technology", + "financial_services", + "manufacturing", + "healthcare", + "professional_services", + "other", +]); + +export const RiskTierSchema = z.enum(["LOW", "MEDIUM", "HIGH", "CRITICAL"]); + +export const MonitoringPrioritySchema = z.enum(["high", "medium", "low"]); + +// ── Vendor Schema ────────────────────────────────────────────────────────── + +export const VendorSchema = z.object({ + vendor_name: z.string().min(1, "vendor_name is required"), + vendor_domain: z.string().url("vendor_domain must be a valid URL"), + vendor_category: VendorCategorySchema, + risk_tier_override: RiskTierSchema.optional(), + active: z.boolean().default(true), + monitoring_priority: MonitoringPrioritySchema, + next_research_date: z + .string() + .datetime({ message: "next_research_date must be an ISO date string" }) + .optional(), + monitor_ids: z.array(z.string()).optional(), + last_synced_at: z + .string() + .datetime({ message: "last_synced_at must be an ISO timestamp" }) + .optional(), +}); + +// ── Vendor Registry Schema ───────────────────────────────────────────────── + +export const VendorRegistrySchema = z.object({ + vendors: z.array(VendorSchema), + last_sync_timestamp: z.string().datetime().optional(), + total_count: z.number().int().nonnegative(), +}); + +// ── Derived TypeScript Types ─────────────────────────────────────────────── + +export type VendorCategory = z.infer; +export type RiskTier = z.infer; +export type MonitoringPriority = z.infer; +export type Vendor = z.infer; +export type VendorRegistry = z.infer; diff --git a/typescript-recipes/parallel-n8n-procurement/src/services/audit-logger.ts b/typescript-recipes/parallel-n8n-procurement/src/services/audit-logger.ts new file mode 100644 index 0000000..c409a97 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/src/services/audit-logger.ts @@ -0,0 +1,40 @@ +import { appendFile, readFile } from "node:fs/promises"; +import type { AuditLogEntry } from "../models/research-run.js"; + +// ── Audit Logger ─────────────────────────────────────────────────────────── + +export class AuditLogger { + private readonly outputPath: string; + + constructor(outputPath: string = "audit-log.jsonl") { + this.outputPath = outputPath; + } + + async logAssessment(entry: AuditLogEntry): Promise { + const line = JSON.stringify(entry) + "\n"; + await appendFile(this.outputPath, line); + } + + async getHistory( + vendorName: string, + limit?: number, + ): Promise { + let content: string; + try { + content = await readFile(this.outputPath, "utf-8"); + } catch { + return []; + } + + const entries = content + .split("\n") + .filter((line) => line.trim()) + .map((line) => JSON.parse(line) as AuditLogEntry) + .filter((entry) => entry.vendor_name === vendorName); + + if (limit !== undefined) { + return entries.slice(-limit); + } + return entries; + } +} diff --git a/typescript-recipes/parallel-n8n-procurement/src/services/batch-planner.ts b/typescript-recipes/parallel-n8n-procurement/src/services/batch-planner.ts new file mode 100644 index 0000000..467a4e3 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/src/services/batch-planner.ts @@ -0,0 +1,49 @@ +import type { Vendor } from "../models/vendor.js"; + +// ── Types ────────────────────────────────────────────────────────────────── + +export interface VendorBatch { + batch_index: number; + vendors: Vendor[]; +} + +// ── Batch Planner ────────────────────────────────────────────────────────── + +export class BatchPlanner { + planBatches(vendors: Vendor[], batchSize: number = 50): VendorBatch[] { + if (vendors.length === 0) return []; + + const batches: VendorBatch[] = []; + for (let i = 0; i < vendors.length; i += batchSize) { + batches.push({ + batch_index: batches.length, + vendors: vendors.slice(i, i + batchSize), + }); + } + return batches; + } + + getVendorsDueForResearch(vendors: Vendor[], today: string): Vendor[] { + const todayPrefix = today.slice(0, 10); // YYYY-MM-DD + + return vendors.filter((v) => { + if (!v.active) return false; + if (!v.next_research_date) return true; // never researched = always due + return v.next_research_date.slice(0, 10) <= todayPrefix; + }); + } + + updateNextResearchDates( + vendors: Vendor[], + cycleLength: number, + ): Vendor[] { + const nextDate = new Date(); + nextDate.setDate(nextDate.getDate() + cycleLength); + const nextDateStr = nextDate.toISOString(); + + return vendors.map((v) => ({ + ...v, + next_research_date: nextDateStr, + })); + } +} diff --git a/typescript-recipes/parallel-n8n-procurement/src/services/event-dedup-cache.ts b/typescript-recipes/parallel-n8n-procurement/src/services/event-dedup-cache.ts new file mode 100644 index 0000000..675023d --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/src/services/event-dedup-cache.ts @@ -0,0 +1,43 @@ +import type { EnrichedEvent } from "../models/monitor-events.js"; + +// ── Constants ────────────────────────────────────────────────────────────── + +const DEFAULT_WINDOW_MS = 24 * 60 * 60 * 1000; // 24 hours + +// ── Deduplication Cache ──────────────────────────────────────────────────── + +export class EventDedupCache { + private readonly cache = new Map(); + private readonly defaultWindowMs: number; + + constructor(defaultWindowMs: number = DEFAULT_WINDOW_MS) { + this.defaultWindowMs = defaultWindowMs; + } + + generateKey(event: EnrichedEvent): string { + return `${event.vendor_domain}:${event.event_type}:${event.severity}`; + } + + has(key: string, windowMs?: number): boolean { + const ts = this.cache.get(key); + if (ts === undefined) return false; + return Date.now() - ts < (windowMs ?? this.defaultWindowMs); + } + + add(key: string): void { + this.cache.set(key, Date.now()); + } + + cleanup(maxAgeMs?: number): void { + const cutoff = Date.now() - (maxAgeMs ?? this.defaultWindowMs); + for (const [key, ts] of this.cache) { + if (ts < cutoff) { + this.cache.delete(key); + } + } + } + + get size(): number { + return this.cache.size; + } +} diff --git a/typescript-recipes/parallel-n8n-procurement/src/services/monitor-event-handler.ts b/typescript-recipes/parallel-n8n-procurement/src/services/monitor-event-handler.ts new file mode 100644 index 0000000..5ad3fc2 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/src/services/monitor-event-handler.ts @@ -0,0 +1,222 @@ +import type { MonitorWebhookPayload, EventGroupDetails } from "../models/monitor-api.js"; +import type { MonitorEventOutput, RiskAssessment } from "../models/risk-assessment.js"; +import type { + EnrichedEvent, + EventHandlerResult, + MonitorRegistryContext, +} from "../models/monitor-events.js"; +import type { ParallelMonitorClient } from "./parallel-monitor-client.js"; +import type { RiskScorer } from "./risk-scorer.js"; +import type { SlackFormatter } from "./slack-formatter.js"; +import type { SlackDeliveryService } from "./slack-delivery.js"; +import type { AuditLogger } from "./audit-logger.js"; +import type { EventDedupCache } from "./event-dedup-cache.js"; +import type { RiskTier } from "../models/vendor.js"; + +// ── Options ──────────────────────────────────────────────────────────────── + +export interface MonitorEventHandlerOptions { + monitorClient: ParallelMonitorClient; + riskScorer: RiskScorer; + formatter: SlackFormatter; + deliveryService: SlackDeliveryService; + auditLogger: AuditLogger; + dedupCache: EventDedupCache; + monitorRegistry: (monitorId: string) => MonitorRegistryContext | undefined; + logger?: Pick; +} + +// ── Handler ──────────────────────────────────────────────────────────────── + +export class MonitorEventHandler { + private readonly monitorClient: ParallelMonitorClient; + private readonly riskScorer: RiskScorer; + private readonly formatter: SlackFormatter; + private readonly deliveryService: SlackDeliveryService; + private readonly auditLogger: AuditLogger; + private readonly dedupCache: EventDedupCache; + private readonly monitorRegistry: (monitorId: string) => MonitorRegistryContext | undefined; + private readonly log: Pick; + + constructor(options: MonitorEventHandlerOptions) { + this.monitorClient = options.monitorClient; + this.riskScorer = options.riskScorer; + this.formatter = options.formatter; + this.deliveryService = options.deliveryService; + this.auditLogger = options.auditLogger; + this.dedupCache = options.dedupCache; + this.monitorRegistry = options.monitorRegistry; + this.log = options.logger ?? console; + } + + // ── Main Entry Point ─────────────────────────────────────────────────── + + async handleWebhookEvent( + payload: MonitorWebhookPayload, + ): Promise { + const monitorId = payload.data.monitor_id; + const eventGroupId = payload.data.event.event_group_id; + + this.log.debug( + "[event-handler] Received webhook for monitor %s, event group %s", + monitorId, + eventGroupId, + ); + + // Look up vendor context + const context = this.monitorRegistry(monitorId); + if (!context) { + this.log.warn("[event-handler] Unknown monitor_id: %s", monitorId); + return { + processed: false, + duplicate: false, + event_group_id: eventGroupId, + error: `Unknown monitor: ${monitorId}`, + }; + } + + // Fetch full event details + const eventDetails = await this.monitorClient.getEventGroupDetails( + monitorId, + eventGroupId, + ); + + // Enrich + const enriched = this.enrichEvent(monitorId, eventDetails, context); + + // Dedup check + if (this.isDuplicate(enriched)) { + this.log.debug( + "[event-handler] Duplicate event for %s, skipping", + context.vendor_name, + ); + return { + processed: false, + duplicate: true, + vendor_domain: context.vendor_domain, + event_group_id: eventGroupId, + }; + } + + // Score + const eventOutput: MonitorEventOutput = { + event_summary: enriched.event_summary, + severity: enriched.severity, + adverse: enriched.adverse, + event_type: enriched.event_type, + }; + + const assessment = this.riskScorer.scoreMonitorEvent(eventOutput, { + vendor_name: context.vendor_name, + vendor_domain: context.vendor_domain, + monitoring_priority: context.monitoring_priority, + }); + + // Format + deliver + const vendor = { + vendor_name: context.vendor_name, + vendor_domain: context.vendor_domain, + vendor_category: "other" as const, + monitoring_priority: context.monitoring_priority as "high" | "medium" | "low", + active: true, + }; + + const message = this.formatter.formatMonitorAlert(assessment, vendor, eventOutput); + await this.deliveryService.sendAlert(message); + + // Record + await this.recordEvent(enriched, assessment); + + return { + processed: true, + duplicate: false, + assessment, + vendor_domain: context.vendor_domain, + event_group_id: eventGroupId, + }; + } + + // ── Enrich ───────────────────────────────────────────────────────────── + + enrichEvent( + monitorId: string, + eventData: EventGroupDetails, + context?: MonitorRegistryContext, + ): EnrichedEvent { + const ctx = context ?? this.monitorRegistry(monitorId); + if (!ctx) { + throw new Error(`No registry context for monitor ${monitorId}`); + } + + // Find first "event" type entry (skip "completion" and "error") + const eventEntry = eventData.events.find((e) => e.type === "event"); + + // Parse output + let eventSummary = ""; + let severity: RiskTier = "LOW"; + let adverse = false; + let eventType = ctx.risk_dimension; + + if (eventEntry?.output) { + const output = + typeof eventEntry.output === "string" + ? eventEntry.output + : eventEntry.output; + + if (typeof output === "object" && output !== null) { + const o = output as Record; + eventSummary = String(o.event_summary ?? ""); + severity = (String(o.severity ?? "LOW").toUpperCase() as RiskTier); + adverse = Boolean(o.adverse); + eventType = String(o.event_type ?? ctx.risk_dimension); + } else { + eventSummary = String(output); + } + } + + return { + event_id: eventEntry?.event_id, + event_group_id: eventData.event_group_id, + monitor_id: eventData.monitor_id, + event_date: eventEntry?.event_date, + source_urls: eventEntry?.source_urls, + vendor_name: ctx.vendor_name, + vendor_domain: ctx.vendor_domain, + risk_dimension: ctx.risk_dimension, + monitoring_priority: ctx.monitoring_priority, + monitor_category: ctx.monitor_category, + event_summary: eventSummary, + severity, + adverse, + event_type: eventType, + }; + } + + // ── Dedup ────────────────────────────────────────────────────────────── + + isDuplicate(event: EnrichedEvent): boolean { + const key = this.dedupCache.generateKey(event); + return this.dedupCache.has(key); + } + + // ── Record ───────────────────────────────────────────────────────────── + + async recordEvent( + event: EnrichedEvent, + assessment: RiskAssessment, + ): Promise { + const key = this.dedupCache.generateKey(event); + this.dedupCache.add(key); + + await this.auditLogger.logAssessment({ + timestamp: new Date().toISOString(), + vendor_name: event.vendor_name, + risk_level: assessment.risk_level, + adverse_flag: assessment.adverse_flag, + categories: assessment.risk_categories.join(", "), + summary: assessment.summary, + run_id: event.event_group_id, + source: "monitor_event", + }); + } +} diff --git a/typescript-recipes/parallel-n8n-procurement/src/services/monitor-health-checker.ts b/typescript-recipes/parallel-n8n-procurement/src/services/monitor-health-checker.ts new file mode 100644 index 0000000..980b8d3 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/src/services/monitor-health-checker.ts @@ -0,0 +1,191 @@ +import axios from "axios"; +import type { Monitor, MonitorCadence } from "../models/monitor-api.js"; +import type { Vendor } from "../models/vendor.js"; +import type { + CleanupResult, + RecreationResult, + HealthCheckReport, +} from "../models/health-check.js"; +import type { ParallelMonitorClient } from "./parallel-monitor-client.js"; + +// ── Options ──────────────────────────────────────────────────────────────── + +export interface MonitorHealthCheckerOptions { + monitorClient: ParallelMonitorClient; + webhookUrl?: string; + logger?: Pick; +} + +// ── Health Checker ───────────────────────────────────────────────────────── + +export class MonitorHealthChecker { + private readonly monitorClient: ParallelMonitorClient; + private readonly webhookUrl?: string; + private readonly log: Pick; + + constructor(options: MonitorHealthCheckerOptions) { + this.monitorClient = options.monitorClient; + this.webhookUrl = options.webhookUrl; + this.log = options.logger ?? console; + } + + // ── Top-Level Health Check ───────────────────────────────────────────── + + async runHealthCheck(vendors: Vendor[]): Promise { + this.log.debug("[health] Starting monitor fleet health check"); + + const listResponse = await this.monitorClient.listMonitors(); + const allMonitors = listResponse.monitors; + + const orphans = this.detectOrphanedMonitors(allMonitors, vendors); + const failed = this.detectFailedMonitors(allMonitors); + + const cleanupResult = await this.cleanupOrphans(orphans); + const recreationResult = await this.recreateFailedMonitors(failed, vendors); + + const webhookHealthy = this.webhookUrl + ? await this.selfPingWebhook(this.webhookUrl) + : true; + + return this.compileReport( + allMonitors, + orphans, + failed, + cleanupResult, + recreationResult, + webhookHealthy, + ); + } + + // ── Detection ────────────────────────────────────────────────────────── + + detectOrphanedMonitors( + activeMonitors: Monitor[], + registeredVendors: Vendor[], + ): Monitor[] { + const activeDomains = new Set( + registeredVendors.filter((v) => v.active).map((v) => v.vendor_domain), + ); + + return activeMonitors.filter((m) => { + const vendorDomain = m.metadata?.vendor_domain as string | undefined; + if (!vendorDomain) return true; // no metadata = orphan + return !activeDomains.has(vendorDomain); + }); + } + + detectFailedMonitors(activeMonitors: Monitor[]): Monitor[] { + return activeMonitors.filter((m) => m.status !== "active"); + } + + // ── Cleanup ──────────────────────────────────────────────────────────── + + async cleanupOrphans(orphans: Monitor[]): Promise { + let deleted = 0; + let failed = 0; + const errors: string[] = []; + + for (const monitor of orphans) { + try { + await this.monitorClient.deleteMonitor(monitor.monitor_id); + deleted++; + this.log.debug("[health] Deleted orphan monitor %s", monitor.monitor_id); + } catch (err) { + failed++; + errors.push(`Failed to delete ${monitor.monitor_id}: ${(err as Error).message}`); + } + } + + return { deleted, failed, errors }; + } + + async recreateFailedMonitors( + failed: Monitor[], + vendors: Vendor[], + ): Promise { + const vendorByDomain = new Map(vendors.map((v) => [v.vendor_domain, v])); + let recreated = 0; + let failCount = 0; + const newMonitorIds: string[] = []; + const errors: string[] = []; + + for (const monitor of failed) { + const vendorDomain = monitor.metadata?.vendor_domain as string | undefined; + if (!vendorDomain || !vendorByDomain.has(vendorDomain)) { + this.log.warn( + "[health] Cannot recreate monitor %s — vendor not found", + monitor.monitor_id, + ); + failCount++; + errors.push(`Vendor not found for monitor ${monitor.monitor_id}`); + continue; + } + + try { + await this.monitorClient.deleteMonitor(monitor.monitor_id); + + const newMonitor = await this.monitorClient.createMonitor({ + query: monitor.query, + cadence: (monitor.cadence as MonitorCadence) ?? "daily", + metadata: monitor.metadata as { + vendor_name: string; + vendor_domain: string; + monitor_category: string; + risk_dimension: string; + }, + output_schema: monitor.output_schema, + }); + + newMonitorIds.push(newMonitor.monitor_id); + recreated++; + this.log.debug( + "[health] Recreated monitor %s → %s", + monitor.monitor_id, + newMonitor.monitor_id, + ); + } catch (err) { + failCount++; + errors.push(`Failed to recreate ${monitor.monitor_id}: ${(err as Error).message}`); + } + } + + return { recreated, failed: failCount, new_monitor_ids: newMonitorIds, errors }; + } + + // ── Webhook Self-Ping ────────────────────────────────────────────────── + + async selfPingWebhook(webhookUrl: string): Promise { + try { + const response = await axios.get(webhookUrl, { timeout: 10_000 }); + return response.status >= 200 && response.status < 300; + } catch { + return false; + } + } + + // ── Report Compilation ───────────────────────────────────────────────── + + compileReport( + allMonitors: Monitor[], + orphans: Monitor[], + failed: Monitor[], + cleanupResult: CleanupResult, + recreationResult: RecreationResult, + webhookHealthy: boolean, + ): HealthCheckReport { + const activeCount = allMonitors.filter((m) => m.status === "active").length + - orphans.filter((m) => m.status === "active").length; + + return { + timestamp: new Date().toISOString(), + total_monitors: allMonitors.length, + active_count: activeCount, + failed_count: failed.length, + orphan_count: orphans.length, + orphans_deleted: cleanupResult.deleted, + monitors_recreated: recreationResult.recreated, + webhook_healthy: webhookHealthy, + errors: [...cleanupResult.errors, ...recreationResult.errors], + }; + } +} diff --git a/typescript-recipes/parallel-n8n-procurement/src/services/monitor-portfolio-manager.ts b/typescript-recipes/parallel-n8n-procurement/src/services/monitor-portfolio-manager.ts new file mode 100644 index 0000000..0283e11 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/src/services/monitor-portfolio-manager.ts @@ -0,0 +1,151 @@ +import type { ParallelMonitorClient } from "./parallel-monitor-client.js"; +import type { MonitorQueryGenerator } from "./monitor-query-generator.js"; +import type { MonitorWebhook } from "../models/monitor-api.js"; +import type { Vendor } from "../models/vendor.js"; +import type { + MonitorRegistryEntry, + ReconcileResult, +} from "../models/monitor-query.js"; + +// ── PRD Section 5.3 Output Schema ───────────────────────────────────────── + +const MONITOR_OUTPUT_SCHEMA = { + type: "json", + json_schema: { + type: "object", + properties: { + event_summary: { type: "string" }, + severity: { type: "string", enum: ["LOW", "MEDIUM", "HIGH", "CRITICAL"] }, + adverse: { type: "boolean" }, + event_type: { type: "string" }, + }, + required: ["event_summary", "severity", "adverse", "event_type"], + }, +}; + +// ── Options ──────────────────────────────────────────────────────────────── + +export interface MonitorPortfolioManagerOptions { + monitorClient: ParallelMonitorClient; + queryGenerator: MonitorQueryGenerator; + webhook?: MonitorWebhook; + logger?: Pick; +} + +// ── Manager ──────────────────────────────────────────────────────────────── + +export class MonitorPortfolioManager { + private readonly monitorClient: ParallelMonitorClient; + private readonly queryGenerator: MonitorQueryGenerator; + private readonly webhook?: MonitorWebhook; + private readonly log: Pick; + + constructor(options: MonitorPortfolioManagerOptions) { + this.monitorClient = options.monitorClient; + this.queryGenerator = options.queryGenerator; + this.webhook = options.webhook; + this.log = options.logger ?? console; + } + + // ── Reconciliation (pure, no API calls) ──────────────────────────────── + + reconcileMonitors( + currentVendors: Vendor[], + registeredMonitors: MonitorRegistryEntry[], + ): ReconcileResult { + const activeVendors = currentVendors.filter((v) => v.active); + const activeDomains = new Set(activeVendors.map((v) => v.vendor_domain)); + + // Group registered monitors by vendor_domain + const registeredByDomain = new Map(); + for (const entry of registeredMonitors) { + const ids = registeredByDomain.get(entry.vendor_domain) ?? []; + ids.push(entry.monitor_id); + registeredByDomain.set(entry.vendor_domain, ids); + } + const registeredDomains = new Set(registeredByDomain.keys()); + + const to_create = activeVendors + .filter((v) => !registeredDomains.has(v.vendor_domain)) + .map((vendor) => ({ + vendor, + queries: this.queryGenerator.generateQueries(vendor), + })); + + const to_delete: ReconcileResult["to_delete"] = []; + for (const [domain, monitorIds] of registeredByDomain) { + if (!activeDomains.has(domain)) { + to_delete.push({ vendor_domain: domain, monitor_ids: monitorIds }); + } + } + + const unchanged = [...activeDomains] + .filter((d) => registeredDomains.has(d)) + .map((vendor_domain) => ({ vendor_domain })); + + return { to_create, to_delete, unchanged }; + } + + // ── Deploy monitors for vendors ──────────────────────────────────────── + + async deployMonitors( + vendors: Vendor[], + ): Promise> { + const result = new Map(); + + for (const vendor of vendors) { + const queries = this.queryGenerator.generateQueries(vendor); + const monitorIds: string[] = []; + + for (const qs of queries) { + this.log.debug( + "[portfolio] Creating %s monitor for %s", + qs.risk_dimension, + vendor.vendor_name, + ); + + const monitor = await this.monitorClient.createMonitor({ + query: qs.query, + cadence: qs.cadence, + webhook: this.webhook, + metadata: { + vendor_name: vendor.vendor_name, + vendor_domain: vendor.vendor_domain, + monitor_category: qs.monitor_category, + risk_dimension: qs.risk_dimension, + }, + output_schema: MONITOR_OUTPUT_SCHEMA, + }); + + monitorIds.push(monitor.monitor_id); + } + + result.set(vendor.vendor_domain, monitorIds); + } + + return result; + } + + // ── Remove monitors ──────────────────────────────────────────────────── + + async removeMonitors(monitorIds: string[]): Promise { + for (const id of monitorIds) { + this.log.debug("[portfolio] Deleting monitor %s", id); + await this.monitorClient.deleteMonitor(id); + } + } + + // ── Apply a full reconciliation ──────────────────────────────────────── + + async applyReconciliation( + reconcileResult: ReconcileResult, + ): Promise<{ created: Map; deleted: string[] }> { + const vendorsToCreate = reconcileResult.to_create.map((e) => e.vendor); + const created = await this.deployMonitors(vendorsToCreate); + + const idsToDelete = reconcileResult.to_delete.flatMap((e) => e.monitor_ids); + await this.removeMonitors(idsToDelete); + + return { created, deleted: idsToDelete }; + } +} diff --git a/typescript-recipes/parallel-n8n-procurement/src/services/monitor-query-generator.ts b/typescript-recipes/parallel-n8n-procurement/src/services/monitor-query-generator.ts new file mode 100644 index 0000000..905f11e --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/src/services/monitor-query-generator.ts @@ -0,0 +1,84 @@ +import type { MonitorCadence } from "../models/monitor-api.js"; +import type { RiskDimension, MonitorQuerySet } from "../models/monitor-query.js"; +import type { Vendor, MonitoringPriority } from "../models/vendor.js"; + +// ── Query Templates (PRD Section 4.2 Workflow 4) ────────────────────────── + +interface QueryTemplate { + risk_dimension: RiskDimension; + monitor_category: string; + template: string; +} + +const QUERY_TEMPLATES: QueryTemplate[] = [ + { + risk_dimension: "legal", + monitor_category: "Legal & Regulatory", + template: + '"{vendor_name}" lawsuit OR litigation OR regulatory action OR SEC investigation OR enforcement', + }, + { + risk_dimension: "cyber", + monitor_category: "Cybersecurity", + template: + '"{vendor_name}" data breach OR cybersecurity incident OR ransomware OR vulnerability disclosure', + }, + { + risk_dimension: "financial", + monitor_category: "Financial Health", + template: + '"{vendor_name}" bankruptcy OR financial distress OR credit downgrade OR debt default OR layoffs', + }, + { + risk_dimension: "leadership", + monitor_category: "Leadership & Governance", + template: + '"{vendor_name}" CEO departure OR executive change OR acquisition OR merger OR leadership', + }, + { + risk_dimension: "esg", + monitor_category: "ESG & Reputation", + template: + '"{vendor_name}" recall OR safety violation OR environmental fine OR labor dispute OR ESG controversy', + }, +]; + +// ── Priority Configuration (PRD Section 6.1) ────────────────────────────── + +interface PriorityConfig { + dimensions: RiskDimension[]; + cadence: MonitorCadence; +} + +const PRIORITY_CONFIG: Record = { + high: { + dimensions: ["legal", "cyber", "financial", "leadership", "esg"], + cadence: "daily", + }, + medium: { + dimensions: ["legal", "cyber", "financial"], + cadence: "daily", + }, + low: { + dimensions: ["legal", "financial"], + cadence: "weekly", + }, +}; + +// ── Generator ────────────────────────────────────────────────────────────── + +export class MonitorQueryGenerator { + generateQueries(vendor: Vendor): MonitorQuerySet[] { + const config = PRIORITY_CONFIG[vendor.monitoring_priority]; + const allowedDimensions = new Set(config.dimensions); + + return QUERY_TEMPLATES.filter((t) => allowedDimensions.has(t.risk_dimension)).map( + (t) => ({ + query: t.template.replace("{vendor_name}", vendor.vendor_name), + risk_dimension: t.risk_dimension, + cadence: config.cadence, + monitor_category: t.monitor_category, + }), + ); + } +} diff --git a/typescript-recipes/parallel-n8n-procurement/src/services/parallel-monitor-client.ts b/typescript-recipes/parallel-n8n-procurement/src/services/parallel-monitor-client.ts new file mode 100644 index 0000000..4cd290d --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/src/services/parallel-monitor-client.ts @@ -0,0 +1,227 @@ +import axios, { + type AxiosInstance, + type AxiosRequestConfig, + AxiosError, +} from "axios"; +import { ParallelApiError } from "../models/task-api.js"; +import { + MonitorSchema, + MonitorListResponseSchema, + MonitorEventSchema, + EventGroupDetailsSchema, + type Monitor, + type MonitorListResponse, + type MonitorEvent, + type EventGroupDetails, + type MonitorCreateInput, + type MonitorUpdateInput, +} from "../models/monitor-api.js"; + +// ── Constants ────────────────────────────────────────────────────────────── + +const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503]); + +// ── Options ──────────────────────────────────────────────────────────────── + +export interface ParallelMonitorClientOptions { + apiKey: string; + baseUrl?: string; + logger?: Pick; +} + +// ── Client ───────────────────────────────────────────────────────────────── + +export class ParallelMonitorClient { + private readonly http: AxiosInstance; + private readonly log: Pick; + + constructor(options: ParallelMonitorClientOptions) { + this.log = options.logger ?? console; + + this.http = axios.create({ + baseURL: options.baseUrl ?? "https://api.parallel.ai", + headers: { + "x-api-key": options.apiKey, + "Content-Type": "application/json", + }, + timeout: 60_000, + }); + } + + // ── Monitor CRUD ─────────────────────────────────────────────────────── + + async createMonitor(config: MonitorCreateInput): Promise { + this.log.debug("[parallel] POST /v1alpha/monitors", { + cadence: config.cadence, + hasWebhook: !!config.webhook, + hasMetadata: !!config.metadata, + }); + + const data = await this.requestWithRetry({ + method: "POST", + url: "/v1alpha/monitors", + data: config, + }); + + return MonitorSchema.parse(data); + } + + async listMonitors(params?: { + limit?: number; + offset?: number; + }): Promise { + this.log.debug("[parallel] GET /v1alpha/monitors", params); + + const data = await this.requestWithRetry({ + method: "GET", + url: "/v1alpha/monitors", + params, + }); + + return MonitorListResponseSchema.parse(data); + } + + async getMonitor(monitorId: string): Promise { + this.log.debug("[parallel] GET /v1alpha/monitors/%s", monitorId); + + const data = await this.requestWithRetry({ + method: "GET", + url: `/v1alpha/monitors/${monitorId}`, + }); + + return MonitorSchema.parse(data); + } + + async updateMonitor( + monitorId: string, + updates: MonitorUpdateInput, + ): Promise { + this.log.debug("[parallel] PATCH /v1alpha/monitors/%s", monitorId); + + const data = await this.requestWithRetry({ + method: "PATCH", + url: `/v1alpha/monitors/${monitorId}`, + data: updates, + }); + + return MonitorSchema.parse(data); + } + + async deleteMonitor(monitorId: string): Promise { + this.log.debug("[parallel] DELETE /v1alpha/monitors/%s", monitorId); + + await this.requestWithRetry({ + method: "DELETE", + url: `/v1alpha/monitors/${monitorId}`, + }); + } + + // ── Monitor Events ───────────────────────────────────────────────────── + + async getMonitorEvents( + monitorId: string, + params?: { limit?: number }, + ): Promise { + this.log.debug( + "[parallel] GET /v1alpha/monitors/%s/events", + monitorId, + ); + + const data = await this.requestWithRetry({ + method: "GET", + url: `/v1alpha/monitors/${monitorId}/events`, + params, + }); + + // API may return { events: [...] } or bare array + const events = Array.isArray(data) + ? data + : (data as { events: unknown[] }).events; + + return events.map((e: unknown) => MonitorEventSchema.parse(e)); + } + + async getEventGroupDetails( + monitorId: string, + eventGroupId: string, + ): Promise { + this.log.debug( + "[parallel] GET /v1alpha/monitors/%s/event_groups/%s", + monitorId, + eventGroupId, + ); + + const data = await this.requestWithRetry({ + method: "GET", + url: `/v1alpha/monitors/${monitorId}/event_groups/${eventGroupId}`, + }); + + return EventGroupDetailsSchema.parse(data); + } + + // ── Private Helpers ──────────────────────────────────────────────────── + + private async requestWithRetry( + config: AxiosRequestConfig, + maxRetries: number = 3, + initialDelayMs: number = 1000, + ): Promise { + let lastError: Error | undefined; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + const response = await this.http.request(config); + return response.data; + } catch (err) { + if (!(err instanceof AxiosError) || !err.response) { + throw err; + } + + const status = err.response.status; + lastError = err; + + if (!RETRYABLE_STATUS_CODES.has(status) || attempt === maxRetries) { + const body = + typeof err.response.data === "string" + ? err.response.data + : JSON.stringify(err.response.data ?? ""); + throw new ParallelApiError( + `Parallel API error ${status}: ${config.method?.toUpperCase()} ${config.url}`, + status, + body, + ); + } + + let delayMs = initialDelayMs * Math.pow(2, attempt); + + if (status === 429) { + const retryAfter = err.response.headers?.["retry-after"]; + if (retryAfter) { + const retryAfterMs = Number(retryAfter) * 1000; + if (!isNaN(retryAfterMs) && retryAfterMs > delayMs) { + delayMs = retryAfterMs; + } + } + } + + this.log.debug( + "[parallel] Retrying %s %s (attempt %d/%d, status %d, delay %dms)", + config.method?.toUpperCase(), + config.url, + attempt + 1, + maxRetries, + status, + delayMs, + ); + + await this.sleep(delayMs); + } + } + + throw lastError ?? new Error("Unexpected retry exhaustion"); + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/typescript-recipes/parallel-n8n-procurement/src/services/parallel-task-client.ts b/typescript-recipes/parallel-n8n-procurement/src/services/parallel-task-client.ts new file mode 100644 index 0000000..068129b --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/src/services/parallel-task-client.ts @@ -0,0 +1,294 @@ +import axios, { + type AxiosInstance, + type AxiosRequestConfig, + AxiosError, +} from "axios"; +import { + ParallelApiError, + RunNotCompleteError, + TaskGroupTimeoutError, + TaskRunSchema, + TaskRunResultSchema, + TaskGroupSchema, + TaskGroupStatusSchema, + TaskGroupResultsSchema, + type TaskRun, + type TaskRunResult, + type TaskRunInput, + type TaskGroup, + type TaskGroupStatus, + type TaskGroupResults, + type CreateRunParams, + type OutputSchema, +} from "../models/task-api.js"; + +// ── Constants ────────────────────────────────────────────────────────────── + +const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503]); +const MAX_RUNS_PER_REQUEST = 1000; + +// ── Options ──────────────────────────────────────────────────────────────── + +export interface ParallelTaskClientOptions { + apiKey: string; + baseUrl?: string; + defaultProcessor?: string; + logger?: Pick; +} + +// ── Client ───────────────────────────────────────────────────────────────── + +export class ParallelTaskClient { + private readonly http: AxiosInstance; + private readonly defaultProcessor: string; + private readonly log: Pick; + + constructor(options: ParallelTaskClientOptions) { + this.defaultProcessor = options.defaultProcessor ?? "ultra8x"; + this.log = options.logger ?? console; + + this.http = axios.create({ + baseURL: options.baseUrl ?? "https://api.parallel.ai", + headers: { + "x-api-key": options.apiKey, + "Content-Type": "application/json", + }, + timeout: 60_000, + }); + } + + // ── Task Run Methods ─────────────────────────────────────────────────── + + async createRun(params: CreateRunParams): Promise { + const { input, processor, outputSchema, webhook } = params; + + const body: Record = { + input, + processor: processor ?? this.defaultProcessor, + }; + + if (outputSchema) { + body.task_spec = { output_schema: outputSchema }; + } + + if (webhook) { + body.webhook = { url: webhook.url, events: webhook.events ?? ["task_run.status"] }; + } + + this.log.debug("[parallel] POST /v1/tasks/runs", { + processor: body.processor, + hasSchema: !!outputSchema, + hasWebhook: !!webhook, + }); + + const data = await this.requestWithRetry({ + method: "POST", + url: "/v1/tasks/runs", + data: body, + }); + + return TaskRunSchema.parse(data); + } + + async getRunStatus(runId: string): Promise { + this.log.debug("[parallel] GET /v1/tasks/runs/%s", runId); + + const data = await this.requestWithRetry({ + method: "GET", + url: `/v1/tasks/runs/${runId}`, + }); + + return TaskRunSchema.parse(data); + } + + async getRunResult(runId: string): Promise { + const status = await this.getRunStatus(runId); + + if (status.status !== "completed") { + throw new RunNotCompleteError(runId, status.status); + } + + this.log.debug("[parallel] GET /v1/tasks/runs/%s/result", runId); + + const data = await this.requestWithRetry({ + method: "GET", + url: `/v1/tasks/runs/${runId}/result`, + }); + + return TaskRunResultSchema.parse(data); + } + + // ── Task Group Methods ───────────────────────────────────────────────── + + async createTaskGroup(): Promise { + this.log.debug("[parallel] POST /v1beta/tasks/groups"); + + const data = await this.requestWithRetry({ + method: "POST", + url: "/v1beta/tasks/groups", + data: {}, + }); + + return TaskGroupSchema.parse(data); + } + + async addRunsToGroup( + taskGroupId: string, + runs: TaskRunInput[], + defaultTaskSpec?: { output_schema: OutputSchema }, + ): Promise { + const allRunIds: string[] = []; + + for (let i = 0; i < runs.length; i += MAX_RUNS_PER_REQUEST) { + const chunk = runs.slice(i, i + MAX_RUNS_PER_REQUEST); + const batchNum = Math.floor(i / MAX_RUNS_PER_REQUEST) + 1; + + this.log.debug( + "[parallel] POST /v1beta/tasks/groups/%s/runs (batch %d, %d runs)", + taskGroupId, + batchNum, + chunk.length, + ); + + const body: Record = { inputs: chunk }; + if (defaultTaskSpec) { + body.default_task_spec = defaultTaskSpec; + } + + const data = await this.requestWithRetry<{ run_ids: string[] }>({ + method: "POST", + url: `/v1beta/tasks/groups/${taskGroupId}/runs`, + data: body, + }); + + allRunIds.push(...data.run_ids); + } + + return allRunIds; + } + + async getTaskGroupStatus(taskGroupId: string): Promise { + this.log.debug("[parallel] GET /v1beta/tasks/groups/%s", taskGroupId); + + const data = await this.requestWithRetry({ + method: "GET", + url: `/v1beta/tasks/groups/${taskGroupId}`, + }); + + return TaskGroupStatusSchema.parse(data); + } + + async getTaskGroupResults( + taskGroupId: string, + includeOutput: boolean = true, + ): Promise { + this.log.debug( + "[parallel] GET /v1beta/tasks/groups/%s/runs (include_output=%s)", + taskGroupId, + includeOutput, + ); + + const data = await this.requestWithRetry({ + method: "GET", + url: `/v1beta/tasks/groups/${taskGroupId}/runs`, + params: { include_output: includeOutput }, + }); + + return TaskGroupResultsSchema.parse(data); + } + + async pollTaskGroupUntilComplete( + taskGroupId: string, + pollIntervalMs: number = 60_000, + timeoutMs: number = 3_600_000, + ): Promise { + const startTime = Date.now(); + + while (Date.now() - startTime < timeoutMs) { + const status = await this.getTaskGroupStatus(taskGroupId); + + this.log.debug( + "[parallel] Poll group %s: active=%s, completed=%d, failed=%d, total=%d", + taskGroupId, + status.status.is_active, + status.status.task_run_status_counts.completed ?? 0, + status.status.task_run_status_counts.failed ?? 0, + status.status.num_task_runs, + ); + + if (!status.status.is_active) { + return this.getTaskGroupResults(taskGroupId, true); + } + + await this.sleep(pollIntervalMs); + } + + throw new TaskGroupTimeoutError(taskGroupId, Date.now() - startTime); + } + + // ── Private Helpers ──────────────────────────────────────────────────── + + private async requestWithRetry( + config: AxiosRequestConfig, + maxRetries: number = 3, + initialDelayMs: number = 1000, + ): Promise { + let lastError: Error | undefined; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + const response = await this.http.request(config); + return response.data; + } catch (err) { + if (!(err instanceof AxiosError) || !err.response) { + throw err; + } + + const status = err.response.status; + lastError = err; + + if (!RETRYABLE_STATUS_CODES.has(status) || attempt === maxRetries) { + const body = + typeof err.response.data === "string" + ? err.response.data + : JSON.stringify(err.response.data ?? ""); + throw new ParallelApiError( + `Parallel API error ${status}: ${config.method?.toUpperCase()} ${config.url}`, + status, + body, + ); + } + + let delayMs = initialDelayMs * Math.pow(2, attempt); + + if (status === 429) { + const retryAfter = err.response.headers?.["retry-after"]; + if (retryAfter) { + const retryAfterMs = Number(retryAfter) * 1000; + if (!isNaN(retryAfterMs) && retryAfterMs > delayMs) { + delayMs = retryAfterMs; + } + } + } + + this.log.debug( + "[parallel] Retrying %s %s (attempt %d/%d, status %d, delay %dms)", + config.method?.toUpperCase(), + config.url, + attempt + 1, + maxRetries, + status, + delayMs, + ); + + await this.sleep(delayMs); + } + } + + throw lastError ?? new Error("Unexpected retry exhaustion"); + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/typescript-recipes/parallel-n8n-procurement/src/services/research-orchestrator.ts b/typescript-recipes/parallel-n8n-procurement/src/services/research-orchestrator.ts new file mode 100644 index 0000000..dd45c44 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/src/services/research-orchestrator.ts @@ -0,0 +1,320 @@ +import type { Vendor, RiskTier } from "../models/vendor.js"; +import type { DeepResearchOutput, RiskAssessment } from "../models/risk-assessment.js"; +import type { + BatchResult, + ProcessedResults, + ResearchRunSummary, +} from "../models/research-run.js"; +import type { ParallelTaskClient } from "./parallel-task-client.js"; +import type { BatchPlanner, VendorBatch } from "./batch-planner.js"; +import type { ResearchPromptBuilder } from "./research-prompt-builder.js"; +import type { RiskScorer } from "./risk-scorer.js"; +import type { SlackFormatter } from "./slack-formatter.js"; +import type { SlackDeliveryService } from "./slack-delivery.js"; +import type { AuditLogger } from "./audit-logger.js"; +import type { SlackOpsReporter } from "./slack-ops-reporter.js"; + +// ── Options ──────────────────────────────────────────────────────────────── + +export interface ResearchOrchestratorOptions { + taskClient: ParallelTaskClient; + batchPlanner: BatchPlanner; + promptBuilder: ResearchPromptBuilder; + riskScorer: RiskScorer; + formatter: SlackFormatter; + deliveryService: SlackDeliveryService; + auditLogger: AuditLogger; + opsReporter?: SlackOpsReporter; + cycleLength?: number; + pollIntervalMs?: number; + pollTimeoutMs?: number; + logger?: Pick; +} + +// ── Orchestrator ─────────────────────────────────────────────────────────── + +export class ResearchOrchestrator { + private readonly taskClient: ParallelTaskClient; + private readonly batchPlanner: BatchPlanner; + private readonly promptBuilder: ResearchPromptBuilder; + private readonly riskScorer: RiskScorer; + private readonly formatter: SlackFormatter; + private readonly deliveryService: SlackDeliveryService; + private readonly auditLogger: AuditLogger; + private readonly opsReporter?: SlackOpsReporter; + private readonly cycleLength: number; + private readonly pollIntervalMs: number; + private readonly pollTimeoutMs: number; + private readonly log: Pick; + + constructor(options: ResearchOrchestratorOptions) { + this.taskClient = options.taskClient; + this.batchPlanner = options.batchPlanner; + this.promptBuilder = options.promptBuilder; + this.riskScorer = options.riskScorer; + this.formatter = options.formatter; + this.deliveryService = options.deliveryService; + this.auditLogger = options.auditLogger; + this.opsReporter = options.opsReporter; + this.cycleLength = options.cycleLength ?? 7; + this.pollIntervalMs = options.pollIntervalMs ?? 60_000; + this.pollTimeoutMs = options.pollTimeoutMs ?? 3_600_000; + this.log = options.logger ?? console; + } + + // ── Top-Level Orchestration ──────────────────────────────────────────── + + async runScheduledResearch( + vendors: Vendor[], + ): Promise { + const startTime = Date.now(); + const today = new Date().toISOString().slice(0, 10); + + const dueVendors = this.batchPlanner.getVendorsDueForResearch(vendors, today); + + this.log.debug( + "[orchestrator] %d vendors due for research out of %d total", + dueVendors.length, + vendors.length, + ); + + if (dueVendors.length === 0) { + return this.buildSummary(0, 0, 0, {}, 0, 0, startTime); + } + + const batches = this.batchPlanner.planBatches(dueVendors); + + // Execute all batches + const allResults = new Map(); + const allFailedDomains = new Set(); + + for (const batch of batches) { + const batchResult = await this.executeBatch(batch); + + for (const [domain, output] of batchResult.results) { + allResults.set(domain, output); + } + + for (const failure of batchResult.failures) { + allFailedDomains.add(failure.vendor_domain); + } + + if (batchResult.failures.length > 0) { + await this.handlePartialFailure( + batch, + batchResult.failures.map((f) => f.vendor_domain), + ); + } + } + + // Process results (score + route to Slack + audit) + const processed = await this.processResults(allResults, dueVendors); + + // Advance dates only for successful vendors + const succeededVendors = dueVendors.filter( + (v) => allResults.has(v.vendor_domain) && !allFailedDomains.has(v.vendor_domain), + ); + this.batchPlanner.updateNextResearchDates(succeededVendors, this.cycleLength); + + // Build summary + const riskCounts: Record = { LOW: 0, MEDIUM: 0, HIGH: 0, CRITICAL: 0 }; + let adverseCount = 0; + for (const { assessment } of processed.assessments) { + riskCounts[assessment.risk_level]++; + if (assessment.adverse_flag) adverseCount++; + } + + const summary = this.buildSummary( + dueVendors.length, + allResults.size, + allFailedDomains.size, + riskCounts, + adverseCount, + batches.length, + startTime, + ); + + if (this.opsReporter && (summary.total_failed > 0 || summary.adverse_count > 0)) { + try { + await this.opsReporter.sendRunSummary(summary); + } catch (err) { + this.log.error("[orchestrator] Failed to send ops run summary: %s", (err as Error).message); + } + } + + return summary; + } + + // ── Execute Single Batch ─────────────────────────────────────────────── + + async executeBatch(batch: VendorBatch): Promise { + this.log.debug( + "[orchestrator] Executing batch %d (%d vendors)", + batch.batch_index, + batch.vendors.length, + ); + + const taskGroup = await this.taskClient.createTaskGroup(); + const outputSchema = this.promptBuilder.getOutputSchema(); + + const runs = batch.vendors.map((vendor) => ({ + input: this.promptBuilder.buildPrompt(vendor), + })); + + const runIds = await this.taskClient.addRunsToGroup( + taskGroup.taskgroup_id, + runs, + { output_schema: outputSchema }, + ); + + // Map run_id → vendor_domain + const runToVendor = new Map(); + for (let i = 0; i < runIds.length; i++) { + runToVendor.set(runIds[i], batch.vendors[i].vendor_domain); + } + + // Poll until complete + const groupResults = await this.taskClient.pollTaskGroupUntilComplete( + taskGroup.taskgroup_id, + this.pollIntervalMs, + this.pollTimeoutMs, + ); + + // Map results back to vendor domains + const results = new Map(); + const failures: BatchResult["failures"] = []; + + for (const run of groupResults) { + const vendorDomain = runToVendor.get(run.run_id); + if (!vendorDomain) continue; + + if (run.status === "completed" && run.output) { + results.set(vendorDomain, run.output.content as DeepResearchOutput); + } else { + failures.push({ + vendor_domain: vendorDomain, + run_id: run.run_id, + error: run.error ?? `Run ended with status: ${run.status}`, + }); + } + } + + return { + batch_index: batch.batch_index, + taskgroup_id: taskGroup.taskgroup_id, + results, + failures, + }; + } + + // ── Process Results ──────────────────────────────────────────────────── + + async processResults( + results: Map, + vendors: Vendor[], + ): Promise { + const vendorMap = new Map(vendors.map((v) => [v.vendor_domain, v])); + const assessments: ProcessedResults["assessments"] = []; + const errors: ProcessedResults["errors"] = []; + + for (const [domain, output] of results) { + const vendor = vendorMap.get(domain); + if (!vendor) { + errors.push({ vendor_domain: domain, error: "Vendor not found in input list" }); + continue; + } + + try { + const assessment = this.riskScorer.scoreDeepResearch(output, { + risk_tier_override: vendor.risk_tier_override, + }); + + // Route to Slack + await this.routeToSlack(assessment, vendor, output); + + // Audit log + await this.auditLogger.logAssessment({ + timestamp: new Date().toISOString(), + vendor_name: vendor.vendor_name, + risk_level: assessment.risk_level, + adverse_flag: assessment.adverse_flag, + categories: assessment.risk_categories.join(", "), + summary: assessment.summary, + run_id: "", + source: "deep_research", + }); + + assessments.push({ vendor, assessment }); + } catch (err) { + errors.push({ + vendor_domain: domain, + error: `Processing failed: ${(err as Error).message}`, + }); + } + } + + return { assessments, errors }; + } + + // ── Handle Partial Failure ───────────────────────────────────────────── + + async handlePartialFailure( + batch: VendorBatch, + failedVendors: string[], + ): Promise { + for (const domain of failedVendors) { + this.log.warn( + "[orchestrator] Vendor %s failed in batch %d — will retry next cycle", + domain, + batch.batch_index, + ); + } + // Failed vendors' next_research_date is NOT advanced + // They will be picked up again in the next cycle + } + + // ── Private Helpers ──────────────────────────────────────────────────── + + private async routeToSlack( + assessment: RiskAssessment, + vendor: Vendor, + output: DeepResearchOutput, + ): Promise { + if (assessment.risk_level === "CRITICAL" || assessment.risk_level === "HIGH") { + const message = this.formatter.formatCriticalAlert( + assessment, + vendor, + output.adverse_events, + ); + await this.deliveryService.sendAlert(message); + } else if (assessment.risk_level === "MEDIUM") { + this.deliveryService.queueForDigest(assessment, vendor); + } + // LOW → no immediate Slack alert + } + + private buildSummary( + totalDue: number, + totalResearched: number, + totalFailed: number, + riskCounts: Partial>, + adverseCount: number, + batchesExecuted: number, + startTime: number, + ): ResearchRunSummary { + return { + total_due: totalDue, + total_researched: totalResearched, + total_failed: totalFailed, + risk_counts: { + LOW: riskCounts.LOW ?? 0, + MEDIUM: riskCounts.MEDIUM ?? 0, + HIGH: riskCounts.HIGH ?? 0, + CRITICAL: riskCounts.CRITICAL ?? 0, + }, + adverse_count: adverseCount, + batches_executed: batchesExecuted, + duration_ms: Date.now() - startTime, + }; + } +} diff --git a/typescript-recipes/parallel-n8n-procurement/src/services/research-prompt-builder.ts b/typescript-recipes/parallel-n8n-procurement/src/services/research-prompt-builder.ts new file mode 100644 index 0000000..0c97466 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/src/services/research-prompt-builder.ts @@ -0,0 +1,143 @@ +import type { OutputSchema } from "../models/task-api.js"; +import type { Vendor } from "../models/vendor.js"; + +// ── Output Schema (PRD Section 5.1) ─────────────────────────────────────── + +const RISK_LEVEL_ENUM = ["LOW", "MEDIUM", "HIGH", "CRITICAL"]; +const RECOMMENDATION_ENUM = ["APPROVE", "MONITOR", "ESCALATE", "REJECT"]; + +const RISK_DIMENSION_SCHEMA = { + type: "object", + properties: { + status: { + type: "string", + enum: RISK_LEVEL_ENUM, + description: "Risk level for this dimension", + }, + findings: { + type: "string", + description: "Summary of key findings for this dimension", + }, + severity: { + type: "string", + enum: RISK_LEVEL_ENUM, + description: "Severity of the most critical finding", + }, + }, + required: ["status", "findings", "severity"], +}; + +const RESEARCH_OUTPUT_SCHEMA: OutputSchema = { + type: "json", + json_schema: { + type: "object", + properties: { + vendor_name: { type: "string", description: "Name of the vendor assessed" }, + assessment_date: { + type: "string", + description: "ISO date of the assessment (YYYY-MM-DD)", + }, + overall_risk_level: { + type: "string", + enum: RISK_LEVEL_ENUM, + description: "Aggregate risk level across all dimensions", + }, + financial_health: { + ...RISK_DIMENSION_SCHEMA, + description: "Financial health assessment: earnings, credit ratings, debt, funding", + }, + legal_regulatory: { + ...RISK_DIMENSION_SCHEMA, + description: "Legal and regulatory risk: litigation, sanctions, compliance", + }, + cybersecurity: { + ...RISK_DIMENSION_SCHEMA, + description: "Cybersecurity posture: vulnerabilities, breach history, certifications", + }, + leadership_governance: { + ...RISK_DIMENSION_SCHEMA, + description: "Leadership and governance: executive changes, board stability, M&A", + }, + esg_reputation: { + ...RISK_DIMENSION_SCHEMA, + description: "ESG and reputational risk: environmental, labor, public perception", + }, + adverse_events: { + type: "array", + description: "List of adverse events discovered during research", + items: { + type: "object", + properties: { + title: { type: "string", description: "Event headline" }, + date: { type: "string", description: "Event date (YYYY-MM-DD or approximate)" }, + category: { + type: "string", + description: "Event category (financial, legal, cyber, leadership, esg, operational)", + }, + severity: { type: "string", enum: RISK_LEVEL_ENUM }, + source_url: { type: "string", description: "URL of the source" }, + description: { type: "string", description: "Brief description of the event" }, + }, + required: ["title", "date", "category", "severity", "description"], + }, + }, + recommendation: { + type: "string", + enum: RECOMMENDATION_ENUM, + description: "Recommended action based on the assessment", + }, + }, + required: [ + "vendor_name", + "assessment_date", + "overall_risk_level", + "financial_health", + "legal_regulatory", + "cybersecurity", + "leadership_governance", + "esg_reputation", + "adverse_events", + "recommendation", + ], + }, +}; + +// ── Prompt Builder ───────────────────────────────────────────────────────── + +export class ResearchPromptBuilder { + buildPrompt(vendor: Vendor): string { + return `You are a vendor risk analyst conducting a comprehensive due diligence assessment of "${vendor.vendor_name}" (${vendor.vendor_domain}), a ${vendor.vendor_category} vendor. + +Investigate the following six risk dimensions thoroughly. For each finding, classify its severity (LOW, MEDIUM, HIGH, CRITICAL) and include source URLs and dates where available. + +1. FINANCIAL HEALTH + Research earnings reports, credit ratings, debt levels, funding rounds, revenue trends, and any signs of financial distress. Check for bankruptcy filings, credit downgrades, and debt defaults. + +2. LEGAL & REGULATORY + Investigate active litigation, regulatory actions, SEC investigations, sanctions exposure, OFAC listings, and compliance violations. Check for class action lawsuits, consent orders, and enforcement actions. + +3. OPERATIONAL RISK + Examine service outages, data breaches, supply chain disruptions, client complaints, and operational incidents. Assess business continuity and disaster recovery posture. + +4. LEADERSHIP & GOVERNANCE + Research executive departures, CEO changes, board reshuffles, activist investor activity, and M&A activity. Assess management stability and governance quality. + +5. ESG & REPUTATION + Investigate environmental violations, labor disputes, workplace safety issues, negative press coverage, social media controversies, and ESG rating changes. Check for recalls and consumer protection actions. + +6. CYBERSECURITY POSTURE + Research known vulnerabilities, breach history, security certifications (SOC 2, ISO 27001), penetration test disclosures, and data protection practices. Check for ransomware incidents and vulnerability disclosures. + +Provide a structured assessment with: +- An overall risk level (LOW, MEDIUM, HIGH, or CRITICAL) +- A status, findings summary, and severity for each risk dimension +- A list of specific adverse events with dates, sources, and severity +- A recommendation: APPROVE (low risk), MONITOR (moderate risk requiring ongoing attention), ESCALATE (high risk requiring immediate review), or REJECT (unacceptable risk) + +Be specific and cite real events, dates, and sources. Do not speculate or fabricate findings.`; + } + + getOutputSchema(): OutputSchema { + return RESEARCH_OUTPUT_SCHEMA; + } +} diff --git a/typescript-recipes/parallel-n8n-procurement/src/services/risk-scorer.ts b/typescript-recipes/parallel-n8n-procurement/src/services/risk-scorer.ts new file mode 100644 index 0000000..bfa47d1 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/src/services/risk-scorer.ts @@ -0,0 +1,210 @@ +import type { RiskTier } from "../models/vendor.js"; +import type { + DeepResearchOutput, + MonitorEventOutput, + VendorOverrides, + VendorContext, + SeverityCounts, + Recommendation, + RiskAssessment, + RiskDimensionOutput, +} from "../models/risk-assessment.js"; + +// ── Constants ────────────────────────────────────────────────────────────── + +const RISK_ORDER: Record = { + LOW: 0, + MEDIUM: 1, + HIGH: 2, + CRITICAL: 3, +}; + +const RECOMMENDATION_MAP: Record = { + LOW: "continue_monitoring", + MEDIUM: "escalate_review", + HIGH: "initiate_contingency", + CRITICAL: "suspend_relationship", +}; + +const DIMENSION_NAMES: Record = { + financial_health: "financial_health", + legal_regulatory: "legal_regulatory", + cybersecurity: "cybersecurity", + leadership_governance: "leadership_governance", + esg_reputation: "esg_reputation", +}; + +// ── Risk Scorer ──────────────────────────────────────────────────────────── + +export class RiskScorer { + scoreDeepResearch( + output: DeepResearchOutput, + vendorOverrides?: VendorOverrides, + ): RiskAssessment { + const dimensions: Record = { + financial_health: output.financial_health, + legal_regulatory: output.legal_regulatory, + cybersecurity: output.cybersecurity, + leadership_governance: output.leadership_governance, + esg_reputation: output.esg_reputation, + }; + + // Step 1: Severity Aggregation + const counts: SeverityCounts = { critical: 0, high: 0, medium: 0, low: 0 }; + const riskCategories: string[] = []; + const mediumCategories: string[] = []; + + for (const [name, dim] of Object.entries(dimensions)) { + const sev = dim.severity.toUpperCase() as RiskTier; + if (sev === "CRITICAL") { + counts.critical++; + riskCategories.push(name); + } else if (sev === "HIGH") { + counts.high++; + riskCategories.push(name); + } else if (sev === "MEDIUM") { + counts.medium++; + mediumCategories.push(name); + } else { + counts.low++; + } + } + + // Step 2: Risk Level Assignment + let riskLevel: RiskTier; + let adverseFlag: boolean; + + if (counts.critical > 0) { + riskLevel = "CRITICAL"; + adverseFlag = true; + } else if (counts.high >= 2) { + riskLevel = "HIGH"; + adverseFlag = true; + } else if (counts.high === 1) { + riskLevel = "HIGH"; + adverseFlag = true; + } else if (counts.medium >= 3) { + riskLevel = "MEDIUM"; + // Conditional: adverse if medium findings span ≥2 distinct categories + const uniqueMediumCats = new Set(mediumCategories); + adverseFlag = uniqueMediumCats.size >= 2; + } else if (counts.medium >= 1) { + riskLevel = "MEDIUM"; + adverseFlag = false; + } else { + riskLevel = "LOW"; + adverseFlag = false; + } + + // Include medium categories in risk_categories for ≥3 medium with adverse + if (counts.medium >= 3 && adverseFlag) { + for (const cat of mediumCategories) { + if (!riskCategories.includes(cat)) { + riskCategories.push(cat); + } + } + } + + // Step 3: Override Rules + const triggeredOverrides: string[] = []; + + // Active data breach + if (output.cybersecurity.status.toUpperCase() === "CRITICAL") { + if (RISK_ORDER[riskLevel] < RISK_ORDER["CRITICAL"]) { + riskLevel = "CRITICAL"; + } + adverseFlag = true; + triggeredOverrides.push("active_data_breach"); + if (!riskCategories.includes("cybersecurity")) { + riskCategories.push("cybersecurity"); + } + } + + // Active government litigation + if (output.legal_regulatory.status.toUpperCase() === "CRITICAL") { + if (RISK_ORDER[riskLevel] < RISK_ORDER["HIGH"]) { + riskLevel = "HIGH"; + } + adverseFlag = true; + triggeredOverrides.push("active_government_litigation"); + if (!riskCategories.includes("legal_regulatory")) { + riskCategories.push("legal_regulatory"); + } + } + + // Vendor risk_tier_override as floor + if (vendorOverrides?.risk_tier_override) { + const override = vendorOverrides.risk_tier_override; + if (RISK_ORDER[override] > RISK_ORDER[riskLevel]) { + riskLevel = override; + triggeredOverrides.push(`risk_tier_override_${override}`); + } + } + + // Step 4: Derive remaining fields + const actionRequired = riskLevel === "HIGH" || riskLevel === "CRITICAL"; + const recommendation = RECOMMENDATION_MAP[riskLevel]; + const summary = this.buildSummary(output.vendor_name, riskLevel, adverseFlag, counts); + + return { + risk_level: riskLevel, + adverse_flag: adverseFlag, + risk_categories: riskCategories, + summary, + action_required: actionRequired, + recommendation, + severity_counts: counts, + triggered_overrides: triggeredOverrides, + }; + } + + scoreMonitorEvent( + event: MonitorEventOutput, + vendorContext: VendorContext, + ): RiskAssessment { + const riskLevel = event.severity.toUpperCase() as RiskTier; + const adverseFlag = event.adverse; + + const counts: SeverityCounts = { critical: 0, high: 0, medium: 0, low: 0 }; + const key = riskLevel.toLowerCase() as keyof SeverityCounts; + counts[key] = 1; + + const actionRequired = riskLevel === "HIGH" || riskLevel === "CRITICAL"; + const recommendation = RECOMMENDATION_MAP[riskLevel]; + const summary = `Monitor event for ${vendorContext.vendor_name}: ${event.event_summary}. Severity: ${riskLevel}.${adverseFlag ? " Adverse event flagged." : ""}`; + + return { + risk_level: riskLevel, + adverse_flag: adverseFlag, + risk_categories: [event.event_type], + summary, + action_required: actionRequired, + recommendation, + severity_counts: counts, + triggered_overrides: [], + }; + } + + private buildSummary( + vendorName: string, + riskLevel: RiskTier, + adverseFlag: boolean, + counts: SeverityCounts, + ): string { + const parts: string[] = []; + parts.push(`${vendorName} assessed at ${riskLevel} risk level.`); + + const findings: string[] = []; + if (counts.critical > 0) findings.push(`${counts.critical} critical`); + if (counts.high > 0) findings.push(`${counts.high} high`); + if (counts.medium > 0) findings.push(`${counts.medium} medium`); + if (counts.low > 0) findings.push(`${counts.low} low`); + parts.push(`Severity breakdown: ${findings.join(", ")} findings.`); + + if (adverseFlag) { + parts.push("Adverse conditions detected requiring attention."); + } + + return parts.join(" "); + } +} diff --git a/typescript-recipes/parallel-n8n-procurement/src/services/slack-command-handler.ts b/typescript-recipes/parallel-n8n-procurement/src/services/slack-command-handler.ts new file mode 100644 index 0000000..12b624b --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/src/services/slack-command-handler.ts @@ -0,0 +1,178 @@ +import type { Vendor } from "../models/vendor.js"; +import type { DeepResearchOutput } from "../models/risk-assessment.js"; +import type { + SlackSlashCommandPayload, + ParsedCommand, + TaskWebhookPayload, +} from "../models/slack-command.js"; +import type { SlackDeliveryService } from "./slack-delivery.js"; +import type { ParallelTaskClient } from "./parallel-task-client.js"; +import type { RiskScorer } from "./risk-scorer.js"; +import type { ResearchPromptBuilder } from "./research-prompt-builder.js"; +import type { SlackFormatter } from "./slack-formatter.js"; + +// ── Pending Request ──────────────────────────────────────────────────────── + +interface PendingRequest { + run_id: string; + channel_id: string; + thread_ts: string; + vendor: Vendor; + requesting_user: string; +} + +// ── Options ──────────────────────────────────────────────────────────────── + +export interface SlackCommandHandlerOptions { + deliveryService: SlackDeliveryService; + taskClient: ParallelTaskClient; + riskScorer: RiskScorer; + promptBuilder: ResearchPromptBuilder; + formatter: SlackFormatter; + vendorLookup: (name: string) => Vendor | undefined; + logger?: Pick; +} + +// ── Handler ──────────────────────────────────────────────────────────────── + +export class SlackCommandHandler { + private readonly deliveryService: SlackDeliveryService; + private readonly taskClient: ParallelTaskClient; + private readonly riskScorer: RiskScorer; + private readonly promptBuilder: ResearchPromptBuilder; + private readonly formatter: SlackFormatter; + private readonly vendorLookup: (name: string) => Vendor | undefined; + private readonly log: Pick; + private readonly pendingRequests = new Map(); + + constructor(options: SlackCommandHandlerOptions) { + this.deliveryService = options.deliveryService; + this.taskClient = options.taskClient; + this.riskScorer = options.riskScorer; + this.promptBuilder = options.promptBuilder; + this.formatter = options.formatter; + this.vendorLookup = options.vendorLookup; + this.log = options.logger ?? console; + } + + // ── Parse ────────────────────────────────────────────────────────────── + + parseSlashCommand(payload: SlackSlashCommandPayload): ParsedCommand { + const vendorName = payload.text.trim(); + + if (!vendorName) { + throw new Error( + "Vendor name is required. Usage: /vendor-research {vendor_name}", + ); + } + + return { + vendor_name: vendorName, + requesting_user: payload.user_name, + channel_id: payload.channel_id, + response_url: payload.response_url, + }; + } + + // ── Handle Research Command ──────────────────────────────────────────── + + async handleResearchCommand(command: ParsedCommand): Promise { + const vendor = this.vendorLookup(command.vendor_name); + + if (!vendor) { + this.log.warn( + "[command] Vendor not found: %s", + command.vendor_name, + ); + await this.deliveryService.sendAlert({ + channel: command.channel_id, + text: `Vendor "${command.vendor_name}" not found. Please check the spelling and try again.`, + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: `\u274c Vendor *"${command.vendor_name}"* not found in the vendor registry.\nPlease check the spelling and try again.`, + }, + }, + ], + }); + return; + } + + this.log.debug( + "[command] Starting ad-hoc research for %s requested by %s", + vendor.vendor_name, + command.requesting_user, + ); + + const threadTs = await this.deliveryService.sendAcknowledgment( + command.channel_id, + vendor.vendor_name, + ); + + const prompt = this.promptBuilder.buildPrompt(vendor); + const outputSchema = this.promptBuilder.getOutputSchema(); + + const taskRun = await this.taskClient.createRun({ + input: prompt, + outputSchema, + }); + + this.pendingRequests.set(taskRun.run_id, { + run_id: taskRun.run_id, + channel_id: command.channel_id, + thread_ts: threadTs, + vendor, + requesting_user: command.requesting_user, + }); + + this.log.debug( + "[command] Task run %s created for %s", + taskRun.run_id, + vendor.vendor_name, + ); + } + + // ── Handle Webhook Callback ──────────────────────────────────────────── + + async handleWebhookCallback(payload: TaskWebhookPayload): Promise { + const pending = this.pendingRequests.get(payload.run_id); + + if (!pending) { + this.log.warn( + "[command] Received callback for unknown run_id: %s", + payload.run_id, + ); + return; + } + + this.log.debug( + "[command] Webhook callback for %s (vendor: %s)", + payload.run_id, + pending.vendor.vendor_name, + ); + + const result = await this.taskClient.getRunResult(payload.run_id); + const researchOutput = result.output.content as DeepResearchOutput; + + const assessment = this.riskScorer.scoreDeepResearch(researchOutput); + const message = this.formatter.formatAdHocResult( + assessment, + pending.vendor, + pending.requesting_user, + ); + + await this.deliveryService.sendThreadReply( + pending.channel_id, + pending.thread_ts, + message, + ); + + this.pendingRequests.delete(payload.run_id); + } + + getPendingCount(): number { + return this.pendingRequests.size; + } +} diff --git a/typescript-recipes/parallel-n8n-procurement/src/services/slack-delivery.ts b/typescript-recipes/parallel-n8n-procurement/src/services/slack-delivery.ts new file mode 100644 index 0000000..9a5eae0 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/src/services/slack-delivery.ts @@ -0,0 +1,182 @@ +import axios from "axios"; +import type { SlackMessage } from "../models/slack.js"; +import type { SlackResponse } from "../models/slack-command.js"; +import type { RiskAssessment } from "../models/risk-assessment.js"; +import type { Vendor } from "../models/vendor.js"; +import type { SlackFormatter } from "./slack-formatter.js"; + +// ── Options ──────────────────────────────────────────────────────────────── + +export interface SlackDeliveryServiceOptions { + webhookUrl: string; + formatter: SlackFormatter; + logger?: Pick; +} + +// ── Digest Queue Entry ───────────────────────────────────────────────────── + +interface DigestEntry { + assessment: RiskAssessment; + vendor: Vendor; +} + +// ── Service ──────────────────────────────────────────────────────────────── + +export class SlackDeliveryService { + private readonly webhookUrl: string; + private readonly formatter: SlackFormatter; + private readonly log: Pick; + + private digestQueue: DigestEntry[] = []; + private sendQueue: Array<{ + fn: () => Promise; + resolve: (value: SlackResponse) => void; + reject: (err: unknown) => void; + }> = []; + private processing = false; + + constructor(options: SlackDeliveryServiceOptions) { + this.webhookUrl = options.webhookUrl; + this.formatter = options.formatter; + this.log = options.logger ?? console; + } + + // ── Send Methods ─────────────────────────────────────────────────────── + + async sendAlert(message: SlackMessage): Promise { + return this.enqueue(() => this.postToSlack(message)); + } + + async sendThreadReply( + channel: string, + threadTs: string, + message: SlackMessage, + ): Promise { + return this.sendAlert({ + ...message, + channel, + thread_ts: threadTs, + }); + } + + async sendAcknowledgment( + channel: string, + vendorName: string, + ): Promise { + this.log.debug("[slack] Sending acknowledgment for %s", vendorName); + + const response = await this.sendAlert({ + channel, + text: `Starting deep research on ${vendorName}. This typically takes 15-30 minutes...`, + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: `\ud83d\udd0d *Starting deep research on ${vendorName}*\nThis typically takes 15-30 minutes. Results will be posted in this thread.`, + }, + }, + ], + }); + + return response.ts ?? ""; + } + + // ── Digest Queue ─────────────────────────────────────────────────────── + + queueForDigest(assessment: RiskAssessment, vendor: Vendor): void { + this.digestQueue.push({ assessment, vendor }); + this.log.debug( + "[slack] Queued %s for digest (queue size: %d)", + vendor.vendor_name, + this.digestQueue.length, + ); + } + + async flushDigest(): Promise { + if (this.digestQueue.length === 0) { + this.log.debug("[slack] Digest queue empty, nothing to flush"); + return null; + } + + const assessments = this.digestQueue.map((e) => e.assessment); + const today = new Date().toISOString().slice(0, 10); + + this.log.debug( + "[slack] Flushing digest with %d assessments", + assessments.length, + ); + + const message = this.formatter.formatDailyDigest(assessments, today); + const response = await this.sendAlert(message); + + this.digestQueue = []; + return response; + } + + getDigestQueueSize(): number { + return this.digestQueue.length; + } + + // ── Private: Rate-Limited Queue ──────────────────────────────────────── + + private enqueue(fn: () => Promise): Promise { + return new Promise((resolve, reject) => { + this.sendQueue.push({ fn, resolve, reject }); + if (!this.processing) { + this.processQueue(); + } + }); + } + + private async processQueue(): Promise { + this.processing = true; + + while (this.sendQueue.length > 0) { + const item = this.sendQueue.shift()!; + try { + const result = await item.fn(); + item.resolve(result); + } catch (err) { + item.reject(err); + } + + if (this.sendQueue.length > 0) { + await this.sleep(1000); + } + } + + this.processing = false; + } + + private async postToSlack(message: SlackMessage): Promise { + this.log.debug("[slack] POST to %s channel=%s", this.webhookUrl, message.channel); + + const body: Record = { + channel: message.channel, + text: message.text, + blocks: message.blocks, + }; + + if (message.thread_ts) { + body.thread_ts = message.thread_ts; + } + + const response = await axios.post(this.webhookUrl, body); + const data = response.data; + + if (typeof data === "string" && data === "ok") { + return { ok: true }; + } + + return { + ok: data?.ok ?? true, + ts: data?.ts, + error: data?.error, + }; + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/typescript-recipes/parallel-n8n-procurement/src/services/slack-formatter.ts b/typescript-recipes/parallel-n8n-procurement/src/services/slack-formatter.ts new file mode 100644 index 0000000..9ef4a70 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/src/services/slack-formatter.ts @@ -0,0 +1,265 @@ +import type { RiskTier } from "../models/vendor.js"; +import type { Vendor } from "../models/vendor.js"; +import type { + RiskAssessment, + AdverseEvent, + MonitorEventOutput, +} from "../models/risk-assessment.js"; +import type { SlackBlock, SlackMessage } from "../models/slack.js"; + +// ── Constants ────────────────────────────────────────────────────────────── + +const MAX_TEXT_LENGTH = 2000; + +const EMOJI: Record = { + CRITICAL: "\ud83d\udd34", + HIGH: "\ud83d\udfe0", + MEDIUM: "\ud83d\udfe1", + LOW: "\ud83d\udfe2", +}; + +const DEFAULT_CHANNELS = { + critical: "#procurement-critical", + alert: "#procurement-alerts", + digest: "#procurement-digest", +}; + +// ── Options ──────────────────────────────────────────────────────────────── + +export interface SlackFormatterOptions { + channels?: { + critical?: string; + alert?: string; + digest?: string; + }; +} + +// ── Formatter ────────────────────────────────────────────────────────────── + +export class SlackFormatter { + private readonly channels: { critical: string; alert: string; digest: string }; + + constructor(options?: SlackFormatterOptions) { + this.channels = { + critical: options?.channels?.critical ?? DEFAULT_CHANNELS.critical, + alert: options?.channels?.alert ?? DEFAULT_CHANNELS.alert, + digest: options?.channels?.digest ?? DEFAULT_CHANNELS.digest, + }; + } + + // ── Routing ────────────────────────────────────────────────────────── + + routeByRiskLevel(riskLevel: RiskTier): string { + switch (riskLevel) { + case "CRITICAL": + return this.channels.critical; + case "HIGH": + return this.channels.alert; + case "MEDIUM": + case "LOW": + default: + return this.channels.digest; + } + } + + // ── Critical / High Alert ──────────────────────────────────────────── + + formatCriticalAlert( + assessment: RiskAssessment, + vendor: Vendor, + findings: AdverseEvent[], + ): SlackMessage { + const emoji = EMOJI[assessment.risk_level]; + const level = assessment.risk_level; + const classification = assessment.adverse_flag ? "ADVERSE" : "MONITORING"; + const reviewHours = level === "CRITICAL" ? "24" : "48"; + + const blocks: SlackBlock[] = [ + headerBlock(`${emoji} ${level} VENDOR RISK ALERT`), + dividerBlock(), + sectionBlock( + `*Vendor:* ${vendor.vendor_name}\n*Risk Level:* ${level}\n*Classification:* ${classification}`, + ), + sectionBlock(truncate(assessment.summary, MAX_TEXT_LENGTH)), + ]; + + if (findings.length > 0) { + const bullets = findings + .map((f) => { + const link = f.source_url ? ` (<${f.source_url}|source>)` : ""; + return `\u2022 *${f.title}* [${f.severity}] — ${f.description}${link}`; + }) + .join("\n"); + blocks.push(sectionBlock(`*Key Findings:*\n${truncate(bullets, MAX_TEXT_LENGTH)}`)); + } + + if (assessment.risk_categories.length > 0) { + blocks.push( + sectionBlock( + `*Risk Categories:* ${assessment.risk_categories.join(", ")}`, + ), + ); + } + + blocks.push(contextBlock(`Research date: ${new Date().toISOString()}`)); + blocks.push(dividerBlock()); + blocks.push( + contextBlock( + `Action Required: Review within ${reviewHours} hours`, + ), + ); + + return { + channel: this.routeByRiskLevel(assessment.risk_level), + text: `${emoji} ${level} risk alert for ${vendor.vendor_name}: ${assessment.summary}`, + blocks, + }; + } + + // ── Daily Digest ───────────────────────────────────────────────────── + + formatDailyDigest( + assessments: RiskAssessment[], + date: string, + ): SlackMessage { + const adverseCount = assessments.filter((a) => a.adverse_flag).length; + const lowCount = assessments.filter((a) => a.risk_level === "LOW").length; + const mediumPlus = assessments.filter((a) => a.risk_level !== "LOW"); + + const blocks: SlackBlock[] = [ + headerBlock(`\ud83d\udcca Daily Vendor Risk Digest \u2014 ${date}`), + sectionBlock( + `*Total Vendors Assessed:* ${assessments.length}\n*Adverse Findings:* ${adverseCount}`, + ), + dividerBlock(), + ]; + + if (mediumPlus.length > 0) { + // Group by risk level + for (const level of ["CRITICAL", "HIGH", "MEDIUM"] as RiskTier[]) { + const group = mediumPlus.filter((a) => a.risk_level === level); + if (group.length === 0) continue; + + const emoji = EMOJI[level]; + const lines = group + .map( + (a) => + `${emoji} *${a.risk_categories[0] ?? "vendor"}*: ${truncate(a.summary, 200)}`, + ) + .join("\n"); + blocks.push(sectionBlock(lines)); + } + } + + if (lowCount > 0) { + blocks.push( + contextBlock( + `${EMOJI.LOW} ${lowCount} vendor${lowCount > 1 ? "s" : ""} assessed with no significant findings`, + ), + ); + } + + return { + channel: this.channels.digest, + text: `Daily vendor risk digest for ${date}: ${assessments.length} vendors assessed, ${adverseCount} adverse findings`, + blocks, + }; + } + + // ── Ad-Hoc Research Result ─────────────────────────────────────────── + + formatAdHocResult( + assessment: RiskAssessment, + vendor: Vendor, + requestedBy: string, + ): SlackMessage { + const emoji = EMOJI[assessment.risk_level]; + const counts = assessment.severity_counts; + + const blocks: SlackBlock[] = [ + headerBlock(`\ud83d\udd0d Ad-Hoc Research Result \u2014 ${vendor.vendor_name}`), + sectionBlock( + `*Requested by:* ${requestedBy}\n*Risk Level:* ${emoji} ${assessment.risk_level}\n*Recommendation:* ${assessment.recommendation}`, + ), + dividerBlock(), + sectionBlock(truncate(assessment.summary, MAX_TEXT_LENGTH)), + sectionBlock( + `*Risk Categories:* ${assessment.risk_categories.length > 0 ? assessment.risk_categories.join(", ") : "None"}\n*Severity Breakdown:* ${counts.critical} critical, ${counts.high} high, ${counts.medium} medium, ${counts.low} low`, + ), + ]; + + if (assessment.action_required) { + blocks.push( + sectionBlock( + `\u26a0\ufe0f *Action Required:* This assessment requires immediate attention.`, + ), + ); + } + + blocks.push(contextBlock(`Completed: ${new Date().toISOString()}`)); + + return { + channel: this.routeByRiskLevel(assessment.risk_level), + text: `Ad-hoc research for ${vendor.vendor_name} requested by ${requestedBy}: ${assessment.risk_level} risk`, + blocks, + thread_ts: "pending", + }; + } + + // ── Monitor Alert ──────────────────────────────────────────────────── + + formatMonitorAlert( + assessment: RiskAssessment, + vendor: Vendor, + event: MonitorEventOutput, + ): SlackMessage { + const emoji = EMOJI[assessment.risk_level]; + + const blocks: SlackBlock[] = [ + headerBlock(`${emoji} Monitor Alert \u2014 ${vendor.vendor_name}`), + sectionBlock( + `*Event Type:* ${event.event_type}\n*Severity:* ${event.severity}\n*Adverse:* ${event.adverse ? "Yes" : "No"}`, + ), + sectionBlock(truncate(event.event_summary, MAX_TEXT_LENGTH)), + contextBlock(`Detected: ${new Date().toISOString()}`), + ]; + + return { + channel: this.routeByRiskLevel(assessment.risk_level), + text: `${emoji} Monitor alert for ${vendor.vendor_name}: ${event.event_summary}`, + blocks, + }; + } +} + +// ── Block Kit Builders ───────────────────────────────────────────────────── + +function headerBlock(text: string): SlackBlock { + return { + type: "header", + text: { type: "plain_text", text, emoji: true }, + }; +} + +function dividerBlock(): SlackBlock { + return { type: "divider" }; +} + +function sectionBlock(text: string): SlackBlock { + return { + type: "section", + text: { type: "mrkdwn", text }, + }; +} + +function contextBlock(text: string): SlackBlock { + return { + type: "context", + elements: [{ type: "mrkdwn", text }], + }; +} + +function truncate(text: string, max: number): string { + if (text.length <= max) return text; + return text.slice(0, max - 3) + "..."; +} diff --git a/typescript-recipes/parallel-n8n-procurement/src/services/slack-ops-reporter.ts b/typescript-recipes/parallel-n8n-procurement/src/services/slack-ops-reporter.ts new file mode 100644 index 0000000..df067dc --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/src/services/slack-ops-reporter.ts @@ -0,0 +1,128 @@ +import type { HealthCheckReport } from "../models/health-check.js"; +import type { ResearchRunSummary } from "../models/research-run.js"; +import type { SlackDeliveryService } from "./slack-delivery.js"; + +// ── Options ──────────────────────────────────────────────────────────────── + +export interface SlackOpsReporterOptions { + deliveryService: SlackDeliveryService; + opsChannel?: string; +} + +// ── Reporter ─────────────────────────────────────────────────────────────── + +export class SlackOpsReporter { + private readonly deliveryService: SlackDeliveryService; + private readonly opsChannel: string; + + constructor(options: SlackOpsReporterOptions) { + this.deliveryService = options.deliveryService; + this.opsChannel = options.opsChannel ?? "#vendor-risk-ops"; + } + + async sendHealthReport(report: HealthCheckReport): Promise { + const date = report.timestamp.slice(0, 10); + const webhookStatus = report.webhook_healthy + ? "\u2705 Reachable" + : "\u274c UNREACHABLE"; + + const statsText = [ + `*Total Monitors:* ${report.total_monitors}`, + `*Active:* ${report.active_count} \u2705`, + `*Failed:* ${report.failed_count} \u274c (re-created: ${report.monitors_recreated})`, + `*Orphaned:* ${report.orphan_count} \ud83d\uddd1\ufe0f (deleted: ${report.orphans_deleted})`, + `*Webhook Endpoint:* ${webhookStatus}`, + ].join("\n"); + + const blocks: Record[] = [ + { + type: "header", + text: { + type: "plain_text", + text: `\ud83d\udd27 Monitor Fleet Health Report \u2014 ${date}`, + emoji: true, + }, + }, + { type: "divider" }, + { + type: "section", + text: { type: "mrkdwn", text: statsText }, + }, + ]; + + if (report.errors.length > 0) { + blocks.push({ type: "divider" }); + blocks.push({ + type: "section", + text: { + type: "mrkdwn", + text: `*Errors (${report.errors.length}):*\n${report.errors.map((e) => `\u2022 ${e}`).join("\n")}`, + }, + }); + } + + blocks.push({ + type: "context", + elements: [{ type: "mrkdwn", text: `Health check completed at ${report.timestamp}` }], + }); + + const fallback = `Monitor Fleet Health: ${report.total_monitors} total, ${report.active_count} active, ${report.failed_count} failed, ${report.orphan_count} orphaned`; + + await this.deliveryService.sendAlert({ + channel: this.opsChannel, + text: fallback, + blocks, + }); + } + + async sendRunSummary(summary: ResearchRunSummary): Promise { + const date = new Date().toISOString().slice(0, 10); + const hasFailed = summary.total_failed > 0; + const hasAdverse = summary.adverse_count > 0; + const icon = hasFailed ? "\u26a0\ufe0f" : "\u2705"; + + const statsText = [ + `*Vendors Due:* ${summary.total_due}`, + `*Researched:* ${summary.total_researched}`, + `*Failed:* ${summary.total_failed}${hasFailed ? " \u274c" : ""}`, + `*Adverse Findings:* ${summary.adverse_count}${hasAdverse ? " \u26a0\ufe0f" : ""}`, + `*Batches:* ${summary.batches_executed}`, + `*Duration:* ${(summary.duration_ms / 1000).toFixed(1)}s`, + ].join("\n"); + + const riskText = [ + `CRITICAL: ${summary.risk_counts.CRITICAL}`, + `HIGH: ${summary.risk_counts.HIGH}`, + `MEDIUM: ${summary.risk_counts.MEDIUM}`, + `LOW: ${summary.risk_counts.LOW}`, + ].join(" | "); + + const blocks: Record[] = [ + { + type: "header", + text: { + type: "plain_text", + text: `${icon} Research Run Complete \u2014 ${date}`, + emoji: true, + }, + }, + { type: "divider" }, + { + type: "section", + text: { type: "mrkdwn", text: statsText }, + }, + { + type: "context", + elements: [{ type: "mrkdwn", text: `*Risk Breakdown:* ${riskText}` }], + }, + ]; + + const fallback = `Research Run Complete \u2014 ${date}: ${summary.total_researched}/${summary.total_due} vendors, ${summary.total_failed} failures, ${summary.adverse_count} adverse`; + + await this.deliveryService.sendAlert({ + channel: this.opsChannel, + text: fallback, + blocks, + }); + } +} diff --git a/typescript-recipes/parallel-n8n-procurement/src/services/vendor-ingestion.ts b/typescript-recipes/parallel-n8n-procurement/src/services/vendor-ingestion.ts new file mode 100644 index 0000000..6c175c9 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/src/services/vendor-ingestion.ts @@ -0,0 +1,270 @@ +import axios from "axios"; +import { writeFile } from "node:fs/promises"; +import { VendorSchema, type Vendor } from "../models/vendor.js"; +import type { VendorDiff, DiffResult } from "../models/vendor-diff.js"; +import type { MonitorPortfolioManager } from "./monitor-portfolio-manager.js"; +import { parseCSV } from "../utils/csv-parser.js"; + +// ── Options ──────────────────────────────────────────────────────────────── + +export interface VendorIngestionServiceOptions { + logger?: Pick; +} + +// ── Service ──────────────────────────────────────────────────────────────── + +export class VendorIngestionService { + private readonly log: Pick; + + constructor(options?: VendorIngestionServiceOptions) { + this.log = options?.logger ?? console; + } + + // ── Ingestion ────────────────────────────────────────────────────────── + + async ingestFromGoogleSheets( + sheetId: string, + range: string, + apiKey?: string, + ): Promise { + this.log.debug("[ingestion] Reading Google Sheet %s range %s", sheetId, range); + + const url = `https://sheets.googleapis.com/v4/spreadsheets/${sheetId}/values/${encodeURIComponent(range)}`; + const params: Record = {}; + if (apiKey) params.key = apiKey; + + const response = await axios.get(url, { params }); + const rows: string[][] = response.data.values ?? []; + + if (rows.length <= 1) return []; // header only or empty + + return this.parseRows(rows.slice(1)); + } + + async ingestFromCSV(csvContent: string): Promise { + this.log.debug("[ingestion] Parsing CSV content"); + + const rows = parseCSV(csvContent); + if (rows.length <= 1) return []; // header only or empty + + return this.parseRows(rows.slice(1)); + } + + // ── Deduplication ────────────────────────────────────────────────────── + + deduplicateVendors(vendors: Vendor[]): Vendor[] { + const byDomain = new Map(); + for (const v of vendors) { + byDomain.set(v.vendor_domain, v); + } + return [...byDomain.values()]; + } + + // ── Diff Engine ──────────────────────────────────────────────────────── + + computeDiff(incoming: Vendor[], previous: Vendor[]): VendorDiff { + const incomingMap = new Map(incoming.map((v) => [v.vendor_domain, v])); + const previousMap = new Map(previous.map((v) => [v.vendor_domain, v])); + + const added: Vendor[] = []; + const modified: VendorDiff["modified"] = []; + const unchanged: Vendor[] = []; + + for (const [domain, vendor] of incomingMap) { + const prev = previousMap.get(domain); + if (!prev) { + added.push(vendor); + } else { + const changes: string[] = []; + if (vendor.monitoring_priority !== prev.monitoring_priority) { + changes.push("monitoring_priority"); + } + if (vendor.vendor_category !== prev.vendor_category) { + changes.push("vendor_category"); + } + if (vendor.risk_tier_override !== prev.risk_tier_override) { + changes.push("risk_tier_override"); + } + if (vendor.active !== prev.active) { + changes.push("active"); + } + + if (changes.length > 0) { + modified.push({ vendor, previous: prev, changes }); + } else { + unchanged.push(vendor); + } + } + } + + const removed: Vendor[] = []; + for (const [domain, vendor] of previousMap) { + if (!incomingMap.has(domain)) { + removed.push(vendor); + } + } + + return { added, removed, unchanged, modified }; + } + + // ── Apply Diff ───────────────────────────────────────────────────────── + + async applyDiff( + diff: VendorDiff, + portfolioManager: MonitorPortfolioManager, + ): Promise { + const monitorsCreated = new Map(); + const monitorsDeleted: string[] = []; + const monitorsAdjusted: string[] = []; + const errors: Array<{ vendor_domain: string; error: string }> = []; + + // Deploy monitors for added vendors + if (diff.added.length > 0) { + try { + const created = await portfolioManager.deployMonitors(diff.added); + for (const [domain, ids] of created) { + monitorsCreated.set(domain, ids); + } + } catch (err) { + for (const v of diff.added) { + errors.push({ + vendor_domain: v.vendor_domain, + error: `Failed to deploy monitors: ${(err as Error).message}`, + }); + } + } + } + + // Remove monitors for removed vendors + for (const vendor of diff.removed) { + if (vendor.monitor_ids && vendor.monitor_ids.length > 0) { + try { + await portfolioManager.removeMonitors(vendor.monitor_ids); + monitorsDeleted.push(...vendor.monitor_ids); + } catch (err) { + errors.push({ + vendor_domain: vendor.vendor_domain, + error: `Failed to remove monitors: ${(err as Error).message}`, + }); + } + } + } + + // Handle modified vendors (priority changes need monitor adjustment) + for (const mod of diff.modified) { + if (mod.changes.includes("monitoring_priority")) { + try { + // Remove old monitors + if (mod.previous.monitor_ids && mod.previous.monitor_ids.length > 0) { + await portfolioManager.removeMonitors(mod.previous.monitor_ids); + monitorsDeleted.push(...mod.previous.monitor_ids); + } + // Deploy new monitors with updated priority + const created = await portfolioManager.deployMonitors([mod.vendor]); + for (const [domain, ids] of created) { + monitorsCreated.set(domain, ids); + } + monitorsAdjusted.push(mod.vendor.vendor_domain); + } catch (err) { + errors.push({ + vendor_domain: mod.vendor.vendor_domain, + error: `Failed to adjust monitors: ${(err as Error).message}`, + }); + } + } + } + + return { + monitors_created: monitorsCreated, + monitors_deleted: monitorsDeleted, + monitors_adjusted: monitorsAdjusted, + errors, + }; + } + + // ── Registry Persistence ─────────────────────────────────────────────── + + async updateRegistry( + vendors: Vendor[], + monitorMapping: Map, + outputPath?: string, + ): Promise { + const now = new Date().toISOString(); + + const updated = vendors.map((v) => ({ + ...v, + monitor_ids: monitorMapping.get(v.vendor_domain) ?? v.monitor_ids ?? [], + last_synced_at: now, + })); + + const registry = { + vendors: updated, + last_sync_timestamp: now, + total_count: updated.length, + }; + + const path = outputPath ?? "vendor-registry.json"; + await writeFile(path, JSON.stringify(registry, null, 2)); + + this.log.debug( + "[ingestion] Registry updated: %d vendors written to %s", + updated.length, + path, + ); + } + + // ── Private: Row Parsing ─────────────────────────────────────────────── + + private parseRows(rows: string[][]): Vendor[] { + const vendors: Vendor[] = []; + + for (const row of rows) { + if (row.length < 3) continue; // Need at least name, domain, category + + try { + const vendor = VendorSchema.parse(this.rowToObject(row)); + vendors.push(vendor); + } catch (err) { + this.log.warn( + "[ingestion] Skipping invalid row: %s — %s", + row[0] ?? "unknown", + (err as Error).message, + ); + } + } + + return vendors; + } + + private rowToObject(row: string[]): Record { + const [ + vendor_name = "", + vendor_domain = "", + vendor_category = "", + risk_tier_override = "", + active = "", + monitoring_priority = "", + ] = row; + + // Normalize domain + let domain = vendor_domain.trim(); + if (domain && !domain.startsWith("http://") && !domain.startsWith("https://")) { + domain = `https://${domain}`; + } + + return { + vendor_name: vendor_name.trim(), + vendor_domain: domain, + vendor_category: vendor_category.trim().toLowerCase(), + risk_tier_override: risk_tier_override.trim() || undefined, + active: this.parseBoolean(active.trim()), + monitoring_priority: monitoring_priority.trim().toLowerCase() || "medium", + }; + } + + private parseBoolean(value: string): boolean { + if (!value) return true; // default active + const lower = value.toLowerCase(); + return lower !== "false" && lower !== "0" && lower !== "no"; + } +} diff --git a/typescript-recipes/parallel-n8n-procurement/src/utils/csv-parser.ts b/typescript-recipes/parallel-n8n-procurement/src/utils/csv-parser.ts new file mode 100644 index 0000000..5077287 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/src/utils/csv-parser.ts @@ -0,0 +1,71 @@ +/** + * Simple CSV parser handling quoted fields, embedded commas, escaped quotes, and BOM. + * Returns array of row arrays (including header row). + */ +export function parseCSV(content: string): string[][] { + // Strip BOM + const cleaned = content.replace(/^\uFEFF/, ""); + + const rows: string[][] = []; + let current: string[] = []; + let field = ""; + let inQuotes = false; + let i = 0; + + while (i < cleaned.length) { + const ch = cleaned[i]; + + if (inQuotes) { + if (ch === '"') { + // Escaped quote "" + if (i + 1 < cleaned.length && cleaned[i + 1] === '"') { + field += '"'; + i += 2; + } else { + // End of quoted field + inQuotes = false; + i++; + } + } else { + field += ch; + i++; + } + } else { + if (ch === '"') { + inQuotes = true; + i++; + } else if (ch === ",") { + current.push(field); + field = ""; + i++; + } else if (ch === "\r") { + // Handle \r\n and bare \r + current.push(field); + field = ""; + rows.push(current); + current = []; + i++; + if (i < cleaned.length && cleaned[i] === "\n") { + i++; + } + } else if (ch === "\n") { + current.push(field); + field = ""; + rows.push(current); + current = []; + i++; + } else { + field += ch; + i++; + } + } + } + + // Push last field/row + if (field.length > 0 || current.length > 0) { + current.push(field); + rows.push(current); + } + + return rows; +} diff --git a/typescript-recipes/parallel-n8n-procurement/src/workflows/generate-all.ts b/typescript-recipes/parallel-n8n-procurement/src/workflows/generate-all.ts new file mode 100644 index 0000000..bec6445 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/src/workflows/generate-all.ts @@ -0,0 +1,33 @@ +import { writeFile, mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { generateVendorSyncWorkflow } from "./generators/workflow1-vendor-sync.js"; +import { generateDeepResearchWorkflow } from "./generators/workflow2-deep-research.js"; +import { generateRiskScoringWorkflow } from "./generators/workflow3-risk-scoring.js"; +import { generateMonitorWorkflow } from "./generators/workflow4-monitors.js"; +import { generateAdHocWorkflow } from "./generators/workflow5-adhoc.js"; +import { generateCombinedWorkflow } from "./generators/workflow-combined.js"; + +const workflows = [ + { name: "workflow1-vendor-sync.json", generate: generateVendorSyncWorkflow }, + { name: "workflow2-deep-research.json", generate: generateDeepResearchWorkflow }, + { name: "workflow3-risk-scoring.json", generate: generateRiskScoringWorkflow }, + { name: "workflow4-monitors.json", generate: generateMonitorWorkflow }, + { name: "workflow5-adhoc.json", generate: generateAdHocWorkflow }, + { name: "workflow-combined.json", generate: generateCombinedWorkflow }, +]; + +async function main() { + const outputDir = process.argv[2] || join(import.meta.dirname ?? ".", "output"); + await mkdir(outputDir, { recursive: true }); + + for (const wf of workflows) { + const json = wf.generate(); + const path = join(outputDir, wf.name); + await writeFile(path, JSON.stringify(json, null, 2)); + console.log(`Generated: ${path}`); + } + + console.log(`\nAll ${workflows.length} workflows generated in ${outputDir}`); +} + +main().catch(console.error); diff --git a/typescript-recipes/parallel-n8n-procurement/src/workflows/generator-utils.ts b/typescript-recipes/parallel-n8n-procurement/src/workflows/generator-utils.ts new file mode 100644 index 0000000..dfd9007 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/src/workflows/generator-utils.ts @@ -0,0 +1,488 @@ +// ── Types ────────────────────────────────────────────────────────────────── + +export interface N8nNode { + id: string; + name: string; + type: string; + position: [number, number]; + typeVersion: number; + parameters: Record; + notes?: string; + credentials?: Record; +} + +export interface N8nConnection { + node: string; + type: string; + index: number; +} + +export interface N8nWorkflow { + name: string; + nodes: N8nNode[]; + connections: Record; + settings: { executionOrder: string }; + tags: string[]; +} + +// ── Position Helper ──────────────────────────────────────────────────────── + +let nodeCounter = 0; + +export function resetNodeCounter(): void { + nodeCounter = 0; +} + +export function nextId(): string { + return `node-${++nodeCounter}`; +} + +export function pos(col: number, row: number = 0): [number, number] { + return [col * 240 + 100, row * 200 + 300]; +} + +// ── Node Builders ────────────────────────────────────────────────────────── + +export function createNode( + name: string, + type: string, + position: [number, number], + parameters: Record, + typeVersion: number = 1, + notes?: string, +): N8nNode { + return { + id: nextId(), + name, + type: `n8n-nodes-base.${type}`, + position, + typeVersion, + parameters, + ...(notes ? { notes } : {}), + }; +} + +export function scheduleNode( + name: string, + hour: number, + position: [number, number], +): N8nNode { + return createNode(name, "scheduleTrigger", position, { + rule: { + interval: [{ field: "hours", hoursInterval: 24, triggerAtHour: hour }], + }, + }, 1.2); +} + +export function manualTriggerNode( + name: string, + position: [number, number], +): N8nNode { + return createNode(name, "manualTrigger", position, {}, 1); +} + +export function httpRequestNode( + name: string, + method: string, + url: string, + position: [number, number], + body?: string, + notes?: string, +): N8nNode { + const params: Record = { + method, + url, + authentication: "genericCredentialType", + genericAuthType: "httpHeaderAuth", + sendHeaders: true, + headerParameters: { + parameters: [{ name: "x-api-key", value: "={{ $vars.PARALLEL_API_KEY }}" }], + }, + }; + if (body) { + params.sendBody = true; + params.specifyBody = "json"; + params.jsonBody = body; + } + return createNode(name, "httpRequest", position, params, 4.2, notes); +} + +export function codeNode( + name: string, + jsCode: string, + position: [number, number], +): N8nNode { + return createNode(name, "code", position, { mode: "runOnceForAllItems", jsCode }, 2); +} + +export function googleSheetsNode( + name: string, + operation: "read" | "append" | "update", + sheetName: string, + position: [number, number], +): N8nNode { + const n8nOp = operation === "read" ? "read" + : operation === "append" ? "appendOrUpdate" + : "appendOrUpdate"; + const params: Record = { + operation: n8nOp, + documentId: { __rl: true, mode: "id", value: "={{ $vars.GOOGLE_SHEET_ID }}" }, + sheetName: { __rl: true, mode: "name", value: sheetName }, + options: {}, + }; + if (operation === "append" || operation === "update") { + params.columns = { mappingMode: "autoMapInputData" }; + } + return createNode(name, "googleSheets", position, params, 4.5); +} + +export function slackNode( + name: string, + channel: string, + position: [number, number], + textExpr: string = "={{ $json.text }}", +): N8nNode { + return createNode(name, "slack", position, { + resource: "message", + operation: "post", + channel: { __rl: true, mode: "name", value: channel }, + text: textExpr, + otherOptions: {}, + }, 2.2); +} + +export function webhookNode( + name: string, + path: string, + position: [number, number], +): N8nNode { + return createNode(name, "webhook", position, { + path, + httpMethod: "POST", + responseMode: "onReceived", + }, 2); +} + +export function waitNode( + name: string, + seconds: number, + position: [number, number], +): N8nNode { + return createNode(name, "wait", position, { + amount: seconds, + unit: "seconds", + }, 1.1); +} + +export function ifNode( + name: string, + leftValue: string, + rightValue: string, + position: [number, number], +): N8nNode { + return createNode(name, "if", position, { + conditions: { + options: { caseSensitive: true, leftValue: "", typeValidation: "strict" }, + conditions: [{ + leftValue, + rightValue, + operator: { type: "string", operation: "equals" }, + id: "condition-0", + }], + combinator: "and", + }, + }, 2); +} + +export function splitInBatchesNode( + name: string, + batchSize: number, + position: [number, number], +): N8nNode { + return createNode(name, "splitInBatches", position, { + batchSize, + options: {}, + }, 3); +} + +export function switchNode( + name: string, + routingField: string, + routes: string[], + position: [number, number], +): N8nNode { + return createNode(name, "switch", position, { + rules: { + values: routes.map((value) => ({ + conditions: { + options: { caseSensitive: true, leftValue: "", typeValidation: "strict" }, + conditions: [{ + leftValue: routingField, + rightValue: value, + operator: { type: "string", operation: "equals" }, + }], + combinator: "and", + }, + renameOutput: true, + outputKey: value, + })), + }, + options: { + fallbackOutput: "extra", + }, + }, 3.2); +} + +export function executeWorkflowNode( + name: string, + position: [number, number], +): N8nNode { + return createNode(name, "executeWorkflow", position, { + source: "parameter", + workflowId: "", + }, 1); +} + +export function executeWorkflowTriggerNode( + name: string, + position: [number, number], +): N8nNode { + return createNode(name, "executeWorkflowTrigger", position, {}, 1); +} + +// ── Parallel AI Native Node Builders ────────────────────────────────────── + +const PARALLEL_CREDENTIAL = { parallelApi: { id: "", name: "Parallel API" } }; + +export interface ParallelMonitorOptions { + query: string; + cadence: "hourly" | "daily" | "weekly" | "every_two_weeks"; + webhookUrl?: string; + outputSchemaType?: "text" | "json"; + outputJsonSchema?: string; + metadata?: Array<{ key: string; value: string }>; +} + +export function parallelCreateMonitorNode( + name: string, + options: ParallelMonitorOptions | string, + position: [number, number], +): N8nNode { + const isExpression = typeof options === "string"; + const params: Record = { + resource: "monitor", + monitorOperation: "createMonitor", + monitorQuery: isExpression ? options : options.query, + monitorCadence: isExpression ? "daily" : options.cadence, + }; + if (!isExpression) { + if (options.webhookUrl) { + params.monitorWebhookUrl = options.webhookUrl; + params.monitorWebhookEventTypes = ["monitor.event.detected"]; + } + if (options.outputSchemaType === "json" && options.outputJsonSchema) { + params.monitorOutputSchemaType = "json"; + params.monitorOutputJsonSchema = options.outputJsonSchema; + } + if (options.metadata && options.metadata.length > 0) { + params.monitorAdditionalFields = { + metadata: { metadataFields: options.metadata }, + }; + } + } + return { + id: nextId(), + name, + type: "n8n-nodes-parallel.parallel", + position, + typeVersion: 1, + parameters: params, + credentials: PARALLEL_CREDENTIAL, + }; +} + +export function parallelDeleteMonitorNode( + name: string, + monitorIdExpr: string, + position: [number, number], +): N8nNode { + return { + id: nextId(), + name, + type: "n8n-nodes-parallel.parallel", + position, + typeVersion: 1, + parameters: { + resource: "monitor", + monitorOperation: "deleteMonitor", + monitorId: monitorIdExpr, + }, + credentials: PARALLEL_CREDENTIAL, + }; +} + +export function parallelGetEventGroupNode( + name: string, + monitorIdExpr: string, + eventGroupIdExpr: string, + position: [number, number], +): N8nNode { + return { + id: nextId(), + name, + type: "n8n-nodes-parallel.parallel", + position, + typeVersion: 1, + parameters: { + resource: "monitor", + monitorOperation: "getMonitorEventGroup", + monitorId: monitorIdExpr, + eventGroupId: eventGroupIdExpr, + }, + credentials: PARALLEL_CREDENTIAL, + }; +} + +export function parallelListMonitorsNode( + name: string, + position: [number, number], +): N8nNode { + return { + id: nextId(), + name, + type: "n8n-nodes-parallel.parallel", + position, + typeVersion: 1, + parameters: { + resource: "monitor", + monitorOperation: "listMonitors", + }, + credentials: PARALLEL_CREDENTIAL, + }; +} + +export interface ParallelAsyncEnrichmentOptions { + inputExpr: string; + processor?: string; + outputSchemaType?: "text" | "json" | "auto"; + outputJsonSchema?: string; + webhookUrl?: string; +} + +export function parallelAsyncEnrichmentNode( + name: string, + options: ParallelAsyncEnrichmentOptions, + position: [number, number], + notes?: string, +): N8nNode { + const params: Record = { + resource: "task", + operation: "asyncWebEnrichment", + inputType: "text", + textInput: options.inputExpr, + asyncProcessor: options.processor ?? "ultra8x", + }; + if (options.outputSchemaType === "json" && options.outputJsonSchema) { + params.asyncOutputSchemaType = "json"; + params.asyncOutputJsonSchema = options.outputJsonSchema; + } else { + params.asyncOutputSchemaType = options.outputSchemaType ?? "text"; + } + if (options.webhookUrl) { + params.webhookUrl = options.webhookUrl; + } + return { + id: nextId(), + name, + type: "n8n-nodes-parallel.parallel", + position, + typeVersion: 1, + parameters: params, + credentials: PARALLEL_CREDENTIAL, + ...(notes ? { notes } : {}), + }; +} + +export function parallelMonitorTriggerNode( + name: string, + position: [number, number], + fetchEventGroup: boolean = true, +): N8nNode { + return { + id: nextId(), + name, + type: "n8n-nodes-parallel.parallelMonitorTrigger", + position, + typeVersion: 1, + parameters: { + eventTypeFilter: ["monitor.event.detected"], + fetchEventGroup, + validateSignatures: false, + includeWebhookData: false, + }, + credentials: PARALLEL_CREDENTIAL, + }; +} + +export function parallelTaskTriggerNode( + name: string, + position: [number, number], +): N8nNode { + return { + id: nextId(), + name, + type: "n8n-nodes-parallel.parallelTrigger", + position, + typeVersion: 1, + parameters: { + onlyCompleted: true, + validateSignatures: false, + includeWebhookData: false, + }, + credentials: PARALLEL_CREDENTIAL, + }; +} + +// ── Connection Builders ──────────────────────────────────────────────────── + +export function connect( + fromName: string, + toName: string, + fromOutput: number = 0, +): { from: string; to: string; output: number } { + return { from: fromName, to: toName, output: fromOutput }; +} + +export function buildConnections( + pairs: Array<{ from: string; to: string; output: number }>, +): Record { + const conns: Record = {}; + + for (const { from, to, output } of pairs) { + if (!conns[from]) { + conns[from] = { main: [] }; + } + while (conns[from].main.length <= output) { + conns[from].main.push([]); + } + conns[from].main[output].push({ node: to, type: "main", index: 0 }); + } + + return conns; +} + +// ── Workflow Builder ─────────────────────────────────────────────────────── + +export function buildWorkflow( + name: string, + nodes: N8nNode[], + connections: Record, +): N8nWorkflow { + return { + name, + nodes, + connections, + settings: { executionOrder: "v1" }, + tags: ["n8n-procurement", "vendor-risk"], + }; +} diff --git a/typescript-recipes/parallel-n8n-procurement/src/workflows/generators/workflow-combined.ts b/typescript-recipes/parallel-n8n-procurement/src/workflows/generators/workflow-combined.ts new file mode 100644 index 0000000..6c370a9 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/src/workflows/generators/workflow-combined.ts @@ -0,0 +1,1154 @@ +import { + resetNodeCounter, pos, scheduleNode, manualTriggerNode, + googleSheetsNode, codeNode, httpRequestNode, splitInBatchesNode, + waitNode, ifNode, switchNode, slackNode, webhookNode, + connect, buildConnections, buildWorkflow, + type N8nWorkflow, +} from "../generator-utils.js"; + +// ── Combined Workflow Generator ───────────────────────────────────────────── +// Merges all 5 workflows into a single importable n8n workflow. +// Eliminates all executeWorkflow / executeWorkflowTrigger nodes. +// WF3 (Risk Scoring) is shared via fan-in from Research, Monitor Events, +// and Ad-Hoc flows. A "Route Back" switch after the Audit Log directs +// data to the correct per-flow continuation using the `source` field. + +export function generateCombinedWorkflow(): N8nWorkflow { + resetNodeCounter(); + + const nodes = [ + // ── REGION 1: SYNC (WF1) ──────────────────────────────────────────── + scheduleNode("Sync: Daily Midnight Trigger", 0, pos(0, -3)), + manualTriggerNode("Sync: Manual Trigger", pos(0, -2)), + googleSheetsNode("Sync: Read Vendor List", "read", "Vendors", pos(1, -3)), + googleSheetsNode("Sync: Read Previous Registry", "read", "Registry", pos(2, -3)), + codeNode("Sync: Compute Diff", SYNC_DIFF_CODE, pos(3, -3)), + splitInBatchesNode("Sync: Loop Added Vendors", 1, pos(4, -4)), + codeNode("Sync: Build Monitor Payload", SYNC_MONITOR_PAYLOAD_CODE, pos(5, -4)), + httpRequestNode( + "Sync: Create Monitor", + "POST", + "https://api.parallel.ai/v1alpha/monitors", + pos(6, -4), + `={{ + JSON.stringify({ + query: $json.monitorPayload?.query || ('"' + ($json.vendor_name || 'Unknown Vendor') + '" vendor risk'), + cadence: $json.monitorPayload?.cadence || 'daily', + webhook: { + url: ($vars.N8N_WEBHOOK_BASE_URL || '') + '/webhook/parallel-monitor-event', + event_types: ['monitor.event.detected'], + }, + metadata: $json.monitorPayload?.metadata || { + vendor_name: $json.vendor_name || 'Unknown Vendor', + vendor_domain: $json.vendor_domain || '', + monitor_category: 'General', + risk_dimension: 'general', + }, + }) + }}`, + ), + splitInBatchesNode("Sync: Loop Removed Vendors", 1, pos(4, -2)), + httpRequestNode( + "Sync: Delete Monitor", + "DELETE", + "={{ 'https://api.parallel.ai/v1alpha/monitors/' + ($json.monitor_id || $json.id || '') }}", + pos(5, -2), + ), + googleSheetsNode("Sync: Update Registry", "update", "Registry", pos(7, -3)), + + // ── REGION 2: RESEARCH (WF2 — uses Parallel async enrichment per vendor) ─ + scheduleNode("Research: Daily 6AM Trigger", 6, pos(0, 0)), + manualTriggerNode("Research: Manual Trigger", pos(0, 1)), + googleSheetsNode("Research: Read Registry", "read", "Registry", pos(1, 0)), + codeNode("Research: Filter Due Vendors", RESEARCH_FILTER_CODE, pos(2, 0)), + codeNode("Research: Build Prompts", RESEARCH_BUILD_PROMPTS_CODE, pos(3, 0)), + splitInBatchesNode("Research: Loop Vendors", 1, pos(4, 0)), + httpRequestNode( + "Research: Run Deep Research", + "POST", + "https://api.parallel.ai/v1/tasks/runs", + pos(5, 0), + `={{ + JSON.stringify({ + input: $json.prompt || ('Conduct a vendor risk assessment of ' + ($json.vendor_name || 'Unknown Vendor')), + processor: 'ultra8x', + task_spec: { output_schema: JSON.parse($json.outputSchema || '{}') }, + }) + }}`, + ), + waitNode("Research: Wait 90s", 90, pos(6, 0)), + codeNode("Research: Collect Results", RESEARCH_COLLECT_CODE, pos(7, 0)), + + // ── REGION 3: MONITOR DEPLOY (WF4 deploy sub-flow) ────────────────── + webhookNode("Monitor: Deploy Webhook", "/webhook/deploy-monitors", pos(0, 3)), + codeNode("Monitor: Generate Queries", MONITOR_QUERY_GEN_CODE, pos(1, 3)), + splitInBatchesNode("Monitor: Loop Monitors", 1, pos(2, 3)), + httpRequestNode( + "Monitor: Create Monitor", + "POST", + "https://api.parallel.ai/v1alpha/monitors", + pos(3, 3), + `={{ + JSON.stringify({ + query: $json.monitorPayload?.query || ('"' + ($json.vendor_name || 'Unknown Vendor') + '" vendor risk'), + cadence: $json.monitorPayload?.cadence || 'daily', + webhook: { + url: ($vars.N8N_WEBHOOK_BASE_URL || '') + '/webhook/parallel-monitor-event', + event_types: ['monitor.event.detected'], + }, + metadata: $json.monitorPayload?.metadata || { + vendor_name: $json.vendor_name || 'Unknown Vendor', + vendor_domain: $json.vendor_domain || '', + monitor_category: 'General', + risk_dimension: 'general', + }, + output_schema: $json.monitorPayload?.output_schema || { + type: 'json', + json_schema: { + type: 'object', + properties: { + event_summary: { type: 'string' }, + severity: { type: 'string' }, + adverse: { type: 'boolean' }, + event_type: { type: 'string' }, + }, + required: ['event_summary', 'severity', 'adverse', 'event_type'], + }, + }, + }) + }}`, + ), + googleSheetsNode("Monitor: Record Monitor IDs", "append", "Monitors", pos(4, 3)), + + // ── REGION 4: MONITOR EVENTS (WF4 event sub-flow) ─────────────────── + webhookNode("Monitor: Event Trigger", "/webhook/parallel-monitor-event", pos(0, 5)), + codeNode("Monitor: Enrich & Classify Event", MONITOR_ENRICH_NATIVE_CODE, pos(1, 5)), + + // ── REGION 5: AD-HOC (WF5 both sub-flows) ────────────────────────── + webhookNode("AdHoc: Slack Command", "/webhook/slack-command", pos(0, 7)), + codeNode("AdHoc: Parse Command", ADHOC_PARSE_CMD_CODE, pos(1, 7)), + slackNode("AdHoc: Send Acknowledgment", "={{ $json.channel_id }}", pos(2, 7), + '={{ "\\ud83d\\udd0d Starting deep research on *" + $json.vendor_name + "*. This typically takes 15-30 minutes..." }}'), + httpRequestNode( + "AdHoc: Start Research Task", + "POST", + "https://api.parallel.ai/v1/tasks/runs", + pos(3, 7), + `={{ + JSON.stringify({ + input: $json.prompt || ('Conduct a vendor risk assessment of ' + ($json.vendor_name || 'Unknown Vendor')), + processor: 'ultra8x', + task_spec: { output_schema: JSON.parse($json.outputSchema || '{}') }, + webhook: { + url: $json.webhookUrl || (($vars.N8N_WEBHOOK_BASE_URL || '') + '/webhook/parallel-task-completion'), + events: ['task_run.status'], + }, + }) + }}`, + "Creates a single deep research run with webhook callback", + ), + webhookNode("AdHoc: Result Callback", "/webhook/parallel-task-completion", pos(0, 9)), + codeNode("AdHoc: Tag Source", ADHOC_TAG_SOURCE_CODE, pos(1, 9)), + + // ── REGION 6: SHARED SCORING CHAIN (WF3 inlined + route back) ────── + codeNode("Scoring: Risk Scorer", SCORING_CODE, pos(5, 12)), + switchNode("Scoring: Route by Risk Level", "={{ $json.risk_level }}", ["CRITICAL", "HIGH", "MEDIUM", "LOW"], pos(6, 12)), + slackNode("Scoring: Alert Critical", "={{ $vars.SLACK_ALERT_TARGET || '#procurement-critical' }}", pos(7, 10), + '={{ "\\ud83d\\udd34 CRITICAL: " + $json.vendor_name + " — " + $json.summary }}'), + slackNode("Scoring: Alert High", "={{ $vars.SLACK_ALERT_TARGET || '#procurement-critical' }}", pos(7, 11), + '={{ "\\ud83d\\udfe0 HIGH: " + $json.vendor_name + " — " + $json.summary }}'), + codeNode("Scoring: Format Digest", SCORING_DIGEST_CODE, pos(7, 12)), + codeNode("Scoring: Log Low", 'return [$input.first()];', pos(7, 13)), + googleSheetsNode("Scoring: Audit Log", "append", "Audit Log", pos(8, 12)), + switchNode("Scoring: Route Back", "={{ $json.source }}", ["deep_research", "adhoc", "monitor_event"], pos(9, 12)), + googleSheetsNode("Research: Update Research Dates", "update", "Registry", pos(10, 11)), + slackNode("AdHoc: Post Thread Reply", "={{ $json.channel_id }}", pos(10, 13), + '={{ $json.text }}'), + + // ── REGION 7: DASHBOARD SNAPSHOT (frontend data endpoint) ──────────── + { + id: "snapshot-webhook-1", + name: "Snapshot: Dashboard Webhook", + type: "n8n-nodes-base.webhook", + position: [100, 3300] as [number, number], + typeVersion: 2, + parameters: { + path: "procurement-dashboard-snapshot", + httpMethod: "GET", + responseMode: "lastNode", + options: {}, + }, + }, + googleSheetsNode("Snapshot: Read Registry", "read", "Registry", [340, 3200]), + googleSheetsNode("Snapshot: Read Audit Log", "read", "Audit Log", [580, 3200]), + googleSheetsNode("Snapshot: Read Monitors", "read", "Monitors", [820, 3200]), + codeNode("Snapshot: Build Payload", SNAPSHOT_BUILD_PAYLOAD_CODE, [1060, 3200]), + + // ── REGION 8: PORTFOLIO MUTATIONS (dashboard write-back) ─────────── + { + id: "portfolio-mutation-webhook-1", + name: "Portfolio: Mutation Webhook", + type: "n8n-nodes-base.webhook", + position: [100, 3600] as [number, number], + typeVersion: 2, + parameters: { + path: "procurement-portfolio-mutation", + httpMethod: "POST", + responseMode: "lastNode", + options: {}, + }, + }, + googleSheetsNode("Portfolio: Read Vendors", "read", "Vendors", [340, 3600]), + googleSheetsNode("Portfolio: Read Registry", "read", "Registry", [580, 3600]), + codeNode("Portfolio: Build Vendor Rows", PORTFOLIO_BUILD_VENDOR_ROWS_CODE, [820, 3600]), + googleSheetsNode("Portfolio: Write Vendors", "update", "Vendors", [1060, 3600]), + codeNode("Portfolio: Build Registry Rows", PORTFOLIO_BUILD_REGISTRY_ROWS_CODE, [1300, 3600]), + googleSheetsNode("Portfolio: Write Registry", "update", "Registry", [1540, 3600]), + codeNode("Portfolio: Mutation Result", PORTFOLIO_MUTATION_RESULT_CODE, [1780, 3600]), + ]; + + const connections = buildConnections([ + // ── SYNC connections ────────────────────────────────────────────── + connect("Sync: Daily Midnight Trigger", "Sync: Read Vendor List"), + connect("Sync: Manual Trigger", "Sync: Read Vendor List"), + connect("Sync: Read Vendor List", "Sync: Read Previous Registry"), + connect("Sync: Read Previous Registry", "Sync: Compute Diff"), + connect("Sync: Compute Diff", "Sync: Loop Added Vendors", 0), + connect("Sync: Compute Diff", "Sync: Loop Removed Vendors", 0), + connect("Sync: Loop Added Vendors", "Sync: Build Monitor Payload", 0), + connect("Sync: Build Monitor Payload", "Sync: Create Monitor"), + connect("Sync: Create Monitor", "Sync: Loop Added Vendors"), + connect("Sync: Loop Added Vendors", "Sync: Update Registry", 1), + connect("Sync: Loop Removed Vendors", "Sync: Delete Monitor", 0), + connect("Sync: Delete Monitor", "Sync: Loop Removed Vendors"), + connect("Sync: Loop Removed Vendors", "Sync: Update Registry", 1), + + // ── RESEARCH connections ────────────────────────────────────────── + connect("Research: Daily 6AM Trigger", "Research: Read Registry"), + connect("Research: Manual Trigger", "Research: Read Registry"), + connect("Research: Read Registry", "Research: Filter Due Vendors"), + connect("Research: Filter Due Vendors", "Research: Build Prompts"), + connect("Research: Build Prompts", "Research: Loop Vendors"), + connect("Research: Loop Vendors", "Research: Run Deep Research", 0), + connect("Research: Run Deep Research", "Research: Wait 90s"), + connect("Research: Wait 90s", "Research: Loop Vendors"), + connect("Research: Loop Vendors", "Research: Collect Results", 1), + connect("Research: Collect Results", "Scoring: Risk Scorer"), // fan-in #1 + + // ── MONITOR DEPLOY connections ──────────────────────────────────── + connect("Monitor: Deploy Webhook", "Monitor: Generate Queries"), + connect("Monitor: Generate Queries", "Monitor: Loop Monitors"), + connect("Monitor: Loop Monitors", "Monitor: Create Monitor", 0), + connect("Monitor: Create Monitor", "Monitor: Loop Monitors"), + connect("Monitor: Loop Monitors", "Monitor: Record Monitor IDs", 1), + + // ── MONITOR EVENTS connections ──────────────────────────────────── + connect("Monitor: Event Trigger", "Monitor: Enrich & Classify Event"), + connect("Monitor: Enrich & Classify Event", "Scoring: Risk Scorer"), // fan-in #2 + + // ── AD-HOC COMMAND connections ──────────────────────────────────── + connect("AdHoc: Slack Command", "AdHoc: Parse Command"), + connect("AdHoc: Parse Command", "AdHoc: Send Acknowledgment"), + connect("AdHoc: Send Acknowledgment", "AdHoc: Start Research Task"), + + // ── AD-HOC CALLBACK connections ────────────────────────────────── + connect("AdHoc: Result Callback", "AdHoc: Tag Source"), + connect("AdHoc: Tag Source", "Scoring: Risk Scorer"), // fan-in #3 + + // ── SHARED SCORING CHAIN connections ───────────────────────────── + connect("Scoring: Risk Scorer", "Scoring: Route by Risk Level"), + connect("Scoring: Route by Risk Level", "Scoring: Alert Critical", 0), + connect("Scoring: Route by Risk Level", "Scoring: Alert High", 1), + connect("Scoring: Route by Risk Level", "Scoring: Format Digest", 2), + connect("Scoring: Route by Risk Level", "Scoring: Log Low", 3), + connect("Scoring: Alert Critical", "Scoring: Audit Log"), + connect("Scoring: Alert High", "Scoring: Audit Log"), + connect("Scoring: Format Digest", "Scoring: Audit Log"), + connect("Scoring: Log Low", "Scoring: Audit Log"), + connect("Scoring: Audit Log", "Scoring: Route Back"), + connect("Scoring: Route Back", "Research: Update Research Dates", 0), // deep_research + connect("Scoring: Route Back", "AdHoc: Post Thread Reply", 1), // adhoc + // output 2 (monitor_event) → terminal, no connection needed + + // ── SNAPSHOT connections ───────────────────────────────────────────── + connect("Snapshot: Dashboard Webhook", "Snapshot: Read Registry"), + connect("Snapshot: Read Registry", "Snapshot: Read Audit Log"), + connect("Snapshot: Read Audit Log", "Snapshot: Read Monitors"), + connect("Snapshot: Read Monitors", "Snapshot: Build Payload"), + + // ── PORTFOLIO MUTATION connections ───────────────────────────────── + connect("Portfolio: Mutation Webhook", "Portfolio: Read Vendors"), + connect("Portfolio: Read Vendors", "Portfolio: Read Registry"), + connect("Portfolio: Read Registry", "Portfolio: Build Vendor Rows"), + connect("Portfolio: Build Vendor Rows", "Portfolio: Write Vendors"), + connect("Portfolio: Write Vendors", "Portfolio: Build Registry Rows"), + connect("Portfolio: Build Registry Rows", "Portfolio: Write Registry"), + connect("Portfolio: Write Registry", "Portfolio: Mutation Result"), + ]); + + return buildWorkflow( + "Vendor Risk Monitoring — Combined Workflow", + nodes, + connections, + ); +} + +// ── Code Constants ────────────────────────────────────────────────────────── +// Each constant is a copy from the individual generators with $() references +// updated to use prefixed node names. + +const SYNC_DIFF_CODE = ` +const rawIncoming = $('Sync: Read Vendor List').all().map(i => i.json); +const rawPrevious = $('Sync: Read Previous Registry').all().map(i => i.json); + +function isActive(row) { + const value = String(row.active ?? 'TRUE').trim().toLowerCase(); + return !['false', 'no', '0', 'inactive'].includes(value); +} + +function key(row) { + return String(row.vendor_domain || row.domain || row.vendor_name || '').trim().toLowerCase(); +} + +function hasMonitorIds(row) { + const ids = row.monitor_ids ?? row.monitorIds ?? ''; + if (Array.isArray(ids)) return ids.length > 0; + return String(ids).trim() !== '' && String(ids).trim() !== '[]'; +} + +const incoming = rawIncoming.filter(isActive).filter(v => key(v)); +const previous = rawPrevious.filter(isActive).filter(v => key(v)); +const previousWithMonitors = rawPrevious.filter(v => key(v) && hasMonitorIds(v)); + +const incomingMap = new Map(incoming.map(v => [key(v), v])); +const previousMap = new Map(previous.map(v => [key(v), v])); + +const added = incoming.filter(v => { + const prev = previousMap.get(key(v)); + return !prev || !hasMonitorIds(prev); +}); +const removed = previousWithMonitors.filter(v => !incomingMap.has(key(v)) || !isActive(v)); +const modified = incoming.filter(v => { + const prev = previousMap.get(key(v)); + return prev && hasMonitorIds(prev) && (prev.monitoring_priority !== v.monitoring_priority || prev.vendor_category !== v.vendor_category); +}); + +return [{ json: { added, removed, modified, unchanged_count: incoming.length - added.length - modified.length } }]; +`; + +const SYNC_MONITOR_PAYLOAD_CODE = ` +const vendor = $json; +if (!vendor || !vendor.vendor_name) { + throw new Error('Sync: Build Monitor Payload received empty vendor input. Ensure Vendors sheet has vendor_name.'); +} +const templates = [ + { dim: "legal", cat: "Legal & Regulatory", q: \`"\${vendor.vendor_name}" lawsuit OR litigation OR regulatory action\` }, + { dim: "cyber", cat: "Cybersecurity", q: \`"\${vendor.vendor_name}" data breach OR cybersecurity incident\` }, + { dim: "financial", cat: "Financial Health", q: \`"\${vendor.vendor_name}" bankruptcy OR financial distress OR credit downgrade\` }, + { dim: "leadership", cat: "Leadership & Governance", q: \`"\${vendor.vendor_name}" CEO departure OR executive change OR merger\` }, + { dim: "esg", cat: "ESG & Reputation", q: \`"\${vendor.vendor_name}" recall OR safety violation OR environmental fine\` }, +]; +const cadence = vendor.monitoring_priority === "low" ? "weekly" : "daily"; +const dims = vendor.monitoring_priority === "high" ? templates + : vendor.monitoring_priority === "medium" ? templates.slice(0, 3) + : [templates[0], templates[2]]; + +return dims.map(t => ({ + json: { + monitorPayload: { + query: t.q, cadence, + metadata: { vendor_name: vendor.vendor_name, vendor_domain: vendor.vendor_domain, monitor_category: t.cat, risk_dimension: t.dim }, + } + } +})); +`; + +const RESEARCH_FILTER_CODE = ` +const today = new Date().toISOString().slice(0, 10); +const vendors = $input.all().map(i => i.json); +const due = vendors.filter(v => { + if (v.active === false || v.active === "false") return false; + if (!v.next_research_date) return true; + return v.next_research_date.slice(0, 10) <= today; +}); +return due.map(v => ({ json: v })); +`; + +const RESEARCH_BUILD_PROMPTS_CODE = ` +const vendors = $input.all().map(i => i.json); +const outputSchema = JSON.stringify({ + type: "object", + properties: { + vendor_name: { type: "string" }, + overall_risk_level: { type: "string", enum: ["LOW","MEDIUM","HIGH","CRITICAL"] }, + financial_health: { type: "object", properties: { status: { type: "string" }, findings: { type: "string" }, severity: { type: "string" } }, required: ["status","findings","severity"] }, + legal_regulatory: { type: "object", properties: { status: { type: "string" }, findings: { type: "string" }, severity: { type: "string" } }, required: ["status","findings","severity"] }, + cybersecurity: { type: "object", properties: { status: { type: "string" }, findings: { type: "string" }, severity: { type: "string" } }, required: ["status","findings","severity"] }, + leadership_governance: { type: "object", properties: { status: { type: "string" }, findings: { type: "string" }, severity: { type: "string" } }, required: ["status","findings","severity"] }, + esg_reputation: { type: "object", properties: { status: { type: "string" }, findings: { type: "string" }, severity: { type: "string" } }, required: ["status","findings","severity"] }, + adverse_events: { type: "array", items: { type: "object" } }, + recommendation: { type: "string" }, + }, + required: ["vendor_name","overall_risk_level","financial_health","legal_regulatory","cybersecurity","leadership_governance","esg_reputation","adverse_events","recommendation"] +}); +return vendors + .filter(v => v && v.vendor_name) + .map(v => ({ + json: { + ...v, + prompt: "Conduct a vendor risk assessment of " + v.vendor_name + " (" + v.vendor_domain + "). " + + "Investigate financial health, legal & regulatory, cybersecurity, leadership & governance, ESG & reputation. " + + "Classify each finding by severity (LOW/MEDIUM/HIGH/CRITICAL) and include source URLs.", + outputSchema, + } +}})); +`; + +const RESEARCH_COLLECT_CODE = ` +const items = $input.all().map(i => i.json); +return items.filter(r => r.run_id && r.status === 'started').map(r => ({ + json: { + vendor: { vendor_name: r.vendor_name, vendor_domain: r.vendor_domain }, + research_output: r.output || r, + run_id: r.run_id, + status: r.status, + } +})); +`; + +const MONITOR_QUERY_GEN_CODE = ` +const vendor = $input.first().json; +if (!vendor || !vendor.vendor_name) { + throw new Error('Monitor: Generate Queries received empty vendor input. Pass vendor_name/vendor_domain in webhook payload.'); +} +const templates = [ + { dim: "legal", cat: "Legal & Regulatory", q: '"' + vendor.vendor_name + '" lawsuit OR litigation OR regulatory action OR SEC investigation OR enforcement' }, + { dim: "cyber", cat: "Cybersecurity", q: '"' + vendor.vendor_name + '" data breach OR cybersecurity incident OR ransomware OR vulnerability disclosure' }, + { dim: "financial", cat: "Financial Health", q: '"' + vendor.vendor_name + '" bankruptcy OR financial distress OR credit downgrade OR debt default OR layoffs' }, + { dim: "leadership", cat: "Leadership & Governance", q: '"' + vendor.vendor_name + '" CEO departure OR executive change OR acquisition OR merger OR leadership' }, + { dim: "esg", cat: "ESG & Reputation", q: '"' + vendor.vendor_name + '" recall OR safety violation OR environmental fine OR labor dispute OR ESG controversy' }, +]; +const cadence = vendor.monitoring_priority === "low" ? "weekly" : "daily"; +const selected = vendor.monitoring_priority === "high" ? templates + : vendor.monitoring_priority === "medium" ? templates.slice(0, 3) + : [templates[0], templates[2]]; + +return selected.map(t => ({ + json: { + monitorPayload: { + query: t.q, cadence, + metadata: { vendor_name: vendor.vendor_name, vendor_domain: vendor.vendor_domain, monitor_category: t.cat, risk_dimension: t.dim }, + output_schema: { + type: "json", + json_schema: { type: "object", properties: { event_summary: { type: "string" }, severity: { type: "string" }, adverse: { type: "boolean" }, event_type: { type: "string" } }, required: ["event_summary","severity","adverse","event_type"] } + } + } + } +})); +`; + +// Native monitor trigger auto-fetches event_group, so enrich is simpler +const MONITOR_ENRICH_NATIVE_CODE = ` +const data = $input.first().json; +const topEvents = Array.isArray(data.events) ? data.events : []; +const eventGroup = data.event_group || {}; +const groupEvents = Array.isArray(eventGroup.events) ? eventGroup.events : []; +const events = topEvents.length ? topEvents : groupEvents; +const eventEntry = events.find(e => e.type === 'event'); +let output = {}; +if (eventEntry && eventEntry.output && typeof eventEntry.output === 'object') { + output = eventEntry.output; +} else if (eventEntry && typeof eventEntry.output === 'string') { + output = { event_summary: eventEntry.output, severity: 'LOW', adverse: false, event_type: 'unknown' }; +} else if (data.output && typeof data.output === 'object') { + output = data.output; +} +return [{ + json: { + monitor_id: data.monitor_id || data.monitor?.id || data.metadata?.monitor_id, + metadata: data.metadata || data.monitor?.metadata || {}, + ...output, + source: 'monitor_event', + event_date: eventEntry?.event_date || data.event_date, + source_urls: eventEntry?.source_urls || data.source_urls, + } +}]; +`; + +const ADHOC_PARSE_CMD_CODE = ` +const payload = $input.first().json; +const vendor_name = (payload.text || '').trim(); +if (!vendor_name) throw new Error('Vendor name is required. Usage: /vendor-research {vendor_name}'); + +const prompt = 'Conduct a comprehensive vendor risk assessment of "' + vendor_name + '". ' + + 'Investigate financial health, legal & regulatory, cybersecurity, leadership & governance, ESG & reputation. ' + + 'Classify each finding by severity (LOW/MEDIUM/HIGH/CRITICAL) and include source URLs.'; + +const outputSchema = JSON.stringify({ + type: "object", + properties: { + vendor_name: { type: "string" }, + overall_risk_level: { type: "string", enum: ["LOW","MEDIUM","HIGH","CRITICAL"] }, + financial_health: { type: "object", properties: { status: { type: "string" }, findings: { type: "string" }, severity: { type: "string" } } }, + legal_regulatory: { type: "object", properties: { status: { type: "string" }, findings: { type: "string" }, severity: { type: "string" } } }, + cybersecurity: { type: "object", properties: { status: { type: "string" }, findings: { type: "string" }, severity: { type: "string" } } }, + leadership_governance: { type: "object", properties: { status: { type: "string" }, findings: { type: "string" }, severity: { type: "string" } } }, + esg_reputation: { type: "object", properties: { status: { type: "string" }, findings: { type: "string" }, severity: { type: "string" } } }, + adverse_events: { type: "array", items: { type: "object" } }, + recommendation: { type: "string" }, + }, + required: ["vendor_name","overall_risk_level","recommendation"] +}); + +return [{ json: { + vendor_name, + channel_id: payload.channel_id || payload.channel, + user_name: payload.user_name || payload.user, + response_url: payload.response_url, + prompt, + outputSchema, + webhookUrl: ($vars?.N8N_WEBHOOK_BASE_URL || '') + "/webhook/parallel-task-completion", +} }]; +`; + +const ADHOC_TAG_SOURCE_CODE = ` +const data = $input.first().json || {}; +const events = Array.isArray(data.events) ? data.events : []; +const event = events.find((e) => e?.type === 'event') || events[events.length - 1]; +const eventData = event?.data || event || {}; +const output = (eventData.output && typeof eventData.output === 'object') + ? eventData.output + : (data.output && typeof data.output === 'object' ? data.output : {}); + +const run_id = data.run_id || eventData.run_id || data.id || eventData.id; +const status = data.status || eventData.status || 'completed'; + +return [{ + json: { + ...data, + run_id, + status, + research_output: output, + ...output, + source: 'adhoc', + } +}]; +`; + +const SCORING_CODE = ` +const input = $input.first().json; +const output = input.research_output || input; + +// Step 1: Severity aggregation +const dims = ['financial_health','legal_regulatory','cybersecurity','leadership_governance','esg_reputation']; +const counts = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 }; +const categories = []; +const mediumCats = []; + +for (const dim of dims) { + const sev = (output[dim]?.severity || 'LOW').toUpperCase(); + counts[sev] = (counts[sev] || 0) + 1; + if (sev === 'CRITICAL' || sev === 'HIGH') categories.push(dim); + if (sev === 'MEDIUM') mediumCats.push(dim); +} + +// Step 2: Risk level assignment +let risk_level, adverse_flag; +if (counts.CRITICAL > 0) { risk_level = 'CRITICAL'; adverse_flag = true; } +else if (counts.HIGH >= 1) { risk_level = 'HIGH'; adverse_flag = true; } +else if (counts.MEDIUM >= 3) { risk_level = 'MEDIUM'; adverse_flag = new Set(mediumCats).size >= 2; } +else if (counts.MEDIUM >= 1) { risk_level = 'MEDIUM'; adverse_flag = false; } +else { risk_level = 'LOW'; adverse_flag = false; } + +// Step 3: Overrides +const overrides = []; +if ((output.cybersecurity?.status || '').toUpperCase() === 'CRITICAL') { + risk_level = 'CRITICAL'; adverse_flag = true; overrides.push('active_data_breach'); +} +if ((output.legal_regulatory?.status || '').toUpperCase() === 'CRITICAL') { + if (['LOW','MEDIUM'].includes(risk_level)) risk_level = 'HIGH'; + adverse_flag = true; overrides.push('active_government_litigation'); +} + +// Step 4: Derived fields +const action_required = risk_level === 'HIGH' || risk_level === 'CRITICAL'; +const recMap = { LOW: 'continue_monitoring', MEDIUM: 'escalate_review', HIGH: 'initiate_contingency', CRITICAL: 'suspend_relationship' }; +const recommendation = recMap[risk_level]; +const vendor_name = output.vendor_name || input.vendor?.vendor_name || 'Unknown'; +const summary = vendor_name + ' assessed at ' + risk_level + ' risk. ' + (adverse_flag ? 'Adverse conditions detected.' : 'No adverse conditions.'); + +return [{ + json: { + vendor_name, risk_level, adverse_flag, action_required, recommendation, + summary, categories, severity_counts: counts, triggered_overrides: overrides, + assessment_date: new Date().toISOString().slice(0, 10), + source: input.source || 'deep_research', + } +}]; +`; + +const SCORING_DIGEST_CODE = ` +const data = $input.first().json; +return [{ json: { ...data, digest_formatted: true } }]; +`; + +const PORTFOLIO_BUILD_VENDOR_ROWS_CODE = ` +const incoming = $('Portfolio: Mutation Webhook').first().json || {}; +const headers = incoming.headers || {}; +const body = incoming.body && typeof incoming.body === 'object' ? incoming.body : incoming; +const currentRows = $('Portfolio: Read Vendors').all().map(i => i.json); +const now = new Date().toISOString(); + +const seedVendors = [ + { vendorName: 'Microsoft', vendorDomain: 'https://microsoft.com', vendorCategory: 'technology', monitoringPriority: 'high' }, + { vendorName: 'Amazon Web Services', vendorDomain: 'https://aws.amazon.com', vendorCategory: 'technology', monitoringPriority: 'high' }, + { vendorName: 'Salesforce', vendorDomain: 'https://salesforce.com', vendorCategory: 'technology', monitoringPriority: 'high' }, + { vendorName: 'JPMorgan Chase', vendorDomain: 'https://jpmorganchase.com', vendorCategory: 'financial_services', monitoringPriority: 'high' }, + { vendorName: 'Goldman Sachs', vendorDomain: 'https://goldmansachs.com', vendorCategory: 'financial_services', monitoringPriority: 'medium' }, + { vendorName: 'UnitedHealth Group', vendorDomain: 'https://unitedhealthgroup.com', vendorCategory: 'healthcare', monitoringPriority: 'high' }, + { vendorName: 'Pfizer', vendorDomain: 'https://pfizer.com', vendorCategory: 'healthcare', monitoringPriority: 'medium' }, + { vendorName: 'Johnson & Johnson', vendorDomain: 'https://jnj.com', vendorCategory: 'healthcare', monitoringPriority: 'medium' }, + { vendorName: 'Siemens', vendorDomain: 'https://siemens.com', vendorCategory: 'manufacturing', monitoringPriority: 'medium' }, + { vendorName: 'Caterpillar', vendorDomain: 'https://caterpillar.com', vendorCategory: 'manufacturing', monitoringPriority: 'low' }, + { vendorName: 'Deloitte', vendorDomain: 'https://deloitte.com', vendorCategory: 'professional_services', monitoringPriority: 'medium' }, + { vendorName: 'Accenture', vendorDomain: 'https://accenture.com', vendorCategory: 'professional_services', monitoringPriority: 'medium' }, + { vendorName: 'Stripe', vendorDomain: 'https://stripe.com', vendorCategory: 'financial_services', monitoringPriority: 'high' }, + { vendorName: 'CrowdStrike', vendorDomain: 'https://crowdstrike.com', vendorCategory: 'technology', monitoringPriority: 'high' }, + { vendorName: '3M', vendorDomain: 'https://3m.com', vendorCategory: 'manufacturing', monitoringPriority: 'low' }, +]; + +function pick(row, keys, fallback = '') { + for (const key of keys) { + if (row[key] !== undefined && row[key] !== null && String(row[key]).trim() !== '') { + return row[key]; + } + } + return fallback; +} + +function headerValue(name) { + const target = name.toLowerCase(); + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === target) return Array.isArray(value) ? value[0] : value; + } + return ''; +} + +function isTruthy(value) { + return ['true', 'yes', '1', 'y'].includes(String(value || '').trim().toLowerCase()); +} + +function isActive(row) { + const value = String(pick(row, ['active'], 'TRUE')).trim().toLowerCase(); + return !['false', 'no', '0', 'inactive'].includes(value); +} + +function normalizeDomain(value, name) { + const fallback = String(name || 'vendor').toLowerCase().replace(/[^a-z0-9]+/g, '-') + '.example'; + const raw = String(value || fallback).trim(); + return raw.startsWith('http://') || raw.startsWith('https://') ? raw : 'https://' + raw; +} + +function keyFor(row) { + return String(pick(row, ['vendor_domain', 'vendorDomain', 'domain'], pick(row, ['vendor_name', 'vendorName', 'name'], ''))) + .trim() + .toLowerCase(); +} + +function normalizePriority(value) { + const normalized = String(value || '').trim().toLowerCase(); + return ['high', 'medium', 'low'].includes(normalized) ? normalized : 'medium'; +} + +function normalizeRisk(value) { + const normalized = String(value || '').trim().toUpperCase(); + return ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'].includes(normalized) ? normalized : ''; +} + +function sheetRowFromInput(input) { + const vendorName = String(input.vendorName || input.vendor_name || '').trim(); + if (!vendorName) throw new Error('Portfolio mutation vendorName is required.'); + const domain = normalizeDomain(input.vendorDomain || input.vendor_domain, vendorName); + return { + vendor_name: vendorName, + vendor_domain: domain, + vendor_category: String(input.vendorCategory || input.vendor_category || 'vendor').trim().toLowerCase().replace(/\\s+/g, '_'), + risk_tier_override: normalizeRisk(input.riskLevel || input.risk_level), + active: 'TRUE', + monitoring_priority: normalizePriority(input.monitoringPriority || input.monitoring_priority), + relationship_owner: String(input.relationshipOwner || input.relationship_owner || 'Procurement').trim(), + region: String(input.region || 'Global').trim(), + risk_score: input.score !== undefined ? String(input.score) : '', + next_research_date: String(input.nextResearchDate || input.next_research_date || ''), + last_synced_at: now, + dashboard_managed: 'TRUE', + }; +} + +function normalizeExistingRow(row) { + const vendorName = String(pick(row, ['vendor_name', 'vendorName', 'name'], '')).trim(); + if (!vendorName) return null; + return { + vendor_name: vendorName, + vendor_domain: normalizeDomain(pick(row, ['vendor_domain', 'vendorDomain', 'domain'], ''), vendorName), + vendor_category: String(pick(row, ['vendor_category', 'vendorCategory', 'category'], 'vendor')).trim().toLowerCase().replace(/\\s+/g, '_'), + risk_tier_override: String(pick(row, ['risk_tier_override', 'riskTierOverride', 'risk_level', 'riskLevel'], '')), + active: isActive(row) ? 'TRUE' : 'FALSE', + monitoring_priority: normalizePriority(pick(row, ['monitoring_priority', 'monitoringPriority', 'priority'], '')), + relationship_owner: String(pick(row, ['relationship_owner', 'relationshipOwner', 'owner'], 'Procurement')), + region: String(pick(row, ['region'], 'Global')), + risk_score: String(pick(row, ['risk_score', 'riskScore', 'score'], '')), + next_research_date: String(pick(row, ['next_research_date', 'nextResearchDate'], '')), + last_synced_at: String(pick(row, ['last_synced_at', 'lastSyncedAt'], '')), + dashboard_managed: isTruthy(pick(row, ['dashboard_managed', 'dashboardManaged'], '')) ? 'TRUE' : 'FALSE', + }; +} + +function seedRow(input) { + return { + vendor_name: input.vendorName, + vendor_domain: input.vendorDomain, + vendor_category: input.vendorCategory, + risk_tier_override: '', + active: 'TRUE', + monitoring_priority: input.monitoringPriority, + relationship_owner: 'Procurement', + region: 'Global', + risk_score: '', + next_research_date: '', + last_synced_at: now, + dashboard_managed: 'FALSE', + }; +} + +const expectedToken = String($vars?.PROCUREMENT_DASHBOARD_WRITE_TOKEN || '').trim(); +if (!expectedToken) { + throw new Error('Set n8n variable PROCUREMENT_DASHBOARD_WRITE_TOKEN before enabling portfolio write-back.'); +} + +const actualToken = String(headerValue('x-procurement-dashboard-token') || '').trim(); +if (actualToken !== expectedToken) { + throw new Error('Unauthorized portfolio mutation.'); +} + +const action = body.action; +if (!['addVendor', 'uploadVendors', 'resetSeedVendors'].includes(action)) { + throw new Error('Unsupported portfolio mutation action.'); +} + +const rowsByKey = new Map(); +for (const row of currentRows) { + const normalized = normalizeExistingRow(row); + if (normalized) rowsByKey.set(keyFor(normalized), normalized); +} + +if (action === 'addVendor') { + const row = sheetRowFromInput(body.vendor || {}); + rowsByKey.set(keyFor(row), row); +} + +if (action === 'uploadVendors') { + const vendors = Array.isArray(body.vendors) ? body.vendors : []; + if (!vendors.length) throw new Error('uploadVendors requires at least one vendor.'); + for (const vendor of vendors) { + const row = sheetRowFromInput(vendor || {}); + rowsByKey.set(keyFor(row), row); + } +} + +if (action === 'resetSeedVendors') { + const seedRows = seedVendors.map(seedRow); + const seedKeys = new Set(seedRows.map(keyFor)); + for (const row of rowsByKey.values()) { + if (!seedKeys.has(keyFor(row)) && row.dashboard_managed === 'TRUE') { + row.active = 'FALSE'; + row.last_synced_at = now; + } + } + for (const row of seedRows) { + rowsByKey.set(keyFor(row), row); + } +} + +return Array.from(rowsByKey.values()).map(row => ({ json: row })); +`; + +const PORTFOLIO_BUILD_REGISTRY_ROWS_CODE = ` +const vendorRows = $('Portfolio: Build Vendor Rows').all().map(i => i.json); +const registryRows = $('Portfolio: Read Registry').all().map(i => i.json); +const now = new Date().toISOString(); + +function pick(row, keys, fallback = '') { + for (const key of keys) { + if (row[key] !== undefined && row[key] !== null && String(row[key]).trim() !== '') { + return row[key]; + } + } + return fallback; +} + +function normalizeDomain(value, name) { + const fallback = String(name || 'vendor').toLowerCase().replace(/[^a-z0-9]+/g, '-') + '.example'; + const raw = String(value || fallback).trim(); + return raw.startsWith('http://') || raw.startsWith('https://') ? raw : 'https://' + raw; +} + +function keyFor(row) { + return String(pick(row, ['vendor_domain', 'vendorDomain', 'domain'], pick(row, ['vendor_name', 'vendorName', 'name'], ''))) + .trim() + .toLowerCase(); +} + +const registryByKey = new Map(); +for (const row of registryRows) { + const key = keyFor(row); + if (key) registryByKey.set(key, row); +} + +return vendorRows.map(row => { + const previous = registryByKey.get(keyFor(row)) || {}; + const vendorName = String(pick(row, ['vendor_name', 'vendorName', 'name'], pick(previous, ['vendor_name', 'vendorName', 'name'], 'Unknown vendor'))); + const domain = normalizeDomain(pick(row, ['vendor_domain', 'vendorDomain', 'domain'], pick(previous, ['vendor_domain', 'vendorDomain', 'domain'], '')), vendorName); + + return { + json: { + vendor_name: vendorName, + vendor_domain: domain, + vendor_category: String(pick(row, ['vendor_category', 'vendorCategory', 'category'], pick(previous, ['vendor_category', 'vendorCategory', 'category'], 'vendor'))), + risk_tier_override: String(pick(row, ['risk_tier_override', 'riskTierOverride'], pick(previous, ['risk_tier_override', 'riskTierOverride'], ''))), + active: String(pick(row, ['active'], pick(previous, ['active'], 'TRUE'))), + monitoring_priority: String(pick(row, ['monitoring_priority', 'monitoringPriority', 'priority'], pick(previous, ['monitoring_priority', 'monitoringPriority', 'priority'], 'medium'))), + monitor_ids: String(pick(previous, ['monitor_ids', 'monitorIds'], '')), + next_research_date: String(pick(row, ['next_research_date', 'nextResearchDate'], pick(previous, ['next_research_date', 'nextResearchDate'], ''))), + last_synced_at: now, + relationship_owner: String(pick(row, ['relationship_owner', 'relationshipOwner', 'owner'], pick(previous, ['relationship_owner', 'relationshipOwner', 'owner'], 'Procurement'))), + region: String(pick(row, ['region'], pick(previous, ['region'], 'Global'))), + risk_score: String(pick(row, ['risk_score', 'riskScore', 'score'], pick(previous, ['risk_score', 'riskScore', 'score'], ''))), + dashboard_managed: String(pick(row, ['dashboard_managed', 'dashboardManaged'], pick(previous, ['dashboard_managed', 'dashboardManaged'], 'FALSE'))), + }, + }; +}); +`; + +const PORTFOLIO_MUTATION_RESULT_CODE = ` +const incoming = $('Portfolio: Mutation Webhook').first().json || {}; +const body = incoming.body && typeof incoming.body === 'object' ? incoming.body : incoming; +const action = body.action || 'unknown'; +const affected = action === 'uploadVendors' + ? (Array.isArray(body.vendors) ? body.vendors.length : 0) + : action === 'addVendor' + ? 1 + : $('Portfolio: Build Vendor Rows').all().length; + +return [{ + json: { + ok: true, + action, + affected, + }, +}]; +`; + +const SNAPSHOT_BUILD_PAYLOAD_CODE = ` +const registry = $('Snapshot: Read Registry').all().map(i => i.json); +const audit_log = $('Snapshot: Read Audit Log').all().map(i => i.json); +const monitors = $('Snapshot: Read Monitors').all().map(i => i.json); +const now = new Date(); + +const riskLevels = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL']; +const riskScores = { LOW: 18, MEDIUM: 48, HIGH: 76, CRITICAL: 94 }; +const dimensionLabels = { + financial_health: 'Financial health', + legal_regulatory: 'Legal & regulatory', + cybersecurity: 'Cybersecurity', + leadership_governance: 'Leadership & governance', + esg_reputation: 'ESG & reputation', +}; +const dimensionOrder = Object.keys(dimensionLabels); + +function pick(row, keys, fallback = '') { + for (const key of keys) { + if (row[key] !== undefined && row[key] !== null && String(row[key]).trim() !== '') { + return row[key]; + } + } + return fallback; +} + +function slugify(value) { + return String(value || 'vendor') + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'vendor'; +} + +function normalizeRisk(value) { + const normalized = String(value || '').trim().toUpperCase(); + return riskLevels.includes(normalized) ? normalized : 'LOW'; +} + +function normalizePriority(value, riskLevel) { + const normalized = String(value || '').trim().toLowerCase(); + if (['high', 'medium', 'low'].includes(normalized)) return normalized; + if (riskLevel === 'CRITICAL' || riskLevel === 'HIGH') return 'high'; + if (riskLevel === 'MEDIUM') return 'medium'; + return 'low'; +} + +function toBool(value) { + if (typeof value === 'boolean') return value; + return ['true', 'yes', '1', 'y'].includes(String(value || '').trim().toLowerCase()); +} + +function parseList(value) { + if (Array.isArray(value)) return value.map(String).filter(Boolean); + if (value === undefined || value === null || value === '') return []; + try { + const parsed = JSON.parse(value); + if (Array.isArray(parsed)) return parsed.map(String).filter(Boolean); + } catch {} + return String(value).split(/[;,]/).map(item => item.trim()).filter(Boolean); +} + +function dimensionKey(value) { + const normalized = String(value || '').toLowerCase(); + if (normalized.includes('financial') || normalized.includes('credit')) return 'financial_health'; + if (normalized.includes('legal') || normalized.includes('regulatory') || normalized.includes('litigation')) return 'legal_regulatory'; + if (normalized.includes('cyber') || normalized.includes('breach') || normalized.includes('security')) return 'cybersecurity'; + if (normalized.includes('leadership') || normalized.includes('governance') || normalized.includes('executive')) return 'leadership_governance'; + if (normalized.includes('esg') || normalized.includes('reputation') || normalized.includes('labor')) return 'esg_reputation'; + return 'financial_health'; +} + +function dateOnly(value, fallbackDate) { + const date = value ? new Date(value) : fallbackDate; + if (Number.isNaN(date.getTime())) return fallbackDate.toISOString().slice(0, 10); + return date.toISOString().slice(0, 10); +} + +function relativeTime(value) { + const date = value ? new Date(value) : now; + if (Number.isNaN(date.getTime())) return 'just now'; + const minutes = Math.max(0, Math.round((now.getTime() - date.getTime()) / 60000)); + if (minutes < 1) return 'just now'; + if (minutes < 60) return minutes + ' minutes ago'; + const hours = Math.round(minutes / 60); + if (hours < 48) return hours + ' hours ago'; + const days = Math.round(hours / 24); + return days + ' days ago'; +} + +function sourceUrl(value) { + const text = String(value || '').trim(); + return text.startsWith('http://') || text.startsWith('https://') ? text : 'https://parallel.ai'; +} + +function recommendationFor(level) { + if (level === 'CRITICAL') return 'suspend_relationship'; + if (level === 'HIGH') return 'initiate_contingency'; + if (level === 'MEDIUM') return 'escalate_review'; + return 'continue_monitoring'; +} + +function auditTimestamp(row) { + return String(pick(row, ['timestamp', 'assessment_date', 'created_at', 'date'], '')); +} + +const latestAuditByVendor = new Map(); +for (const row of audit_log) { + const vendorName = String(pick(row, ['vendor_name', 'vendorName', 'name'], '')).trim(); + if (!vendorName) continue; + const key = vendorName.toLowerCase(); + const current = latestAuditByVendor.get(key); + const nextTime = new Date(auditTimestamp(row)).getTime() || 0; + const currentTime = current ? (new Date(auditTimestamp(current)).getTime() || 0) : -1; + if (!current || nextTime >= currentTime) latestAuditByVendor.set(key, row); +} + +function dimensionsFor(latestAudit, riskLevel) { + const categories = parseList(pick(latestAudit || {}, ['categories', 'risk_categories', 'category'], '')); + const activeKeys = new Set(categories.map(dimensionKey)); + return dimensionOrder.map(key => { + const active = activeKeys.has(key); + return { + key, + label: dimensionLabels[key], + severity: active ? riskLevel : 'LOW', + status: active ? 'watch' : 'stable', + findings: active + ? (pick(latestAudit || {}, ['summary', 'detail', 'event_summary'], dimensionLabels[key] + ' requires review.')) + : 'No active findings in the current monitoring window.', + }; + }); +} + +function monitorLensFor(vendorName) { + return monitors + .filter(row => String(pick(row, ['vendor_name', 'vendorName'], '')).toLowerCase() === vendorName.toLowerCase()) + .map(row => ({ + dimension: String(pick(row, ['risk_dimension', 'monitor_category', 'category'], 'general')), + cadence: String(pick(row, ['cadence'], 'daily')), + status: 'active', + query: String(pick(row, ['query', 'monitor_query'], vendorName + ' vendor risk')), + lastEvent: String(pick(row, ['last_event_at', 'updated_at', 'created_at'], 'No event yet')), + })); +} + +const activeRegistry = registry.filter(row => { + const active = String(pick(row, ['active'], 'true')).trim().toLowerCase(); + return !['false', 'no', '0'].includes(active); +}); + +const vendors = activeRegistry.map(row => { + const vendorName = String(pick(row, ['vendor_name', 'vendorName', 'name'], 'Unknown vendor')).trim(); + const latest = latestAuditByVendor.get(vendorName.toLowerCase()); + const riskLevel = normalizeRisk(pick(latest || {}, ['risk_level', 'riskLevel'], pick(row, ['risk_tier_override', 'risk_level'], 'LOW'))); + const latestDate = dateOnly(auditTimestamp(latest || {}), now); + const nextDate = dateOnly(pick(row, ['next_research_date', 'nextResearchDate'], now.toISOString()), now); + const adverseFlag = toBool(pick(latest || {}, ['adverse_flag', 'adverseFlag'], riskLevel === 'HIGH' || riskLevel === 'CRITICAL')); + const summary = String(pick(latest || {}, ['summary', 'detail', 'event_summary'], vendorName + ' is currently assessed at ' + riskLevel + ' risk.')); + const overrides = parseList(pick(latest || {}, ['triggered_overrides', 'triggeredOverrides'], pick(row, ['risk_tier_override'], ''))); + const domain = String(pick(row, ['vendor_domain', 'vendorDomain', 'domain'], slugify(vendorName) + '.com')); + const normalizedDomain = domain.startsWith('http://') || domain.startsWith('https://') ? domain : 'https://' + domain; + const score = Number(pick(latest || {}, ['score', 'risk_score'], pick(row, ['risk_score', 'riskScore', 'score'], riskScores[riskLevel]))) || riskScores[riskLevel]; + const monitorsForVendor = monitorLensFor(vendorName); + + return { + id: slugify(vendorName), + vendorName, + vendorDomain: normalizedDomain, + vendorCategory: String(pick(row, ['vendor_category', 'vendorCategory', 'category'], 'vendor')).toLowerCase().replace(/\\s+/g, '_'), + monitoringPriority: normalizePriority(pick(row, ['monitoring_priority', 'monitoringPriority', 'priority'], ''), riskLevel), + relationshipOwner: String(pick(row, ['relationship_owner', 'relationshipOwner', 'owner'], 'Procurement')), + region: String(pick(row, ['region'], 'Global')), + riskLevel, + overallRiskLevel: riskLevel, + score, + actionRequired: riskLevel === 'HIGH' || riskLevel === 'CRITICAL', + adverseFlag, + recommendation: String(pick(latest || {}, ['recommendation'], recommendationFor(riskLevel))), + summary, + movement: String(pick(latest || {}, ['movement'], '+0 live snapshot')), + lastAssessmentDate: latestDate, + nextResearchDate: nextDate, + triggeredOverrides: overrides.filter(value => value && riskLevels.indexOf(String(value).toUpperCase()) === -1), + dimensions: dimensionsFor(latest, riskLevel), + adverseEvents: adverseFlag ? [{ + title: String(pick(latest || {}, ['title', 'event_summary'], riskLevel + ' risk finding')), + date: latestDate, + category: String(parseList(pick(latest || {}, ['categories', 'category'], 'general'))[0] || 'general'), + severity: riskLevel, + description: summary, + sourceUrl: sourceUrl(pick(latest || {}, ['source', 'source_url', 'sourceUrl'], '')), + }] : [], + evidence: latest ? [{ + title: String(pick(latest, ['title', 'summary'], 'Latest assessment')), + publication: String(pick(latest, ['source', 'publication'], 'Parallel assessment')), + publishedAt: latestDate, + materiality: summary, + href: sourceUrl(pick(latest, ['source_url', 'sourceUrl', 'source'], '')), + }] : [], + monitors: monitorsForVendor, + }; +}).sort((left, right) => right.score - left.score); + +const riskDistribution = riskLevels.map(level => ({ + label: level, + count: vendors.filter(vendor => vendor.riskLevel === level).length, +})); + +const dueVendors = vendors.filter(vendor => { + const next = new Date(vendor.nextResearchDate); + return !Number.isNaN(next.getTime()) && next <= now; +}); + +const researchedToday = audit_log.filter(row => dateOnly(auditTimestamp(row), now) === now.toISOString().slice(0, 10)).length; +const adverseCount = vendors.filter(vendor => vendor.adverseFlag).length; +const actionCount = vendors.filter(vendor => vendor.actionRequired).length; +const criticalCount = vendors.filter(vendor => vendor.riskLevel === 'CRITICAL').length; +const highCount = vendors.filter(vendor => vendor.riskLevel === 'HIGH').length; +const activeMonitorCount = monitors.length; + +const sortedAudit = audit_log.slice().sort((left, right) => { + return (new Date(auditTimestamp(right)).getTime() || 0) - (new Date(auditTimestamp(left)).getTime() || 0); +}); + +const feed = sortedAudit.slice(0, 25).map(row => { + const vendorName = String(pick(row, ['vendor_name', 'vendorName', 'name'], 'Unknown vendor')); + const riskLevel = normalizeRisk(pick(row, ['risk_level', 'riskLevel', 'severity'], 'MEDIUM')); + const summary = String(pick(row, ['summary', 'detail', 'event_summary'], vendorName + ' monitoring event.')); + return { + vendorName, + title: String(pick(row, ['title', 'event_summary'], summary.split('.')[0] || 'Monitoring update')), + severity: riskLevel, + timestamp: relativeTime(auditTimestamp(row)), + detail: summary, + sourceUrl: sourceUrl(pick(row, ['source_url', 'sourceUrl', 'source'], '')), + }; +}); + +const actionQueue = vendors + .filter(vendor => vendor.actionRequired) + .map(vendor => ({ + vendorName: vendor.vendorName, + owner: vendor.riskLevel === 'CRITICAL' ? 'Security operations' : 'Procurement finance', + deadline: vendor.riskLevel === 'CRITICAL' ? 'Due in 12h' : 'Due in 24h', + action: vendor.riskLevel === 'CRITICAL' + ? 'Validate exposure, review contingency supplier path, and notify accountable stakeholders.' + : 'Update the vendor risk memo and confirm mitigation owner.', + riskLevel: vendor.riskLevel, + })); + +return [{ + json: { + lastUpdated: now.toISOString(), + metrics: [ + { + label: 'Portfolio risk posture', + value: criticalCount + ' CRITICAL / ' + highCount + ' HIGH', + trend: actionCount + ' vendors require immediate review', + tone: actionCount ? 'critical' : 'positive', + }, + { + label: 'Research cadence', + value: dueVendors.length + ' due today', + trend: researchedToday + ' audit log entries recorded today', + tone: dueVendors.length ? 'warning' : 'positive', + }, + { + label: 'Monitor fleet health', + value: activeMonitorCount + ' active', + trend: 'Webhook healthy, live snapshot generated', + tone: 'positive', + }, + { + label: 'Action queue', + value: actionCount + ' escalations', + trend: actionQueue.filter(item => item.deadline.includes('12h')).length + ' due in the next 12h', + tone: actionCount ? 'default' : 'positive', + }, + ], + riskDistribution, + researchSummary: { + totalDue: dueVendors.length, + totalResearched: researchedToday, + totalFailed: 0, + adverseCount, + batchesExecuted: Math.ceil(Math.max(dueVendors.length, researchedToday) / 50), + duration: 'live', + }, + health: { + totalMonitors: monitors.length, + activeCount: monitors.length, + failedCount: 0, + orphanCount: 0, + recreated: 0, + webhookHealthy: true, + }, + feed, + actionQueue, + vendors, + } +}]; +`; diff --git a/typescript-recipes/parallel-n8n-procurement/src/workflows/generators/workflow1-vendor-sync.ts b/typescript-recipes/parallel-n8n-procurement/src/workflows/generators/workflow1-vendor-sync.ts new file mode 100644 index 0000000..cea11ac --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/src/workflows/generators/workflow1-vendor-sync.ts @@ -0,0 +1,94 @@ +import { + resetNodeCounter, pos, scheduleNode, manualTriggerNode, + googleSheetsNode, codeNode, httpRequestNode, splitInBatchesNode, + connect, buildConnections, buildWorkflow, + type N8nWorkflow, +} from "../generator-utils.js"; + +export function generateVendorSyncWorkflow(): N8nWorkflow { + resetNodeCounter(); + + const nodes = [ + scheduleNode("Daily Sync Trigger", 0, pos(0, 0)), + manualTriggerNode("Manual Trigger", pos(0, 1)), + googleSheetsNode("Read Vendor List", "read", "Vendors", pos(1, 0)), + googleSheetsNode("Read Previous Registry", "read", "Registry", pos(2, 0)), + codeNode("Compute Diff", DIFF_CODE, pos(3, 0)), + splitInBatchesNode("Loop Added Vendors", 1, pos(4, -1)), + codeNode("Build Monitor Payload", MONITOR_PAYLOAD_CODE, pos(5, -1)), + httpRequestNode( + "Create Monitor", + "POST", + "https://api.parallel.ai/v1alpha/monitors", + pos(6, -1), + "={{ JSON.stringify($json.monitorPayload) }}", + ), + splitInBatchesNode("Loop Removed Vendors", 1, pos(4, 1)), + httpRequestNode( + "Delete Monitor", + "DELETE", + "=https://api.parallel.ai/v1alpha/monitors/{{ $json.monitor_id }}", + pos(5, 1), + ), + googleSheetsNode("Update Registry", "update", "Registry", pos(7, 0)), + ]; + + const connections = buildConnections([ + connect("Daily Sync Trigger", "Read Vendor List"), + connect("Manual Trigger", "Read Vendor List"), + connect("Read Vendor List", "Read Previous Registry"), + connect("Read Previous Registry", "Compute Diff"), + connect("Compute Diff", "Loop Added Vendors", 0), + connect("Compute Diff", "Loop Removed Vendors", 0), + connect("Loop Added Vendors", "Build Monitor Payload", 0), + connect("Build Monitor Payload", "Create Monitor"), + connect("Create Monitor", "Loop Added Vendors"), + connect("Loop Added Vendors", "Update Registry", 1), + connect("Loop Removed Vendors", "Delete Monitor", 0), + connect("Delete Monitor", "Loop Removed Vendors"), + connect("Loop Removed Vendors", "Update Registry", 1), + ]); + + return buildWorkflow("Workflow 1: Vendor Ingestion & Sync", nodes, connections); +} + +const DIFF_CODE = ` +const incoming = $('Read Vendor List').all().map(i => i.json); +const previous = $('Read Previous Registry').all().map(i => i.json); + +const incomingMap = new Map(incoming.map(v => [v.vendor_domain, v])); +const previousMap = new Map(previous.map(v => [v.vendor_domain, v])); + +const added = incoming.filter(v => !previousMap.has(v.vendor_domain)); +const removed = previous.filter(v => !incomingMap.has(v.vendor_domain)); +const modified = incoming.filter(v => { + const prev = previousMap.get(v.vendor_domain); + return prev && (prev.monitoring_priority !== v.monitoring_priority || prev.vendor_category !== v.vendor_category); +}); + +return [{ json: { added, removed, modified, unchanged_count: incoming.length - added.length - modified.length } }]; +`; + +const MONITOR_PAYLOAD_CODE = ` +const vendor = $json; +const templates = [ + { dim: "legal", cat: "Legal & Regulatory", q: \`"\${vendor.vendor_name}" lawsuit OR litigation OR regulatory action\` }, + { dim: "cyber", cat: "Cybersecurity", q: \`"\${vendor.vendor_name}" data breach OR cybersecurity incident\` }, + { dim: "financial", cat: "Financial Health", q: \`"\${vendor.vendor_name}" bankruptcy OR financial distress OR credit downgrade\` }, + { dim: "leadership", cat: "Leadership & Governance", q: \`"\${vendor.vendor_name}" CEO departure OR executive change OR merger\` }, + { dim: "esg", cat: "ESG & Reputation", q: \`"\${vendor.vendor_name}" recall OR safety violation OR environmental fine\` }, +]; +const cadence = vendor.monitoring_priority === "low" ? "weekly" : "daily"; +const dims = vendor.monitoring_priority === "high" ? templates + : vendor.monitoring_priority === "medium" ? templates.slice(0, 3) + : [templates[0], templates[2]]; + +return dims.map(t => ({ + json: { + monitorPayload: { + query: t.q, cadence, + metadata: { vendor_name: vendor.vendor_name, vendor_domain: vendor.vendor_domain, monitor_category: t.cat, risk_dimension: t.dim }, + } + } +})); +`; diff --git a/typescript-recipes/parallel-n8n-procurement/src/workflows/generators/workflow2-deep-research.ts b/typescript-recipes/parallel-n8n-procurement/src/workflows/generators/workflow2-deep-research.ts new file mode 100644 index 0000000..c399d94 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/src/workflows/generators/workflow2-deep-research.ts @@ -0,0 +1,120 @@ +import { + resetNodeCounter, pos, scheduleNode, manualTriggerNode, + googleSheetsNode, codeNode, httpRequestNode, waitNode, ifNode, + executeWorkflowNode, connect, buildConnections, buildWorkflow, + type N8nWorkflow, +} from "../generator-utils.js"; + +export function generateDeepResearchWorkflow(): N8nWorkflow { + resetNodeCounter(); + + const nodes = [ + scheduleNode("Daily 6AM Trigger", 6, pos(0, 0)), + manualTriggerNode("Manual Trigger", pos(0, 1)), + googleSheetsNode("Read Registry", "read", "Registry", pos(1, 0)), + codeNode("Filter Due Vendors", FILTER_CODE, pos(2, 0)), + httpRequestNode( + "Create Task Group", + "POST", + "https://api.parallel.ai/v1beta/tasks/groups", + pos(3, 0), + '={{ JSON.stringify({}) }}', + ), + codeNode("Build Task Runs", BUILD_RUNS_CODE, pos(4, 0)), + httpRequestNode( + "Add Runs to Group", + "POST", + "=https://api.parallel.ai/v1beta/tasks/groups/{{ $('Create Task Group').item.json.taskgroup_id }}/runs", + pos(5, 0), + "={{ $json.runsPayload }}", + ), + waitNode("Wait 60s", 60, pos(6, 0)), + httpRequestNode( + "Poll Group Status", + "GET", + "=https://api.parallel.ai/v1beta/tasks/groups/{{ $('Create Task Group').item.json.taskgroup_id }}", + pos(7, 0), + ), + ifNode("Is Complete?", "={{ $json.status.is_active }}", "false", pos(8, 0)), + httpRequestNode( + "Get Results", + "GET", + "=https://api.parallel.ai/v1beta/tasks/groups/{{ $('Create Task Group').item.json.taskgroup_id }}/runs?include_output=true", + pos(9, 0), + ), + codeNode("Parse Results", PARSE_RESULTS_CODE, pos(10, 0)), + executeWorkflowNode("Score & Route (WF3)", pos(11, 0)), + googleSheetsNode("Update Research Dates", "update", "Registry", pos(12, 0)), + ]; + + const connections = buildConnections([ + connect("Daily 6AM Trigger", "Read Registry"), + connect("Manual Trigger", "Read Registry"), + connect("Read Registry", "Filter Due Vendors"), + connect("Filter Due Vendors", "Create Task Group"), + connect("Create Task Group", "Build Task Runs"), + connect("Build Task Runs", "Add Runs to Group"), + connect("Add Runs to Group", "Wait 60s"), + connect("Wait 60s", "Poll Group Status"), + connect("Poll Group Status", "Is Complete?"), + connect("Is Complete?", "Get Results", 0), // true → get results + connect("Is Complete?", "Wait 60s", 1), // false → loop back + connect("Get Results", "Parse Results"), + connect("Parse Results", "Score & Route (WF3)"), + connect("Score & Route (WF3)", "Update Research Dates"), + ]); + + return buildWorkflow("Workflow 2: Scheduled Deep Research", nodes, connections); +} + +const FILTER_CODE = ` +const today = new Date().toISOString().slice(0, 10); +const vendors = $input.all().map(i => i.json); +const due = vendors.filter(v => { + if (v.active === false || v.active === "false") return false; + if (!v.next_research_date) return true; + return v.next_research_date.slice(0, 10) <= today; +}); +return due.map(v => ({ json: v })); +`; + +const BUILD_RUNS_CODE = ` +const vendors = $('Filter Due Vendors').all().map(i => i.json); +const outputSchema = { + type: "json", + json_schema: { + type: "object", + properties: { + vendor_name: { type: "string" }, + overall_risk_level: { type: "string", enum: ["LOW","MEDIUM","HIGH","CRITICAL"] }, + financial_health: { type: "object", properties: { status: { type: "string" }, findings: { type: "string" }, severity: { type: "string" } }, required: ["status","findings","severity"] }, + legal_regulatory: { type: "object", properties: { status: { type: "string" }, findings: { type: "string" }, severity: { type: "string" } }, required: ["status","findings","severity"] }, + cybersecurity: { type: "object", properties: { status: { type: "string" }, findings: { type: "string" }, severity: { type: "string" } }, required: ["status","findings","severity"] }, + leadership_governance: { type: "object", properties: { status: { type: "string" }, findings: { type: "string" }, severity: { type: "string" } }, required: ["status","findings","severity"] }, + esg_reputation: { type: "object", properties: { status: { type: "string" }, findings: { type: "string" }, severity: { type: "string" } }, required: ["status","findings","severity"] }, + adverse_events: { type: "array", items: { type: "object" } }, + recommendation: { type: "string" }, + }, + required: ["vendor_name","overall_risk_level","financial_health","legal_regulatory","cybersecurity","leadership_governance","esg_reputation","adverse_events","recommendation"] + } +}; +const inputs = vendors.map(v => ({ + input: "Conduct a vendor risk assessment of " + v.vendor_name + " (" + v.vendor_domain + ").", + processor: "ultra8x" +})); +return [{ json: { runsPayload: JSON.stringify({ inputs, default_task_spec: { output_schema: outputSchema } }) } }]; +`; + +const PARSE_RESULTS_CODE = ` +const results = $input.all().map(i => i.json); +const vendors = $('Filter Due Vendors').all().map(i => i.json); +const parsed = results.filter(r => r.status === "completed" && r.output).map((r, i) => ({ + json: { + vendor: vendors[i] || {}, + research_output: r.output.content || r.output, + run_id: r.run_id, + status: r.status, + } +})); +return parsed; +`; diff --git a/typescript-recipes/parallel-n8n-procurement/src/workflows/generators/workflow3-risk-scoring.ts b/typescript-recipes/parallel-n8n-procurement/src/workflows/generators/workflow3-risk-scoring.ts new file mode 100644 index 0000000..984e867 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/src/workflows/generators/workflow3-risk-scoring.ts @@ -0,0 +1,95 @@ +import { + resetNodeCounter, pos, executeWorkflowTriggerNode, + codeNode, switchNode, slackNode, googleSheetsNode, + connect, buildConnections, buildWorkflow, + type N8nWorkflow, +} from "../generator-utils.js"; + +export function generateRiskScoringWorkflow(): N8nWorkflow { + resetNodeCounter(); + + const nodes = [ + executeWorkflowTriggerNode("Receive Research Output", pos(0, 0)), + codeNode("Risk Scorer", SCORING_CODE, pos(1, 0)), + switchNode("Route by Risk Level", "={{ $json.risk_level }}", ["CRITICAL", "HIGH", "MEDIUM", "LOW"], pos(2, 0)), + slackNode("Alert Critical", "#procurement-critical", pos(3, -2), + '={{ "\\ud83d\\udd34 CRITICAL: " + $json.vendor_name + " — " + $json.summary }}'), + slackNode("Alert High", "#procurement-alerts", pos(3, -1), + '={{ "\\ud83d\\udfe0 HIGH: " + $json.vendor_name + " — " + $json.summary }}'), + codeNode("Format Digest Entry", DIGEST_CODE, pos(3, 0)), + codeNode("Log Low Risk", 'return [$input.first()];', pos(3, 1)), + googleSheetsNode("Audit Log", "append", "Audit Log", pos(4, 0)), + ]; + + const connections = buildConnections([ + connect("Receive Research Output", "Risk Scorer"), + connect("Risk Scorer", "Route by Risk Level"), + connect("Route by Risk Level", "Alert Critical", 0), + connect("Route by Risk Level", "Alert High", 1), + connect("Route by Risk Level", "Format Digest Entry", 2), + connect("Route by Risk Level", "Log Low Risk", 3), + connect("Alert Critical", "Audit Log"), + connect("Alert High", "Audit Log"), + connect("Format Digest Entry", "Audit Log"), + connect("Log Low Risk", "Audit Log"), + ]); + + return buildWorkflow("Workflow 3: Risk Scoring & Slack Delivery", nodes, connections); +} + +const SCORING_CODE = ` +const input = $input.first().json; +const output = input.research_output || input; + +// Step 1: Severity aggregation +const dims = ['financial_health','legal_regulatory','cybersecurity','leadership_governance','esg_reputation']; +const counts = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 }; +const categories = []; +const mediumCats = []; + +for (const dim of dims) { + const sev = (output[dim]?.severity || 'LOW').toUpperCase(); + counts[sev] = (counts[sev] || 0) + 1; + if (sev === 'CRITICAL' || sev === 'HIGH') categories.push(dim); + if (sev === 'MEDIUM') mediumCats.push(dim); +} + +// Step 2: Risk level assignment +let risk_level, adverse_flag; +if (counts.CRITICAL > 0) { risk_level = 'CRITICAL'; adverse_flag = true; } +else if (counts.HIGH >= 1) { risk_level = 'HIGH'; adverse_flag = true; } +else if (counts.MEDIUM >= 3) { risk_level = 'MEDIUM'; adverse_flag = new Set(mediumCats).size >= 2; } +else if (counts.MEDIUM >= 1) { risk_level = 'MEDIUM'; adverse_flag = false; } +else { risk_level = 'LOW'; adverse_flag = false; } + +// Step 3: Overrides +const overrides = []; +if ((output.cybersecurity?.status || '').toUpperCase() === 'CRITICAL') { + risk_level = 'CRITICAL'; adverse_flag = true; overrides.push('active_data_breach'); +} +if ((output.legal_regulatory?.status || '').toUpperCase() === 'CRITICAL') { + if (['LOW','MEDIUM'].includes(risk_level)) risk_level = 'HIGH'; + adverse_flag = true; overrides.push('active_government_litigation'); +} + +// Step 4: Derived fields +const action_required = risk_level === 'HIGH' || risk_level === 'CRITICAL'; +const recMap = { LOW: 'continue_monitoring', MEDIUM: 'escalate_review', HIGH: 'initiate_contingency', CRITICAL: 'suspend_relationship' }; +const recommendation = recMap[risk_level]; +const vendor_name = output.vendor_name || input.vendor?.vendor_name || 'Unknown'; +const summary = vendor_name + ' assessed at ' + risk_level + ' risk. ' + (adverse_flag ? 'Adverse conditions detected.' : 'No adverse conditions.'); + +return [{ + json: { + vendor_name, risk_level, adverse_flag, action_required, recommendation, + summary, categories, severity_counts: counts, triggered_overrides: overrides, + assessment_date: new Date().toISOString().slice(0, 10), + source: input.source || 'deep_research', + } +}]; +`; + +const DIGEST_CODE = ` +const data = $input.first().json; +return [{ json: { ...data, digest_formatted: true } }]; +`; diff --git a/typescript-recipes/parallel-n8n-procurement/src/workflows/generators/workflow4-monitors.ts b/typescript-recipes/parallel-n8n-procurement/src/workflows/generators/workflow4-monitors.ts new file mode 100644 index 0000000..2916064 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/src/workflows/generators/workflow4-monitors.ts @@ -0,0 +1,100 @@ +import { + resetNodeCounter, pos, executeWorkflowTriggerNode, webhookNode, + codeNode, httpRequestNode, splitInBatchesNode, googleSheetsNode, + executeWorkflowNode, connect, buildConnections, buildWorkflow, + type N8nWorkflow, +} from "../generator-utils.js"; + +export function generateMonitorWorkflow(): N8nWorkflow { + resetNodeCounter(); + + const nodes = [ + // Sub-flow A: Deploy monitors (triggered by Execute Workflow) + executeWorkflowTriggerNode("Deploy Trigger", pos(0, -1)), + codeNode("Generate Monitor Queries", QUERY_GEN_CODE, pos(1, -1)), + splitInBatchesNode("Loop Monitors", 1, pos(2, -1)), + httpRequestNode( + "Create Monitor", + "POST", + "https://api.parallel.ai/v1alpha/monitors", + pos(3, -1), + "={{ JSON.stringify($json.monitorPayload) }}", + ), + googleSheetsNode("Record Monitor IDs", "append", "Monitors", pos(4, -1)), + + // Sub-flow B: Inbound webhook events + webhookNode("Monitor Event Webhook", "/webhook/monitor-events", pos(0, 1)), + codeNode("Parse Webhook Payload", PARSE_WEBHOOK_CODE, pos(1, 1)), + httpRequestNode( + "Fetch Event Details", + "GET", + "=https://api.parallel.ai/v1alpha/monitors/{{ $json.monitor_id }}/event_groups/{{ $json.event_group_id }}", + pos(2, 1), + ), + codeNode("Enrich & Classify Event", ENRICH_CODE, pos(3, 1)), + executeWorkflowNode("Score Event (WF3)", pos(4, 1)), + ]; + + const connections = buildConnections([ + // Sub-flow A + connect("Deploy Trigger", "Generate Monitor Queries"), + connect("Generate Monitor Queries", "Loop Monitors"), + connect("Loop Monitors", "Create Monitor", 0), + connect("Create Monitor", "Loop Monitors"), + connect("Loop Monitors", "Record Monitor IDs", 1), + // Sub-flow B + connect("Monitor Event Webhook", "Parse Webhook Payload"), + connect("Parse Webhook Payload", "Fetch Event Details"), + connect("Fetch Event Details", "Enrich & Classify Event"), + connect("Enrich & Classify Event", "Score Event (WF3)"), + ]); + + return buildWorkflow("Workflow 4: Monitor Deployment & Event Routing", nodes, connections); +} + +const QUERY_GEN_CODE = ` +const vendor = $input.first().json; +const templates = [ + { dim: "legal", cat: "Legal & Regulatory", q: '"' + vendor.vendor_name + '" lawsuit OR litigation OR regulatory action OR SEC investigation OR enforcement' }, + { dim: "cyber", cat: "Cybersecurity", q: '"' + vendor.vendor_name + '" data breach OR cybersecurity incident OR ransomware OR vulnerability disclosure' }, + { dim: "financial", cat: "Financial Health", q: '"' + vendor.vendor_name + '" bankruptcy OR financial distress OR credit downgrade OR debt default OR layoffs' }, + { dim: "leadership", cat: "Leadership & Governance", q: '"' + vendor.vendor_name + '" CEO departure OR executive change OR acquisition OR merger OR leadership' }, + { dim: "esg", cat: "ESG & Reputation", q: '"' + vendor.vendor_name + '" recall OR safety violation OR environmental fine OR labor dispute OR ESG controversy' }, +]; +const cadence = vendor.monitoring_priority === "low" ? "weekly" : "daily"; +const selected = vendor.monitoring_priority === "high" ? templates + : vendor.monitoring_priority === "medium" ? templates.slice(0, 3) + : [templates[0], templates[2]]; + +return selected.map(t => ({ + json: { + monitorPayload: { + query: t.q, cadence, + metadata: { vendor_name: vendor.vendor_name, vendor_domain: vendor.vendor_domain, monitor_category: t.cat, risk_dimension: t.dim }, + output_schema: { + type: "json", + json_schema: { type: "object", properties: { event_summary: { type: "string" }, severity: { type: "string" }, adverse: { type: "boolean" }, event_type: { type: "string" } }, required: ["event_summary","severity","adverse","event_type"] } + } + } + } +})); +`; + +const PARSE_WEBHOOK_CODE = ` +const payload = $input.first().json; +return [{ json: { monitor_id: payload.data.monitor_id, event_group_id: payload.data.event.event_group_id, metadata: payload.data.metadata || {} } }]; +`; + +const ENRICH_CODE = ` +const eventData = $input.first().json; +const webhookData = $('Parse Webhook Payload').item.json; +const events = eventData.events || []; +const eventEntry = events.find(e => e.type === 'event'); +let output = {}; +if (eventEntry && eventEntry.output && typeof eventEntry.output === 'object') { + output = eventEntry.output; +} else if (eventEntry && typeof eventEntry.output === 'string') { + output = { event_summary: eventEntry.output, severity: 'LOW', adverse: false, event_type: 'unknown' }; +} +return [{ json: { ...webhookData, ...output, source: 'monitor_event', event_date: eventEntry?.event_date, source_urls: eventEntry?.source_urls } }]; +`; diff --git a/typescript-recipes/parallel-n8n-procurement/src/workflows/generators/workflow5-adhoc.ts b/typescript-recipes/parallel-n8n-procurement/src/workflows/generators/workflow5-adhoc.ts new file mode 100644 index 0000000..cb78469 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/src/workflows/generators/workflow5-adhoc.ts @@ -0,0 +1,93 @@ +import { + resetNodeCounter, pos, webhookNode, codeNode, + httpRequestNode, slackNode, executeWorkflowNode, + connect, buildConnections, buildWorkflow, + type N8nWorkflow, +} from "../generator-utils.js"; + +export function generateAdHocWorkflow(): N8nWorkflow { + resetNodeCounter(); + + const nodes = [ + // Slash command entry + webhookNode("Slack Command", "/webhook/slack-command", pos(0, 0)), + codeNode("Parse Command", PARSE_CMD_CODE, pos(1, 0)), + slackNode("Send Acknowledgment", "={{ $json.channel_id }}", pos(2, 0), + '={{ "\\ud83d\\udd0d Starting deep research on *" + $json.vendor_name + "*. This typically takes 15-30 minutes..." }}'), + httpRequestNode( + "Start Research Task", + "POST", + "https://api.parallel.ai/v1/tasks/runs", + pos(3, 0), + "={{ $json.taskPayload }}", + "Creates a single deep research run with webhook callback", + ), + + // Result callback entry + webhookNode("Result Callback", "/webhook/adhoc-result", pos(0, 2)), + codeNode("Extract Run ID", 'const d = $input.first().json;\nreturn [{ json: { run_id: d.run_id || d.data?.run_id, status: d.status || d.data?.status } }];', pos(1, 2)), + httpRequestNode( + "Get Research Result", + "GET", + "=https://api.parallel.ai/v1/tasks/runs/{{ $json.run_id }}/result", + pos(2, 2), + ), + executeWorkflowNode("Score Result (WF3)", pos(3, 2)), + slackNode("Post Thread Reply", "={{ $json.channel_id }}", pos(4, 2), + '={{ $json.text }}'), + ]; + + const connections = buildConnections([ + connect("Slack Command", "Parse Command"), + connect("Parse Command", "Send Acknowledgment"), + connect("Send Acknowledgment", "Start Research Task"), + connect("Result Callback", "Extract Run ID"), + connect("Extract Run ID", "Get Research Result"), + connect("Get Research Result", "Score Result (WF3)"), + connect("Score Result (WF3)", "Post Thread Reply"), + ]); + + return buildWorkflow("Workflow 5: Ad-Hoc Research via Slack", nodes, connections); +} + +const PARSE_CMD_CODE = ` +const payload = $input.first().json; +const vendor_name = (payload.text || '').trim(); +if (!vendor_name) throw new Error('Vendor name is required. Usage: /vendor-research {vendor_name}'); + +const prompt = 'Conduct a comprehensive vendor risk assessment of "' + vendor_name + '". ' + + 'Investigate financial health, legal & regulatory, cybersecurity, leadership & governance, ESG & reputation. ' + + 'Classify each finding by severity (LOW/MEDIUM/HIGH/CRITICAL) and include source URLs.'; + +const outputSchema = { + type: "json", + json_schema: { + type: "object", + properties: { + vendor_name: { type: "string" }, + overall_risk_level: { type: "string", enum: ["LOW","MEDIUM","HIGH","CRITICAL"] }, + financial_health: { type: "object", properties: { status: { type: "string" }, findings: { type: "string" }, severity: { type: "string" } } }, + legal_regulatory: { type: "object", properties: { status: { type: "string" }, findings: { type: "string" }, severity: { type: "string" } } }, + cybersecurity: { type: "object", properties: { status: { type: "string" }, findings: { type: "string" }, severity: { type: "string" } } }, + leadership_governance: { type: "object", properties: { status: { type: "string" }, findings: { type: "string" }, severity: { type: "string" } } }, + esg_reputation: { type: "object", properties: { status: { type: "string" }, findings: { type: "string" }, severity: { type: "string" } } }, + adverse_events: { type: "array", items: { type: "object" } }, + recommendation: { type: "string" }, + }, + required: ["vendor_name","overall_risk_level","recommendation"] + } +}; + +return [{ json: { + vendor_name, + channel_id: payload.channel_id || payload.channel, + user_name: payload.user_name || payload.user, + response_url: payload.response_url, + taskPayload: JSON.stringify({ + input: prompt, + processor: "ultra8x", + task_spec: { output_schema: outputSchema }, + webhook: { url: $vars.N8N_WEBHOOK_BASE_URL + "/webhook/adhoc-result", events: ["task_run.status"] } + }) +} }]; +`; diff --git a/typescript-recipes/parallel-n8n-procurement/system-architecture.excalidraw b/typescript-recipes/parallel-n8n-procurement/system-architecture.excalidraw new file mode 100644 index 0000000..763756b --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/system-architecture.excalidraw @@ -0,0 +1,186 @@ +{ + "type": "excalidraw", + "version": 2, + "source": "https://excalidraw.com", + "elements": [ + + {"id":"sec1_bg","type":"rectangle","x":40,"y":40,"width":760,"height":340,"angle":0,"strokeColor":"#e67700","backgroundColor":"#fff9db","fillStyle":"solid","strokeWidth":1,"strokeStyle":"dashed","roughness":0,"opacity":30,"groupIds":["sec1"],"frameId":null,"roundness":{"type":3},"seed":100,"version":1,"versionNonce":200,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false}, + {"id":"sec1_title","type":"text","x":60,"y":52,"width":200,"height":28,"angle":0,"strokeColor":"#e67700","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec1"],"frameId":null,"roundness":null,"seed":101,"version":1,"versionNonce":201,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"YOUR TEAM","fontSize":22,"fontFamily":2,"textAlign":"left","verticalAlign":"top","containerId":null,"originalText":"YOUR TEAM","autoResize":true,"lineHeight":1.25}, + {"id":"sec1_sub","type":"text","x":60,"y":80,"width":300,"height":16,"angle":0,"strokeColor":"#868e96","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec1"],"frameId":null,"roundness":null,"seed":102,"version":1,"versionNonce":202,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"The only things humans touch","fontSize":13,"fontFamily":2,"textAlign":"left","verticalAlign":"top","containerId":null,"originalText":"The only things humans touch","autoResize":true,"lineHeight":1.25}, + + {"id":"sec1_gsheets","type":"rectangle","x":60,"y":105,"width":420,"height":255,"angle":0,"strokeColor":"#e67700","backgroundColor":"#fff3bf","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":["sec1"],"frameId":null,"roundness":{"type":3},"seed":103,"version":1,"versionNonce":203,"isDeleted":false,"boundElements":[{"id":"sec1_gsheets_t","type":"text"},{"id":"arrow_gs_wf1","type":"arrow"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"sec1_gsheets_t","type":"text","x":70,"y":112,"width":400,"height":240,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec1"],"frameId":null,"roundness":null,"seed":104,"version":1,"versionNonce":204,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"Google Sheets (4 Tabs)\n\nVendors Tab:\n vendor_name | vendor_domain | vendor_category\n risk_tier_override | active | monitoring_priority\n\nRegistry Tab:\n ...vendors[] + monitor_ids + next_research_date\n\nAudit Log Tab:\n timestamp | vendor_name | risk_level | adverse_flag\n categories | summary | run_id | source\n\nMonitors Tab:\n monitor_id | vendor_domain | risk_dimension | cadence","fontSize":12,"fontFamily":3,"textAlign":"left","verticalAlign":"top","containerId":"sec1_gsheets","originalText":"Google Sheets (4 Tabs)\n\nVendors Tab:\n vendor_name | vendor_domain | vendor_category\n risk_tier_override | active | monitoring_priority\n\nRegistry Tab:\n ...vendors[] + monitor_ids + next_research_date\n\nAudit Log Tab:\n timestamp | vendor_name | risk_level | adverse_flag\n categories | summary | run_id | source\n\nMonitors Tab:\n monitor_id | vendor_domain | risk_dimension | cadence","autoResize":true,"lineHeight":1.25}, + + {"id":"sec1_cats","type":"rectangle","x":500,"y":105,"width":280,"height":140,"angle":0,"strokeColor":"#e67700","backgroundColor":"#fff3bf","fillStyle":"solid","strokeWidth":1,"strokeStyle":"dashed","roughness":0,"opacity":100,"groupIds":["sec1"],"frameId":null,"roundness":{"type":3},"seed":105,"version":1,"versionNonce":205,"isDeleted":false,"boundElements":[{"id":"sec1_cats_t","type":"text"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"sec1_cats_t","type":"text","x":510,"y":112,"width":260,"height":126,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec1"],"frameId":null,"roundness":null,"seed":106,"version":1,"versionNonce":206,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"Vendor Categories\n\ntechnology | financial_services\nmanufacturing | healthcare\nprofessional_services | other\n\nPriority: high / medium / low\nOverride: acts as scoring floor","fontSize":12,"fontFamily":3,"textAlign":"left","verticalAlign":"top","containerId":"sec1_cats","originalText":"Vendor Categories\n\ntechnology | financial_services\nmanufacturing | healthcare\nprofessional_services | other\n\nPriority: high / medium / low\nOverride: acts as scoring floor","autoResize":true,"lineHeight":1.25}, + + {"id":"sec1_slash","type":"rectangle","x":500,"y":260,"width":280,"height":100,"angle":0,"strokeColor":"#e67700","backgroundColor":"#fff3bf","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":["sec1"],"frameId":null,"roundness":{"type":3},"seed":107,"version":1,"versionNonce":207,"isDeleted":false,"boundElements":[{"id":"sec1_slash_t","type":"text"},{"id":"arrow_slash_wf5","type":"arrow"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"sec1_slash_t","type":"text","x":510,"y":268,"width":260,"height":84,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec1"],"frameId":null,"roundness":null,"seed":108,"version":1,"versionNonce":208,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"/vendor-research {company}\n\nAny team member, any Slack channel\nTriggers WF5: Ad-Hoc Research\nResults posted as thread reply","fontSize":13,"fontFamily":3,"textAlign":"left","verticalAlign":"top","containerId":"sec1_slash","originalText":"/vendor-research {company}\n\nAny team member, any Slack channel\nTriggers WF5: Ad-Hoc Research\nResults posted as thread reply","autoResize":true,"lineHeight":1.25}, + + {"id":"sec8_bg","type":"rectangle","x":1720,"y":40,"width":460,"height":340,"angle":0,"strokeColor":"#1971c2","backgroundColor":"#e7f5ff","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec8"],"frameId":null,"roundness":{"type":3},"seed":800,"version":1,"versionNonce":900,"isDeleted":false,"boundElements":[{"id":"sec8_t","type":"text"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"sec8_t","type":"text","x":1740,"y":52,"width":420,"height":316,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec8"],"frameId":null,"roundness":null,"seed":801,"version":1,"versionNonce":901,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"SCALE & SETUP\n\n15 to 3,000+ vendors supported\n50 vendors per Task Group batch\n7-day research rotation cycle\n\n5 monitors per HIGH vendor (daily)\n3 monitors per MEDIUM vendor (daily)\n2 monitors per LOW vendor (weekly)\n\n24-hour event dedup window\n1,000 max runs per API request\n60s poll interval / 3,600s timeout\n3 retries with exponential backoff\n\n30-minute setup, first results in < 1 hour","fontSize":14,"fontFamily":2,"textAlign":"left","verticalAlign":"top","containerId":"sec8_bg","originalText":"SCALE & SETUP\n\n15 to 3,000+ vendors supported\n50 vendors per Task Group batch\n7-day research rotation cycle\n\n5 monitors per HIGH vendor (daily)\n3 monitors per MEDIUM vendor (daily)\n2 monitors per LOW vendor (weekly)\n\n24-hour event dedup window\n1,000 max runs per API request\n60s poll interval / 3,600s timeout\n3 retries with exponential backoff\n\n30-minute setup, first results in < 1 hour","autoResize":true,"lineHeight":1.25}, + + {"id":"sec9_bg","type":"rectangle","x":1720,"y":400,"width":460,"height":340,"angle":0,"strokeColor":"#1971c2","backgroundColor":"#a5d8ff","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":["sec9"],"frameId":null,"roundness":{"type":3},"seed":810,"version":1,"versionNonce":910,"isDeleted":false,"boundElements":[{"id":"sec9_t","type":"text"},{"id":"arrow_slash_wf5","type":"arrow"},{"id":"arrow_wf5_taskapi","type":"arrow"},{"id":"arrow_wf5_slack","type":"arrow"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"sec9_t","type":"text","x":1740,"y":412,"width":420,"height":316,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec9"],"frameId":null,"roundness":null,"seed":811,"version":1,"versionNonce":911,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"WF5: AD-HOC RESEARCH\n\n1. /vendor-research Acme Corp\n |\n2. Parse command, lookup vendor in registry\n |\n3. Ack in Slack thread:\n \"Starting research... 15-30 min\"\n |\n4. Call Parallel AI Task API\n (single run, same prompt as WF2)\n |\n5. Webhook callback -> score result\n |\n6. Post full report as thread reply\n\nUses same scoring engine as WF2 + WF4","fontSize":13,"fontFamily":3,"textAlign":"left","verticalAlign":"top","containerId":"sec9_bg","originalText":"WF5: AD-HOC RESEARCH\n\n1. /vendor-research Acme Corp\n |\n2. Parse command, lookup vendor in registry\n |\n3. Ack in Slack thread:\n \"Starting research... 15-30 min\"\n |\n4. Call Parallel AI Task API\n (single run, same prompt as WF2)\n |\n5. Webhook callback -> score result\n |\n6. Post full report as thread reply\n\nUses same scoring engine as WF2 + WF4","autoResize":true,"lineHeight":1.25}, + + {"id":"sec2_bg","type":"rectangle","x":40,"y":400,"width":1100,"height":700,"angle":0,"strokeColor":"#1971c2","backgroundColor":"#e7f5ff","fillStyle":"solid","strokeWidth":1,"strokeStyle":"dashed","roughness":0,"opacity":15,"groupIds":["sec2"],"frameId":null,"roundness":{"type":3},"seed":200,"version":1,"versionNonce":300,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false}, + {"id":"sec2_title","type":"text","x":60,"y":412,"width":450,"height":28,"angle":0,"strokeColor":"#1971c2","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec2"],"frameId":null,"roundness":null,"seed":201,"version":1,"versionNonce":301,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"VENDOR INTELLIGENCE PIPELINE","fontSize":22,"fontFamily":2,"textAlign":"left","verticalAlign":"top","containerId":null,"originalText":"VENDOR INTELLIGENCE PIPELINE","autoResize":true,"lineHeight":1.25}, + {"id":"sec2_sub","type":"text","x":60,"y":440,"width":350,"height":16,"angle":0,"strokeColor":"#868e96","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec2"],"frameId":null,"roundness":null,"seed":202,"version":1,"versionNonce":302,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"Automated daily research + sync cycle","fontSize":13,"fontFamily":2,"textAlign":"left","verticalAlign":"top","containerId":null,"originalText":"Automated daily research + sync cycle","autoResize":true,"lineHeight":1.25}, + + {"id":"wf1_box","type":"rectangle","x":60,"y":465,"width":1060,"height":200,"angle":0,"strokeColor":"#1971c2","backgroundColor":"#a5d8ff","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":["sec2"],"frameId":null,"roundness":{"type":3},"seed":210,"version":1,"versionNonce":310,"isDeleted":false,"boundElements":[{"id":"arrow_gs_wf1","type":"arrow"},{"id":"arrow_wf1_wf4","type":"arrow"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"wf1_title","type":"text","x":80,"y":475,"width":400,"height":20,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec2"],"frameId":null,"roundness":null,"seed":211,"version":1,"versionNonce":311,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"WF1: VENDOR SYNC CRON: every 6 hours","fontSize":14,"fontFamily":2,"textAlign":"left","verticalAlign":"top","containerId":null,"originalText":"WF1: VENDOR SYNC CRON: every 6 hours","autoResize":true,"lineHeight":1.25}, + {"id":"wf1_diff_label","type":"text","x":80,"y":500,"width":200,"height":16,"angle":0,"strokeColor":"#495057","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec2"],"frameId":null,"roundness":null,"seed":212,"version":1,"versionNonce":312,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"Diff Engine: compare incoming vs registry","fontSize":12,"fontFamily":2,"textAlign":"left","verticalAlign":"top","containerId":null,"originalText":"Diff Engine: compare incoming vs registry","autoResize":true,"lineHeight":1.25}, + + {"id":"diff_added","type":"rectangle","x":80,"y":525,"width":310,"height":55,"angle":0,"strokeColor":"#2f9e44","backgroundColor":"#d3f9d8","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":["sec2"],"frameId":null,"roundness":{"type":3},"seed":213,"version":1,"versionNonce":313,"isDeleted":false,"boundElements":[{"id":"diff_added_t","type":"text"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"diff_added_t","type":"text","x":90,"y":533,"width":290,"height":40,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec2"],"frameId":null,"roundness":null,"seed":214,"version":1,"versionNonce":314,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"ADDED vendors\nNew rows -> deploy monitors for each","fontSize":13,"fontFamily":1,"textAlign":"center","verticalAlign":"middle","containerId":"diff_added","originalText":"ADDED vendors\nNew rows -> deploy monitors for each","autoResize":true,"lineHeight":1.25}, + + {"id":"diff_removed","type":"rectangle","x":410,"y":525,"width":310,"height":55,"angle":0,"strokeColor":"#c92a2a","backgroundColor":"#ffe3e3","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":["sec2"],"frameId":null,"roundness":{"type":3},"seed":215,"version":1,"versionNonce":315,"isDeleted":false,"boundElements":[{"id":"diff_removed_t","type":"text"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"diff_removed_t","type":"text","x":420,"y":533,"width":290,"height":40,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec2"],"frameId":null,"roundness":null,"seed":216,"version":1,"versionNonce":316,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"REMOVED vendors\nDropped rows -> delete all monitors","fontSize":13,"fontFamily":1,"textAlign":"center","verticalAlign":"middle","containerId":"diff_removed","originalText":"REMOVED vendors\nDropped rows -> delete all monitors","autoResize":true,"lineHeight":1.25}, + + {"id":"diff_modified","type":"rectangle","x":740,"y":525,"width":360,"height":55,"angle":0,"strokeColor":"#e67700","backgroundColor":"#fff3bf","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":["sec2"],"frameId":null,"roundness":{"type":3},"seed":217,"version":1,"versionNonce":317,"isDeleted":false,"boundElements":[{"id":"diff_modified_t","type":"text"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"diff_modified_t","type":"text","x":750,"y":533,"width":340,"height":40,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec2"],"frameId":null,"roundness":null,"seed":218,"version":1,"versionNonce":318,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"MODIFIED vendors\npriority / category / override / active changes","fontSize":13,"fontFamily":1,"textAlign":"center","verticalAlign":"middle","containerId":"diff_modified","originalText":"MODIFIED vendors\npriority / category / override / active changes","autoResize":true,"lineHeight":1.25}, + + {"id":"wf1_note","type":"text","x":80,"y":595,"width":500,"height":60,"angle":0,"strokeColor":"#868e96","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec2"],"frameId":null,"roundness":null,"seed":219,"version":1,"versionNonce":319,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"Modified priority -> remove old monitors + deploy new monitor set\nFailed vendors' dates NOT advanced (retry next cycle)\nRegistry persisted with monitor IDs + sync timestamp","fontSize":11,"fontFamily":2,"textAlign":"left","verticalAlign":"top","containerId":null,"originalText":"Modified priority -> remove old monitors + deploy new monitor set\nFailed vendors' dates NOT advanced (retry next cycle)\nRegistry persisted with monitor IDs + sync timestamp","autoResize":true,"lineHeight":1.25}, + + {"id":"wf2_box","type":"rectangle","x":60,"y":680,"width":1060,"height":400,"angle":0,"strokeColor":"#1971c2","backgroundColor":"#a5d8ff","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":["sec2"],"frameId":null,"roundness":{"type":3},"seed":220,"version":1,"versionNonce":320,"isDeleted":false,"boundElements":[{"id":"arrow_wf2_taskapi","type":"arrow"},{"id":"arrow_wf2_wf3","type":"arrow"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"wf2_title","type":"text","x":80,"y":690,"width":450,"height":20,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec2"],"frameId":null,"roundness":null,"seed":221,"version":1,"versionNonce":321,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"WF2: DEEP RESEARCH CRON: daily 2 AM UTC","fontSize":14,"fontFamily":2,"textAlign":"left","verticalAlign":"top","containerId":null,"originalText":"WF2: DEEP RESEARCH CRON: daily 2 AM UTC","autoResize":true,"lineHeight":1.25}, + + {"id":"wf2_batch","type":"rectangle","x":80,"y":720,"width":480,"height":70,"angle":0,"strokeColor":"#1971c2","backgroundColor":"#d0ebff","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec2"],"frameId":null,"roundness":{"type":3},"seed":222,"version":1,"versionNonce":322,"isDeleted":false,"boundElements":[{"id":"wf2_batch_t","type":"text"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"wf2_batch_t","type":"text","x":90,"y":728,"width":460,"height":54,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec2"],"frameId":null,"roundness":null,"seed":223,"version":1,"versionNonce":323,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"Batch Planner: filter vendors where next_research_date <= today\nSplit into batches of 50 -> send each as Parallel AI TaskGroup\n7-day cycle: advance next_research_date for succeeded vendors only","fontSize":12,"fontFamily":3,"textAlign":"left","verticalAlign":"middle","containerId":"wf2_batch","originalText":"Batch Planner: filter vendors where next_research_date <= today\nSplit into batches of 50 -> send each as Parallel AI TaskGroup\n7-day cycle: advance next_research_date for succeeded vendors only","autoResize":true,"lineHeight":1.25}, + + {"id":"wf2_dim_label","type":"text","x":80,"y":805,"width":350,"height":20,"angle":0,"strokeColor":"#c92a2a","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec2"],"frameId":null,"roundness":null,"seed":224,"version":1,"versionNonce":324,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"6 RISK DIMENSIONS RESEARCHED PER VENDOR","fontSize":14,"fontFamily":2,"textAlign":"left","verticalAlign":"top","containerId":null,"originalText":"6 RISK DIMENSIONS RESEARCHED PER VENDOR","autoResize":true,"lineHeight":1.25}, + + {"id":"dim1","type":"rectangle","x":80,"y":830,"width":330,"height":50,"angle":0,"strokeColor":"#c92a2a","backgroundColor":"#fff5f5","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec2"],"frameId":null,"roundness":{"type":3},"seed":225,"version":1,"versionNonce":325,"isDeleted":false,"boundElements":[{"id":"dim1_t","type":"text"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"dim1_t","type":"text","x":90,"y":837,"width":310,"height":36,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec2"],"frameId":null,"roundness":null,"seed":226,"version":1,"versionNonce":326,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"1. Financial Health\nearnings, credit ratings, debt, bankruptcy","fontSize":12,"fontFamily":1,"textAlign":"left","verticalAlign":"middle","containerId":"dim1","originalText":"1. Financial Health\nearnings, credit ratings, debt, bankruptcy","autoResize":true,"lineHeight":1.25}, + + {"id":"dim2","type":"rectangle","x":425,"y":830,"width":330,"height":50,"angle":0,"strokeColor":"#c92a2a","backgroundColor":"#fff5f5","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec2"],"frameId":null,"roundness":{"type":3},"seed":227,"version":1,"versionNonce":327,"isDeleted":false,"boundElements":[{"id":"dim2_t","type":"text"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"dim2_t","type":"text","x":435,"y":837,"width":310,"height":36,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec2"],"frameId":null,"roundness":null,"seed":228,"version":1,"versionNonce":328,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"2. Legal & Regulatory\nlitigation, SEC, sanctions, enforcement","fontSize":12,"fontFamily":1,"textAlign":"left","verticalAlign":"middle","containerId":"dim2","originalText":"2. Legal & Regulatory\nlitigation, SEC, sanctions, enforcement","autoResize":true,"lineHeight":1.25}, + + {"id":"dim3","type":"rectangle","x":770,"y":830,"width":330,"height":50,"angle":0,"strokeColor":"#c92a2a","backgroundColor":"#fff5f5","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec2"],"frameId":null,"roundness":{"type":3},"seed":229,"version":1,"versionNonce":329,"isDeleted":false,"boundElements":[{"id":"dim3_t","type":"text"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"dim3_t","type":"text","x":780,"y":837,"width":310,"height":36,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec2"],"frameId":null,"roundness":null,"seed":230,"version":1,"versionNonce":330,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"3. Cybersecurity\nbreaches, vulnerabilities, SOC2, ISO27001","fontSize":12,"fontFamily":1,"textAlign":"left","verticalAlign":"middle","containerId":"dim3","originalText":"3. Cybersecurity\nbreaches, vulnerabilities, SOC2, ISO27001","autoResize":true,"lineHeight":1.25}, + + {"id":"dim4","type":"rectangle","x":80,"y":890,"width":330,"height":50,"angle":0,"strokeColor":"#c92a2a","backgroundColor":"#fff5f5","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec2"],"frameId":null,"roundness":{"type":3},"seed":231,"version":1,"versionNonce":331,"isDeleted":false,"boundElements":[{"id":"dim4_t","type":"text"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"dim4_t","type":"text","x":90,"y":897,"width":310,"height":36,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec2"],"frameId":null,"roundness":null,"seed":232,"version":1,"versionNonce":332,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"4. Leadership & Governance\nexec turnover, board changes, M&A","fontSize":12,"fontFamily":1,"textAlign":"left","verticalAlign":"middle","containerId":"dim4","originalText":"4. Leadership & Governance\nexec turnover, board changes, M&A","autoResize":true,"lineHeight":1.25}, + + {"id":"dim5","type":"rectangle","x":425,"y":890,"width":330,"height":50,"angle":0,"strokeColor":"#c92a2a","backgroundColor":"#fff5f5","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec2"],"frameId":null,"roundness":{"type":3},"seed":233,"version":1,"versionNonce":333,"isDeleted":false,"boundElements":[{"id":"dim5_t","type":"text"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"dim5_t","type":"text","x":435,"y":897,"width":310,"height":36,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec2"],"frameId":null,"roundness":null,"seed":234,"version":1,"versionNonce":334,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"5. ESG & Reputation\nenvironmental fines, labor disputes, recalls","fontSize":12,"fontFamily":1,"textAlign":"left","verticalAlign":"middle","containerId":"dim5","originalText":"5. ESG & Reputation\nenvironmental fines, labor disputes, recalls","autoResize":true,"lineHeight":1.25}, + + {"id":"dim6","type":"rectangle","x":770,"y":890,"width":330,"height":50,"angle":0,"strokeColor":"#c92a2a","backgroundColor":"#fff5f5","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec2"],"frameId":null,"roundness":{"type":3},"seed":235,"version":1,"versionNonce":335,"isDeleted":false,"boundElements":[{"id":"dim6_t","type":"text"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"dim6_t","type":"text","x":780,"y":897,"width":310,"height":36,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec2"],"frameId":null,"roundness":null,"seed":236,"version":1,"versionNonce":336,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"6. Adverse Events\nbreaking negative news, material changes","fontSize":12,"fontFamily":1,"textAlign":"left","verticalAlign":"middle","containerId":"dim6","originalText":"6. Adverse Events\nbreaking negative news, material changes","autoResize":true,"lineHeight":1.25}, + + {"id":"wf2_output","type":"text","x":80,"y":955,"width":700,"height":100,"angle":0,"strokeColor":"#868e96","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec2"],"frameId":null,"roundness":null,"seed":237,"version":1,"versionNonce":337,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"Structured JSON output per vendor:\n{ vendor_name, assessment_date, overall_risk_level,\n financial_health: {status, findings, severity}, legal_regulatory, cybersecurity,\n leadership_governance, esg_reputation,\n adverse_events: [{title, date, category, severity, source_url, description}],\n recommendation: APPROVE | MONITOR | ESCALATE | REJECT }","fontSize":11,"fontFamily":3,"textAlign":"left","verticalAlign":"top","containerId":null,"originalText":"Structured JSON output per vendor:\n{ vendor_name, assessment_date, overall_risk_level,\n financial_health: {status, findings, severity}, legal_regulatory, cybersecurity,\n leadership_governance, esg_reputation,\n adverse_events: [{title, date, category, severity, source_url, description}],\n recommendation: APPROVE | MONITOR | ESCALATE | REJECT }","autoResize":true,"lineHeight":1.25}, + + {"id":"sec3_bg","type":"rectangle","x":1180,"y":400,"width":520,"height":700,"angle":0,"strokeColor":"#6741d9","backgroundColor":"#f3d9fa","fillStyle":"solid","strokeWidth":1,"strokeStyle":"dashed","roughness":0,"opacity":15,"groupIds":["sec3"],"frameId":null,"roundness":{"type":3},"seed":300,"version":1,"versionNonce":400,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false}, + {"id":"sec3_title","type":"text","x":1200,"y":412,"width":200,"height":28,"angle":0,"strokeColor":"#6741d9","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec3"],"frameId":null,"roundness":null,"seed":301,"version":1,"versionNonce":401,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"PARALLEL AI","fontSize":22,"fontFamily":2,"textAlign":"left","verticalAlign":"top","containerId":null,"originalText":"PARALLEL AI","autoResize":true,"lineHeight":1.25}, + {"id":"sec3_sub","type":"text","x":1200,"y":440,"width":350,"height":16,"angle":0,"strokeColor":"#868e96","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec3"],"frameId":null,"roundness":null,"seed":302,"version":1,"versionNonce":402,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"Web intelligence infrastructure","fontSize":13,"fontFamily":2,"textAlign":"left","verticalAlign":"top","containerId":null,"originalText":"Web intelligence infrastructure","autoResize":true,"lineHeight":1.25}, + + {"id":"taskapi_box","type":"rectangle","x":1200,"y":465,"width":480,"height":280,"angle":0,"strokeColor":"#6741d9","backgroundColor":"#d0bfff","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":["sec3"],"frameId":null,"roundness":{"type":3},"seed":310,"version":1,"versionNonce":410,"isDeleted":false,"boundElements":[{"id":"taskapi_t","type":"text"},{"id":"arrow_wf2_taskapi","type":"arrow"},{"id":"arrow_wf5_taskapi","type":"arrow"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"taskapi_t","type":"text","x":1210,"y":473,"width":460,"height":264,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec3"],"frameId":null,"roundness":null,"seed":311,"version":1,"versionNonce":411,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"TASK API (Batch Research)\nPOST /v1beta/tasks/groups\n\nFlow:\n createTaskGroup()\n |\n addRunsToGroup() max 1,000 runs/request\n | processor: ultra8x\n pollUntilComplete() interval: 60s\n timeout: 3,600s\n\nRetry: 3x exponential backoff (1s, 2s, 4s)\non 429 / 500 / 502 / 503\n\nReturns: structured JSON per vendor\n(6 dimensions + adverse events + recommendation)","fontSize":12,"fontFamily":3,"textAlign":"left","verticalAlign":"top","containerId":"taskapi_box","originalText":"TASK API (Batch Research)\nPOST /v1beta/tasks/groups\n\nFlow:\n createTaskGroup()\n |\n addRunsToGroup() max 1,000 runs/request\n | processor: ultra8x\n pollUntilComplete() interval: 60s\n timeout: 3,600s\n\nRetry: 3x exponential backoff (1s, 2s, 4s)\non 429 / 500 / 502 / 503\n\nReturns: structured JSON per vendor\n(6 dimensions + adverse events + recommendation)","autoResize":true,"lineHeight":1.25}, + + {"id":"monapi_box","type":"rectangle","x":1200,"y":760,"width":480,"height":320,"angle":0,"strokeColor":"#6741d9","backgroundColor":"#d0bfff","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":["sec3"],"frameId":null,"roundness":{"type":3},"seed":320,"version":1,"versionNonce":420,"isDeleted":false,"boundElements":[{"id":"monapi_t","type":"text"},{"id":"arrow_wf4_monapi","type":"arrow"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"monapi_t","type":"text","x":1210,"y":768,"width":460,"height":304,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec3"],"frameId":null,"roundness":null,"seed":321,"version":1,"versionNonce":421,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"MONITOR API (Continuous Surveillance)\nPOST/GET /v1alpha/monitors\n\nOperations:\n createMonitor() - query, cadence, webhook, metadata\n listMonitors() - fleet inventory\n deleteMonitor() - cleanup\n getEvents() - event retrieval\n\nWebhook: monitor.event.detected\n -> POST to n8n webhook URL\n -> EventHandler enriches + dedup + scores\n\nMonitor metadata per monitor:\n { vendor_name, vendor_domain,\n monitor_category, risk_dimension }\n\nOutput schema per event:\n { event_summary, severity, adverse, event_type }","fontSize":12,"fontFamily":3,"textAlign":"left","verticalAlign":"top","containerId":"monapi_box","originalText":"MONITOR API (Continuous Surveillance)\nPOST/GET /v1alpha/monitors\n\nOperations:\n createMonitor() - query, cadence, webhook, metadata\n listMonitors() - fleet inventory\n deleteMonitor() - cleanup\n getEvents() - event retrieval\n\nWebhook: monitor.event.detected\n -> POST to n8n webhook URL\n -> EventHandler enriches + dedup + scores\n\nMonitor metadata per monitor:\n { vendor_name, vendor_domain,\n monitor_category, risk_dimension }\n\nOutput schema per event:\n { event_summary, severity, adverse, event_type }","autoResize":true,"lineHeight":1.25}, + + {"id":"sec5_bg","type":"rectangle","x":40,"y":1120,"width":540,"height":560,"angle":0,"strokeColor":"#c92a2a","backgroundColor":"#ffe3e3","fillStyle":"solid","strokeWidth":1,"strokeStyle":"dashed","roughness":0,"opacity":15,"groupIds":["sec5"],"frameId":null,"roundness":{"type":3},"seed":500,"version":1,"versionNonce":600,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false}, + {"id":"sec5_title","type":"text","x":60,"y":1132,"width":400,"height":28,"angle":0,"strokeColor":"#c92a2a","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec5"],"frameId":null,"roundness":null,"seed":501,"version":1,"versionNonce":601,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"RISK SCORING ENGINE (WF3)","fontSize":22,"fontFamily":2,"textAlign":"left","verticalAlign":"top","containerId":null,"originalText":"RISK SCORING ENGINE (WF3)","autoResize":true,"lineHeight":1.25}, + {"id":"sec5_sub","type":"text","x":60,"y":1160,"width":350,"height":16,"angle":0,"strokeColor":"#868e96","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec5"],"frameId":null,"roundness":null,"seed":502,"version":1,"versionNonce":602,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"Deterministic rules -- NOT AI. Auditable + explainable.","fontSize":13,"fontFamily":2,"textAlign":"left","verticalAlign":"top","containerId":null,"originalText":"Deterministic rules -- NOT AI. Auditable + explainable.","autoResize":true,"lineHeight":1.25}, + + {"id":"scoring_table","type":"rectangle","x":60,"y":1185,"width":500,"height":200,"angle":0,"strokeColor":"#c92a2a","backgroundColor":"#ffe3e3","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec5"],"frameId":null,"roundness":{"type":3},"seed":510,"version":1,"versionNonce":610,"isDeleted":false,"boundElements":[{"id":"scoring_table_t","type":"text"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"scoring_table_t","type":"text","x":70,"y":1193,"width":480,"height":184,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec5"],"frameId":null,"roundness":null,"seed":511,"version":1,"versionNonce":611,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"SCORING RULES\n\nCONDITION LEVEL ADVERSE\n-------------------------------------------------------\nAny CRITICAL dimension CRITICAL true\n2+ HIGH dimensions HIGH true\n1 HIGH dimension HIGH true\n3+ MEDIUM across 2+ categories MEDIUM true\n1-2 MEDIUM dimensions MEDIUM false\nAll LOW LOW false","fontSize":13,"fontFamily":3,"textAlign":"left","verticalAlign":"top","containerId":"scoring_table","originalText":"SCORING RULES\n\nCONDITION LEVEL ADVERSE\n-------------------------------------------------------\nAny CRITICAL dimension CRITICAL true\n2+ HIGH dimensions HIGH true\n1 HIGH dimension HIGH true\n3+ MEDIUM across 2+ categories MEDIUM true\n1-2 MEDIUM dimensions MEDIUM false\nAll LOW LOW false","autoResize":true,"lineHeight":1.25}, + + {"id":"overrides_box","type":"rectangle","x":60,"y":1400,"width":500,"height":110,"angle":0,"strokeColor":"#c92a2a","backgroundColor":"#fff5f5","fillStyle":"solid","strokeWidth":1,"strokeStyle":"dashed","roughness":0,"opacity":100,"groupIds":["sec5"],"frameId":null,"roundness":{"type":3},"seed":520,"version":1,"versionNonce":620,"isDeleted":false,"boundElements":[{"id":"overrides_t","type":"text"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"overrides_t","type":"text","x":70,"y":1408,"width":480,"height":94,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec5"],"frameId":null,"roundness":null,"seed":521,"version":1,"versionNonce":621,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"OVERRIDE RULES (applied after base scoring)\n\nCyber status = CRITICAL -> force CRITICAL (active_data_breach)\nLegal status = CRITICAL -> force min HIGH (active_govt_litigation)\nrisk_tier_override field -> acts as FLOOR (never lowers score)","fontSize":12,"fontFamily":3,"textAlign":"left","verticalAlign":"top","containerId":"overrides_box","originalText":"OVERRIDE RULES (applied after base scoring)\n\nCyber status = CRITICAL -> force CRITICAL (active_data_breach)\nLegal status = CRITICAL -> force min HIGH (active_govt_litigation)\nrisk_tier_override field -> acts as FLOOR (never lowers score)","autoResize":true,"lineHeight":1.25}, + + {"id":"reco_low","type":"rectangle","x":60,"y":1530,"width":120,"height":50,"angle":0,"strokeColor":"#2f9e44","backgroundColor":"#d3f9d8","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec5"],"frameId":null,"roundness":{"type":3},"seed":530,"version":1,"versionNonce":630,"isDeleted":false,"boundElements":[{"id":"reco_low_t","type":"text"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"reco_low_t","type":"text","x":65,"y":1537,"width":110,"height":36,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec5"],"frameId":null,"roundness":null,"seed":531,"version":1,"versionNonce":631,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"LOW\ncontinue_monitoring","fontSize":11,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"reco_low","originalText":"LOW\ncontinue_monitoring","autoResize":true,"lineHeight":1.25}, + + {"id":"reco_med","type":"rectangle","x":190,"y":1530,"width":120,"height":50,"angle":0,"strokeColor":"#e67700","backgroundColor":"#fff3bf","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec5"],"frameId":null,"roundness":{"type":3},"seed":532,"version":1,"versionNonce":632,"isDeleted":false,"boundElements":[{"id":"reco_med_t","type":"text"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"reco_med_t","type":"text","x":195,"y":1537,"width":110,"height":36,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec5"],"frameId":null,"roundness":null,"seed":533,"version":1,"versionNonce":633,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"MEDIUM\nescalate_review","fontSize":11,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"reco_med","originalText":"MEDIUM\nescalate_review","autoResize":true,"lineHeight":1.25}, + + {"id":"reco_high","type":"rectangle","x":320,"y":1530,"width":120,"height":50,"angle":0,"strokeColor":"#e67700","backgroundColor":"#ffe8cc","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec5"],"frameId":null,"roundness":{"type":3},"seed":534,"version":1,"versionNonce":634,"isDeleted":false,"boundElements":[{"id":"reco_high_t","type":"text"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"reco_high_t","type":"text","x":325,"y":1537,"width":110,"height":36,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec5"],"frameId":null,"roundness":null,"seed":535,"version":1,"versionNonce":635,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"HIGH\ninitiate_contingency","fontSize":11,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"reco_high","originalText":"HIGH\ninitiate_contingency","autoResize":true,"lineHeight":1.25}, + + {"id":"reco_crit","type":"rectangle","x":450,"y":1530,"width":120,"height":50,"angle":0,"strokeColor":"#c92a2a","backgroundColor":"#ffe3e3","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec5"],"frameId":null,"roundness":{"type":3},"seed":536,"version":1,"versionNonce":636,"isDeleted":false,"boundElements":[{"id":"reco_crit_t","type":"text"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"reco_crit_t","type":"text","x":455,"y":1537,"width":110,"height":36,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec5"],"frameId":null,"roundness":null,"seed":537,"version":1,"versionNonce":637,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"CRITICAL\nsuspend_relationship","fontSize":11,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"reco_crit","originalText":"CRITICAL\nsuspend_relationship","autoResize":true,"lineHeight":1.25}, + + {"id":"reco_label","type":"text","x":60,"y":1590,"width":400,"height":16,"angle":0,"strokeColor":"#868e96","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec5"],"frameId":null,"roundness":null,"seed":538,"version":1,"versionNonce":638,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"action_required = true when HIGH or CRITICAL","fontSize":12,"fontFamily":2,"textAlign":"left","verticalAlign":"top","containerId":null,"originalText":"action_required = true when HIGH or CRITICAL","autoResize":true,"lineHeight":1.25}, + + {"id":"sec4_bg","type":"rectangle","x":600,"y":1120,"width":1100,"height":560,"angle":0,"strokeColor":"#1971c2","backgroundColor":"#e7f5ff","fillStyle":"solid","strokeWidth":1,"strokeStyle":"dashed","roughness":0,"opacity":15,"groupIds":["sec4"],"frameId":null,"roundness":{"type":3},"seed":400,"version":1,"versionNonce":500,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false}, + {"id":"sec4_title","type":"text","x":620,"y":1132,"width":450,"height":28,"angle":0,"strokeColor":"#1971c2","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec4"],"frameId":null,"roundness":null,"seed":401,"version":1,"versionNonce":501,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"CONTINUOUS MONITORING (WF4)","fontSize":22,"fontFamily":2,"textAlign":"left","verticalAlign":"top","containerId":null,"originalText":"CONTINUOUS MONITORING (WF4)","autoResize":true,"lineHeight":1.25}, + + {"id":"portfolio_box","type":"rectangle","x":620,"y":1170,"width":500,"height":220,"angle":0,"strokeColor":"#1971c2","backgroundColor":"#a5d8ff","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":["sec4"],"frameId":null,"roundness":{"type":3},"seed":410,"version":1,"versionNonce":510,"isDeleted":false,"boundElements":[{"id":"portfolio_t","type":"text"},{"id":"arrow_wf4_monapi","type":"arrow"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"portfolio_t","type":"text","x":630,"y":1178,"width":480,"height":204,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec4"],"frameId":null,"roundness":null,"seed":411,"version":1,"versionNonce":511,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"MONITOR PORTFOLIO MATRIX\n\n DIMENSION HIGH MEDIUM LOW\n ──────────────────────────────────────────\n Legal & Regulatory daily daily weekly\n Cybersecurity daily daily --\n Financial Health daily daily weekly\n Leadership daily -- --\n ESG & Reputation daily -- --\n ──────────────────────────────────────────\n TOTAL MONITORS 5/daily 3/daily 2/weekly","fontSize":13,"fontFamily":3,"textAlign":"left","verticalAlign":"top","containerId":"portfolio_box","originalText":"MONITOR PORTFOLIO MATRIX\n\n DIMENSION HIGH MEDIUM LOW\n ──────────────────────────────────────────\n Legal & Regulatory daily daily weekly\n Cybersecurity daily daily --\n Financial Health daily daily weekly\n Leadership daily -- --\n ESG & Reputation daily -- --\n ──────────────────────────────────────────\n TOTAL MONITORS 5/daily 3/daily 2/weekly","autoResize":true,"lineHeight":1.25}, + + {"id":"queries_box","type":"rectangle","x":620,"y":1405,"width":500,"height":160,"angle":0,"strokeColor":"#495057","backgroundColor":"#f8f9fa","fillStyle":"solid","strokeWidth":1,"strokeStyle":"dashed","roughness":0,"opacity":100,"groupIds":["sec4"],"frameId":null,"roundness":{"type":3},"seed":412,"version":1,"versionNonce":512,"isDeleted":false,"boundElements":[{"id":"queries_t","type":"text"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"queries_t","type":"text","x":630,"y":1413,"width":480,"height":144,"angle":0,"strokeColor":"#495057","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec4"],"frameId":null,"roundness":null,"seed":413,"version":1,"versionNonce":513,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"5 QUERY TEMPLATES (interpolated per vendor)\n\nlegal: \"{vendor}\" lawsuit OR litigation OR regulatory action\ncyber: \"{vendor}\" data breach OR ransomware OR vulnerability\nfinancial: \"{vendor}\" bankruptcy OR credit downgrade OR layoffs\nleadership: \"{vendor}\" CEO departure OR acquisition OR merger\nesg: \"{vendor}\" recall OR safety violation OR labor dispute","fontSize":12,"fontFamily":3,"textAlign":"left","verticalAlign":"top","containerId":"queries_box","originalText":"5 QUERY TEMPLATES (interpolated per vendor)\n\nlegal: \"{vendor}\" lawsuit OR litigation OR regulatory action\ncyber: \"{vendor}\" data breach OR ransomware OR vulnerability\nfinancial: \"{vendor}\" bankruptcy OR credit downgrade OR layoffs\nleadership: \"{vendor}\" CEO departure OR acquisition OR merger\nesg: \"{vendor}\" recall OR safety violation OR labor dispute","autoResize":true,"lineHeight":1.25}, + + {"id":"dedup_box","type":"rectangle","x":1140,"y":1170,"width":340,"height":130,"angle":0,"strokeColor":"#2f9e44","backgroundColor":"#d3f9d8","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":["sec4"],"frameId":null,"roundness":{"type":3},"seed":420,"version":1,"versionNonce":520,"isDeleted":false,"boundElements":[{"id":"dedup_t","type":"text"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"dedup_t","type":"text","x":1150,"y":1178,"width":320,"height":114,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec4"],"frameId":null,"roundness":null,"seed":421,"version":1,"versionNonce":521,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"EVENT DEDUP CACHE\n\nKey: {domain}:{event_type}:{severity}\nWindow: 24 hours\nCleanup: removes expired entries\n\nPrevents duplicate Slack alerts\nwhen same story hits multiple monitors","fontSize":12,"fontFamily":3,"textAlign":"left","verticalAlign":"top","containerId":"dedup_box","originalText":"EVENT DEDUP CACHE\n\nKey: {domain}:{event_type}:{severity}\nWindow: 24 hours\nCleanup: removes expired entries\n\nPrevents duplicate Slack alerts\nwhen same story hits multiple monitors","autoResize":true,"lineHeight":1.25}, + + {"id":"health_box","type":"rectangle","x":1140,"y":1320,"width":340,"height":245,"angle":0,"strokeColor":"#2f9e44","backgroundColor":"#d3f9d8","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":["sec4"],"frameId":null,"roundness":{"type":3},"seed":430,"version":1,"versionNonce":530,"isDeleted":false,"boundElements":[{"id":"health_t","type":"text"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"health_t","type":"text","x":1150,"y":1328,"width":320,"height":229,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec4"],"frameId":null,"roundness":null,"seed":431,"version":1,"versionNonce":531,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"HEALTH CHECKER (daily 6 AM)\n\n1. List all active monitors\n |\n2. Detect ORPHANS\n (vendor no longer active)\n |\n3. Detect FAILURES\n (status != active)\n |\n4. Delete orphans\n Recreate failed monitors\n |\n5. Self-ping webhook endpoint\n |\n6. Report to #vendor-risk-ops","fontSize":12,"fontFamily":3,"textAlign":"left","verticalAlign":"top","containerId":"health_box","originalText":"HEALTH CHECKER (daily 6 AM)\n\n1. List all active monitors\n |\n2. Detect ORPHANS\n (vendor no longer active)\n |\n3. Detect FAILURES\n (status != active)\n |\n4. Delete orphans\n Recreate failed monitors\n |\n5. Self-ping webhook endpoint\n |\n6. Report to #vendor-risk-ops","autoResize":true,"lineHeight":1.25}, + + {"id":"sec6_bg","type":"rectangle","x":40,"y":1700,"width":1100,"height":340,"angle":0,"strokeColor":"#e67700","backgroundColor":"#ffe8cc","fillStyle":"solid","strokeWidth":1,"strokeStyle":"dashed","roughness":0,"opacity":15,"groupIds":["sec6"],"frameId":null,"roundness":{"type":3},"seed":600,"version":1,"versionNonce":700,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false}, + {"id":"sec6_title","type":"text","x":60,"y":1712,"width":300,"height":28,"angle":0,"strokeColor":"#e67700","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec6"],"frameId":null,"roundness":null,"seed":601,"version":1,"versionNonce":701,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"ALERT DELIVERY","fontSize":22,"fontFamily":2,"textAlign":"left","verticalAlign":"top","containerId":null,"originalText":"ALERT DELIVERY","autoResize":true,"lineHeight":1.25}, + + {"id":"routing_box","type":"rectangle","x":60,"y":1750,"width":260,"height":140,"angle":0,"strokeColor":"#495057","backgroundColor":"#f8f9fa","fillStyle":"solid","strokeWidth":1,"strokeStyle":"dashed","roughness":0,"opacity":100,"groupIds":["sec6"],"frameId":null,"roundness":{"type":3},"seed":610,"version":1,"versionNonce":710,"isDeleted":false,"boundElements":[{"id":"routing_t","type":"text"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"routing_t","type":"text","x":70,"y":1758,"width":240,"height":124,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec6"],"frameId":null,"roundness":null,"seed":611,"version":1,"versionNonce":711,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"ROUTING LOGIC\n\nCRITICAL -> #critical\nHIGH -> #critical\nMEDIUM -> queue for digest\nLOW -> log only (no alert)\n\nRate limit: 1 msg/sec serial","fontSize":12,"fontFamily":3,"textAlign":"left","verticalAlign":"top","containerId":"routing_box","originalText":"ROUTING LOGIC\n\nCRITICAL -> #critical\nHIGH -> #critical\nMEDIUM -> queue for digest\nLOW -> log only (no alert)\n\nRate limit: 1 msg/sec serial","autoResize":true,"lineHeight":1.25}, + + {"id":"ch_critical","type":"rectangle","x":340,"y":1750,"width":250,"height":130,"angle":0,"strokeColor":"#c92a2a","backgroundColor":"#ffc9c9","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":["sec6"],"frameId":null,"roundness":{"type":3},"seed":620,"version":1,"versionNonce":720,"isDeleted":false,"boundElements":[{"id":"ch_critical_t","type":"text"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"ch_critical_t","type":"text","x":350,"y":1758,"width":230,"height":114,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec6"],"frameId":null,"roundness":null,"seed":621,"version":1,"versionNonce":721,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"#procurement-critical\n\nCRITICAL + HIGH alerts\nFull detail: vendor, findings,\nsource URLs, risk categories\n\nReview deadline:\n CRITICAL = 24 hours\n HIGH = 48 hours","fontSize":11,"fontFamily":3,"textAlign":"left","verticalAlign":"top","containerId":"ch_critical","originalText":"#procurement-critical\n\nCRITICAL + HIGH alerts\nFull detail: vendor, findings,\nsource URLs, risk categories\n\nReview deadline:\n CRITICAL = 24 hours\n HIGH = 48 hours","autoResize":true,"lineHeight":1.25}, + + {"id":"ch_alerts","type":"rectangle","x":610,"y":1750,"width":250,"height":130,"angle":0,"strokeColor":"#e67700","backgroundColor":"#ffe8cc","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":["sec6"],"frameId":null,"roundness":{"type":3},"seed":622,"version":1,"versionNonce":722,"isDeleted":false,"boundElements":[{"id":"ch_alerts_t","type":"text"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"ch_alerts_t","type":"text","x":620,"y":1758,"width":230,"height":114,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec6"],"frameId":null,"roundness":null,"seed":623,"version":1,"versionNonce":723,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"#procurement-alerts\n\nStandard notifications\nMonitor event alerts\nVendor onboarding confirms\nStatus updates","fontSize":11,"fontFamily":3,"textAlign":"left","verticalAlign":"top","containerId":"ch_alerts","originalText":"#procurement-alerts\n\nStandard notifications\nMonitor event alerts\nVendor onboarding confirms\nStatus updates","autoResize":true,"lineHeight":1.25}, + + {"id":"ch_digest","type":"rectangle","x":60,"y":1900,"width":250,"height":120,"angle":0,"strokeColor":"#e67700","backgroundColor":"#fff3bf","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":["sec6"],"frameId":null,"roundness":{"type":3},"seed":624,"version":1,"versionNonce":724,"isDeleted":false,"boundElements":[{"id":"ch_digest_t","type":"text"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"ch_digest_t","type":"text","x":70,"y":1908,"width":230,"height":104,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec6"],"frameId":null,"roundness":null,"seed":625,"version":1,"versionNonce":725,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"#procurement-digest\n\nWeekly batched MEDIUM findings\nGrouped by risk level\nTotal vendors assessed\nAdverse count summary","fontSize":11,"fontFamily":3,"textAlign":"left","verticalAlign":"top","containerId":"ch_digest","originalText":"#procurement-digest\n\nWeekly batched MEDIUM findings\nGrouped by risk level\nTotal vendors assessed\nAdverse count summary","autoResize":true,"lineHeight":1.25}, + + {"id":"ch_ops","type":"rectangle","x":340,"y":1900,"width":250,"height":120,"angle":0,"strokeColor":"#1971c2","backgroundColor":"#e7f5ff","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":["sec6"],"frameId":null,"roundness":{"type":3},"seed":626,"version":1,"versionNonce":726,"isDeleted":false,"boundElements":[{"id":"ch_ops_t","type":"text"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"ch_ops_t","type":"text","x":350,"y":1908,"width":230,"height":104,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec6"],"frameId":null,"roundness":null,"seed":627,"version":1,"versionNonce":727,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"#vendor-risk-ops\n\nHealth check reports\nResearch run summaries\nFailure + error alerts\n(Ops team, not risk analysts)","fontSize":11,"fontFamily":3,"textAlign":"left","verticalAlign":"top","containerId":"ch_ops","originalText":"#vendor-risk-ops\n\nHealth check reports\nResearch run summaries\nFailure + error alerts\n(Ops team, not risk analysts)","autoResize":true,"lineHeight":1.25}, + + {"id":"sec7_bg","type":"rectangle","x":1180,"y":1700,"width":520,"height":340,"angle":0,"strokeColor":"#2f9e44","backgroundColor":"#d3f9d8","fillStyle":"solid","strokeWidth":1,"strokeStyle":"dashed","roughness":0,"opacity":15,"groupIds":["sec7"],"frameId":null,"roundness":{"type":3},"seed":700,"version":1,"versionNonce":800,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false}, + {"id":"sec7_title","type":"text","x":1200,"y":1712,"width":300,"height":28,"angle":0,"strokeColor":"#2f9e44","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec7"],"frameId":null,"roundness":null,"seed":701,"version":1,"versionNonce":801,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"AUDIT & COMPLIANCE","fontSize":22,"fontFamily":2,"textAlign":"left","verticalAlign":"top","containerId":null,"originalText":"AUDIT & COMPLIANCE","autoResize":true,"lineHeight":1.25}, + + {"id":"audit_entry","type":"rectangle","x":1200,"y":1750,"width":480,"height":180,"angle":0,"strokeColor":"#2f9e44","backgroundColor":"#d3f9d8","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":["sec7"],"frameId":null,"roundness":{"type":3},"seed":710,"version":1,"versionNonce":810,"isDeleted":false,"boundElements":[{"id":"audit_entry_t","type":"text"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"audit_entry_t","type":"text","x":1210,"y":1758,"width":460,"height":164,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec7"],"frameId":null,"roundness":null,"seed":711,"version":1,"versionNonce":811,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"AUDIT LOG ENTRY (every assessment logged)\n\ntimestamp: ISO 8601\nvendor_name: string\nrisk_level: LOW | MEDIUM | HIGH | CRITICAL\nadverse_flag: boolean\ncategories: comma-separated risk dimensions\nsummary: assessment narrative\nrun_id: task group or event group ID\nsource: \"deep_research\" | \"monitor_event\"","fontSize":12,"fontFamily":3,"textAlign":"left","verticalAlign":"top","containerId":"audit_entry","originalText":"AUDIT LOG ENTRY (every assessment logged)\n\ntimestamp: ISO 8601\nvendor_name: string\nrisk_level: LOW | MEDIUM | HIGH | CRITICAL\nadverse_flag: boolean\ncategories: comma-separated risk dimensions\nsummary: assessment narrative\nrun_id: task group or event group ID\nsource: \"deep_research\" | \"monitor_event\"","autoResize":true,"lineHeight":1.25}, + + {"id":"audit_src1","type":"rectangle","x":1200,"y":1945,"width":230,"height":75,"angle":0,"strokeColor":"#2f9e44","backgroundColor":"#ebfbee","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec7"],"frameId":null,"roundness":{"type":3},"seed":720,"version":1,"versionNonce":820,"isDeleted":false,"boundElements":[{"id":"audit_src1_t","type":"text"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"audit_src1_t","type":"text","x":1210,"y":1953,"width":210,"height":59,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec7"],"frameId":null,"roundness":null,"seed":721,"version":1,"versionNonce":821,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"source: deep_research\nFrom WF2 scheduled batches\nFrom WF5 ad-hoc requests","fontSize":12,"fontFamily":3,"textAlign":"left","verticalAlign":"middle","containerId":"audit_src1","originalText":"source: deep_research\nFrom WF2 scheduled batches\nFrom WF5 ad-hoc requests","autoResize":true,"lineHeight":1.25}, + + {"id":"audit_src2","type":"rectangle","x":1450,"y":1945,"width":230,"height":75,"angle":0,"strokeColor":"#2f9e44","backgroundColor":"#ebfbee","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec7"],"frameId":null,"roundness":{"type":3},"seed":722,"version":1,"versionNonce":822,"isDeleted":false,"boundElements":[{"id":"audit_src2_t","type":"text"}],"updated":1709654400000,"link":null,"locked":false}, + {"id":"audit_src2_t","type":"text","x":1460,"y":1953,"width":210,"height":59,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":["sec7"],"frameId":null,"roundness":null,"seed":723,"version":1,"versionNonce":823,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"source: monitor_event\nFrom WF4 webhook events\nReal-time detections","fontSize":12,"fontFamily":3,"textAlign":"left","verticalAlign":"middle","containerId":"audit_src2","originalText":"source: monitor_event\nFrom WF4 webhook events\nReal-time detections","autoResize":true,"lineHeight":1.25}, + + {"id":"arrow_gs_wf1","type":"arrow","x":270,"y":365,"width":0,"height":95,"angle":0,"strokeColor":"#e67700","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":2},"seed":1100,"version":1,"versionNonce":1200,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"points":[[0,0],[0,95]],"lastCommittedPoint":null,"startBinding":{"elementId":"sec1_gsheets","focus":0,"gap":5},"endBinding":{"elementId":"wf1_box","focus":-0.5,"gap":5},"startArrowhead":null,"endArrowhead":"arrow"}, + + {"id":"arrow_wf1_wf4","type":"arrow","x":1125,"y":565,"width":110,"height":660,"angle":0,"strokeColor":"#1971c2","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":2},"seed":1101,"version":1,"versionNonce":1201,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"points":[[0,0],[110,0],[110,660]],"lastCommittedPoint":null,"startBinding":{"elementId":"wf1_box","focus":0,"gap":5},"endBinding":{"elementId":"portfolio_box","focus":0.5,"gap":5},"startArrowhead":null,"endArrowhead":"arrow"}, + + {"id":"arrow_wf2_taskapi","type":"arrow","x":1125,"y":850,"width":70,"height":0,"angle":0,"strokeColor":"#6741d9","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":2},"seed":1102,"version":1,"versionNonce":1202,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"points":[[0,0],[70,0]],"lastCommittedPoint":null,"startBinding":{"elementId":"wf2_box","focus":0,"gap":5},"endBinding":{"elementId":"taskapi_box","focus":0,"gap":5},"startArrowhead":"arrow","endArrowhead":"arrow"}, + {"id":"arrow_wf2_taskapi_label","type":"text","x":1130,"y":830,"width":60,"height":16,"angle":0,"strokeColor":"#6741d9","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":80,"groupIds":[],"frameId":null,"roundness":null,"seed":1103,"version":1,"versionNonce":1203,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"batches","fontSize":12,"fontFamily":2,"textAlign":"center","verticalAlign":"top","containerId":null,"originalText":"batches","autoResize":true,"lineHeight":1.25}, + + {"id":"arrow_wf4_monapi","type":"arrow","x":1125,"y":1280,"width":70,"height":620,"angle":0,"strokeColor":"#6741d9","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":2},"seed":1104,"version":1,"versionNonce":1204,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"points":[[0,0],[220,0],[220,-620]],"lastCommittedPoint":null,"startBinding":{"elementId":"portfolio_box","focus":0.3,"gap":5},"endBinding":{"elementId":"monapi_box","focus":0,"gap":5},"startArrowhead":"arrow","endArrowhead":"arrow"}, + {"id":"arrow_wf4_monapi_label","type":"text","x":1370,"y":930,"width":120,"height":16,"angle":0,"strokeColor":"#6741d9","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":80,"groupIds":[],"frameId":null,"roundness":null,"seed":1105,"version":1,"versionNonce":1205,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"deploy + events","fontSize":12,"fontFamily":2,"textAlign":"center","verticalAlign":"top","containerId":null,"originalText":"deploy + events","autoResize":true,"lineHeight":1.25}, + + {"id":"arrow_wf2_wf3","type":"arrow","x":310,"y":1085,"width":0,"height":30,"angle":0,"strokeColor":"#1971c2","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"dashed","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":2},"seed":1106,"version":1,"versionNonce":1206,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"points":[[0,0],[0,30]],"lastCommittedPoint":null,"startBinding":{"elementId":"wf2_box","focus":0,"gap":5},"endBinding":{"elementId":"scoring_table","focus":0,"gap":5},"startArrowhead":null,"endArrowhead":"arrow"}, + {"id":"arrow_wf2_wf3_label","type":"text","x":320,"y":1092,"width":140,"height":16,"angle":0,"strokeColor":"#1971c2","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":80,"groupIds":[],"frameId":null,"roundness":null,"seed":1107,"version":1,"versionNonce":1207,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"scoreDeepResearch()","fontSize":11,"fontFamily":3,"textAlign":"left","verticalAlign":"top","containerId":null,"originalText":"scoreDeepResearch()","autoResize":true,"lineHeight":1.25}, + + {"id":"arrow_wf4_wf3","type":"arrow","x":615,"y":1350,"width":30,"height":0,"angle":0,"strokeColor":"#1971c2","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"dashed","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":2},"seed":1108,"version":1,"versionNonce":1208,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"points":[[0,0],[-30,0]],"lastCommittedPoint":null,"startBinding":{"elementId":"portfolio_box","focus":0.5,"gap":5},"endBinding":{"elementId":"scoring_table","focus":0.5,"gap":5},"startArrowhead":null,"endArrowhead":"arrow"}, + {"id":"arrow_wf4_wf3_label","type":"text","x":570,"y":1330,"width":150,"height":16,"angle":0,"strokeColor":"#1971c2","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":80,"groupIds":[],"frameId":null,"roundness":null,"seed":1109,"version":1,"versionNonce":1209,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"scoreMonitorEvent()","fontSize":11,"fontFamily":3,"textAlign":"right","verticalAlign":"top","containerId":null,"originalText":"scoreMonitorEvent()","autoResize":true,"lineHeight":1.25}, + + {"id":"arrow_wf3_slack","type":"arrow","x":310,"y":1615,"width":0,"height":80,"angle":0,"strokeColor":"#e67700","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":2},"seed":1110,"version":1,"versionNonce":1210,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"points":[[0,0],[0,80]],"lastCommittedPoint":null,"startBinding":null,"endBinding":null,"startArrowhead":null,"endArrowhead":"arrow"}, + {"id":"arrow_wf3_slack_label","type":"text","x":320,"y":1645,"width":130,"height":16,"angle":0,"strokeColor":"#e67700","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":80,"groupIds":[],"frameId":null,"roundness":null,"seed":1111,"version":1,"versionNonce":1211,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"route by risk_level","fontSize":11,"fontFamily":2,"textAlign":"left","verticalAlign":"top","containerId":null,"originalText":"route by risk_level","autoResize":true,"lineHeight":1.25}, + + {"id":"arrow_wf3_audit","type":"arrow","x":560,"y":1500,"width":635,"height":245,"angle":0,"strokeColor":"#2f9e44","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":2},"seed":1112,"version":1,"versionNonce":1212,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"points":[[0,0],[635,0],[635,245]],"lastCommittedPoint":null,"startBinding":null,"endBinding":{"elementId":"audit_entry","focus":0,"gap":5},"startArrowhead":null,"endArrowhead":"arrow"}, + {"id":"arrow_wf3_audit_label","type":"text","x":870,"y":1480,"width":110,"height":16,"angle":0,"strokeColor":"#2f9e44","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":80,"groupIds":[],"frameId":null,"roundness":null,"seed":1113,"version":1,"versionNonce":1213,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"logAssessment()","fontSize":11,"fontFamily":3,"textAlign":"center","verticalAlign":"top","containerId":null,"originalText":"logAssessment()","autoResize":true,"lineHeight":1.25}, + + {"id":"arrow_slash_wf5","type":"arrow","x":785,"y":310,"width":930,"height":0,"angle":0,"strokeColor":"#e67700","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":2},"seed":1114,"version":1,"versionNonce":1214,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"points":[[0,0],[930,0]],"lastCommittedPoint":null,"startBinding":{"elementId":"sec1_slash","focus":0,"gap":5},"endBinding":{"elementId":"sec9_bg","focus":-0.8,"gap":5},"startArrowhead":null,"endArrowhead":"arrow"}, + + {"id":"arrow_wf5_taskapi","type":"arrow","x":1715,"y":570,"width":520,"height":0,"angle":0,"strokeColor":"#6741d9","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"dashed","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":2},"seed":1116,"version":1,"versionNonce":1216,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"points":[[0,0],[-35,0]],"lastCommittedPoint":null,"startBinding":{"elementId":"sec9_bg","focus":0,"gap":5},"endBinding":{"elementId":"taskapi_box","focus":0,"gap":5},"startArrowhead":null,"endArrowhead":"arrow"}, + + {"id":"main_title","type":"text","x":550,"y":-40,"width":700,"height":36,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":9999,"version":1,"versionNonce":9998,"isDeleted":false,"boundElements":null,"updated":1709654400000,"link":null,"locked":false,"text":"PARALLEL PROCUREMENT -- Vendor Risk Intelligence System","fontSize":28,"fontFamily":2,"textAlign":"center","verticalAlign":"top","containerId":null,"originalText":"PARALLEL PROCUREMENT -- Vendor Risk Intelligence System","autoResize":true,"lineHeight":1.25} + + ], + "appState": { + "gridSize": null, + "viewBackgroundColor": "#ffffff" + }, + "files": {} +} diff --git a/typescript-recipes/parallel-n8n-procurement/templates/audit-log-tab.csv b/typescript-recipes/parallel-n8n-procurement/templates/audit-log-tab.csv new file mode 100644 index 0000000..35405ac --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/templates/audit-log-tab.csv @@ -0,0 +1 @@ +timestamp,vendor_name,risk_level,adverse_flag,categories,summary,run_id,source diff --git a/typescript-recipes/parallel-n8n-procurement/templates/monitors-tab.csv b/typescript-recipes/parallel-n8n-procurement/templates/monitors-tab.csv new file mode 100644 index 0000000..93eb29e --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/templates/monitors-tab.csv @@ -0,0 +1 @@ +monitor_id,vendor_name,vendor_domain,risk_dimension,monitor_category,cadence,created_at diff --git a/typescript-recipes/parallel-n8n-procurement/templates/registry-tab.csv b/typescript-recipes/parallel-n8n-procurement/templates/registry-tab.csv new file mode 100644 index 0000000..9cea1a5 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/templates/registry-tab.csv @@ -0,0 +1 @@ +vendor_name,vendor_domain,vendor_category,risk_tier_override,active,monitoring_priority,monitor_ids,next_research_date,last_synced_at,relationship_owner,region,risk_score,dashboard_managed diff --git a/typescript-recipes/parallel-n8n-procurement/templates/vendors-tab.csv b/typescript-recipes/parallel-n8n-procurement/templates/vendors-tab.csv new file mode 100644 index 0000000..3f743f5 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/templates/vendors-tab.csv @@ -0,0 +1,16 @@ +vendor_name,vendor_domain,vendor_category,risk_tier_override,active,monitoring_priority,relationship_owner,region,risk_score,next_research_date,last_synced_at,dashboard_managed +Microsoft,https://microsoft.com,technology,,TRUE,high,Procurement,Global,,,,FALSE +Amazon Web Services,https://aws.amazon.com,technology,,TRUE,high,Procurement,Global,,,,FALSE +Salesforce,https://salesforce.com,technology,,TRUE,high,Procurement,Global,,,,FALSE +JPMorgan Chase,https://jpmorganchase.com,financial_services,,TRUE,high,Procurement,Global,,,,FALSE +Goldman Sachs,https://goldmansachs.com,financial_services,,TRUE,medium,Procurement,Global,,,,FALSE +UnitedHealth Group,https://unitedhealthgroup.com,healthcare,,TRUE,high,Procurement,Global,,,,FALSE +Pfizer,https://pfizer.com,healthcare,,TRUE,medium,Procurement,Global,,,,FALSE +Johnson & Johnson,https://jnj.com,healthcare,,TRUE,medium,Procurement,Global,,,,FALSE +Siemens,https://siemens.com,manufacturing,,TRUE,medium,Procurement,Global,,,,FALSE +Caterpillar,https://caterpillar.com,manufacturing,,TRUE,low,Procurement,Global,,,,FALSE +Deloitte,https://deloitte.com,professional_services,,TRUE,medium,Procurement,Global,,,,FALSE +Accenture,https://accenture.com,professional_services,,TRUE,medium,Procurement,Global,,,,FALSE +Stripe,https://stripe.com,financial_services,,TRUE,high,Procurement,Global,,,,FALSE +CrowdStrike,https://crowdstrike.com,technology,,TRUE,high,Procurement,Global,,,,FALSE +3M,https://3m.com,manufacturing,,TRUE,low,Procurement,Global,,,,FALSE diff --git a/typescript-recipes/parallel-n8n-procurement/tests/config/config.test.ts b/typescript-recipes/parallel-n8n-procurement/tests/config/config.test.ts new file mode 100644 index 0000000..0c08f01 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/tests/config/config.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { loadConfig, resetConfig } from "@/config/index.js"; + +describe("loadConfig", () => { + const savedEnv = { ...process.env }; + + function setRequiredEnv() { + process.env.PARALLEL_API_KEY = "test-key"; + process.env.GOOGLE_SHEET_ID = "sheet123"; + process.env.SLACK_WEBHOOK_URL = "https://hooks.slack.com/services/test"; + process.env.N8N_WEBHOOK_BASE_URL = "https://example.app.n8n.cloud"; + } + + beforeEach(() => { + // Restore original env and reset singleton before each test + process.env = { ...savedEnv }; + resetConfig(); + }); + + describe("required variables", () => { + it("throws when PARALLEL_API_KEY is missing", () => { + process.env.GOOGLE_SHEET_ID = "sheet123"; + process.env.SLACK_WEBHOOK_URL = "https://hooks.slack.com/services/test"; + process.env.N8N_WEBHOOK_BASE_URL = "https://example.app.n8n.cloud"; + delete process.env.PARALLEL_API_KEY; + + expect(() => loadConfig()).toThrow("PARALLEL_API_KEY"); + }); + + it("throws when GOOGLE_SHEET_ID is missing", () => { + process.env.PARALLEL_API_KEY = "test-key"; + process.env.SLACK_WEBHOOK_URL = "https://hooks.slack.com/services/test"; + process.env.N8N_WEBHOOK_BASE_URL = "https://example.app.n8n.cloud"; + delete process.env.GOOGLE_SHEET_ID; + + expect(() => loadConfig()).toThrow("GOOGLE_SHEET_ID"); + }); + + it("throws when SLACK_WEBHOOK_URL is missing", () => { + process.env.PARALLEL_API_KEY = "test-key"; + process.env.GOOGLE_SHEET_ID = "sheet123"; + process.env.N8N_WEBHOOK_BASE_URL = "https://example.app.n8n.cloud"; + delete process.env.SLACK_WEBHOOK_URL; + + expect(() => loadConfig()).toThrow("SLACK_WEBHOOK_URL"); + }); + + it("throws when N8N_WEBHOOK_BASE_URL is missing", () => { + process.env.PARALLEL_API_KEY = "test-key"; + process.env.GOOGLE_SHEET_ID = "sheet123"; + process.env.SLACK_WEBHOOK_URL = "https://hooks.slack.com/services/test"; + delete process.env.N8N_WEBHOOK_BASE_URL; + + expect(() => loadConfig()).toThrow("N8N_WEBHOOK_BASE_URL"); + }); + + it("throws with descriptive message mentioning .env", () => { + delete process.env.PARALLEL_API_KEY; + delete process.env.GOOGLE_SHEET_ID; + delete process.env.SLACK_WEBHOOK_URL; + delete process.env.N8N_WEBHOOK_BASE_URL; + + expect(() => loadConfig()).toThrow( + "Check your .env file or environment variables" + ); + }); + }); + + describe("defaults", () => { + it("applies all default values", () => { + setRequiredEnv(); + const config = loadConfig(); + + expect(config.PARALLEL_BASE_URL).toBe("https://api.parallel.ai"); + expect(config.RESEARCH_CRON).toBe("0 6 * * *"); + expect(config.SYNC_CRON).toBe("0 0 * * *"); + expect(config.BATCH_SIZE).toBe(50); + expect(config.RESEARCH_PROCESSOR).toBe("ultra8x"); + expect(config.MONITOR_CADENCE_HIGH).toBe("daily"); + expect(config.MONITOR_CADENCE_STD).toBe("weekly"); + expect(config.MONITORS_PER_VENDOR_HIGH).toBe(5); + expect(config.MONITORS_PER_VENDOR_STD).toBe(2); + }); + + it("leaves optional slack channels as undefined", () => { + setRequiredEnv(); + const config = loadConfig(); + + expect(config.SLACK_CHANNEL_CRITICAL).toBeUndefined(); + expect(config.SLACK_CHANNEL_ALERT).toBeUndefined(); + expect(config.SLACK_CHANNEL_DIGEST).toBeUndefined(); + }); + }); + + describe("type coercion", () => { + it("coerces BATCH_SIZE from string to number", () => { + setRequiredEnv(); + process.env.BATCH_SIZE = "100"; + + const config = loadConfig(); + expect(config.BATCH_SIZE).toBe(100); + }); + + it("coerces MONITORS_PER_VENDOR_HIGH from string to number", () => { + setRequiredEnv(); + process.env.MONITORS_PER_VENDOR_HIGH = "10"; + + const config = loadConfig(); + expect(config.MONITORS_PER_VENDOR_HIGH).toBe(10); + }); + }); + + describe("caching", () => { + it("returns the same instance on subsequent calls", () => { + setRequiredEnv(); + const first = loadConfig(); + const second = loadConfig(); + expect(first).toBe(second); + }); + + it("returns fresh instance after resetConfig", () => { + setRequiredEnv(); + const first = loadConfig(); + resetConfig(); + const second = loadConfig(); + expect(first).not.toBe(second); + expect(first).toEqual(second); + }); + }); +}); diff --git a/typescript-recipes/parallel-n8n-procurement/tests/fixtures/deep-research-output.json b/typescript-recipes/parallel-n8n-procurement/tests/fixtures/deep-research-output.json new file mode 100644 index 0000000..2e79b43 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/tests/fixtures/deep-research-output.json @@ -0,0 +1,49 @@ +{ + "vendor_name": "Acme Corp", + "assessment_date": "2026-03-05", + "overall_risk_level": "HIGH", + "financial_health": { + "status": "warning", + "findings": "Revenue declined 15% YoY. Credit rating downgraded from A to BBB by Moody's in January 2026. Debt-to-equity ratio increased to 2.8x.", + "severity": "HIGH" + }, + "legal_regulatory": { + "status": "issues", + "findings": "Pending SEC investigation into accounting practices. Class action lawsuit filed by shareholders in Q4 2025.", + "severity": "MEDIUM" + }, + "cybersecurity": { + "status": "stable", + "findings": "SOC 2 Type II certified. No known breaches in past 24 months. Regular penetration testing disclosed.", + "severity": "LOW" + }, + "leadership_governance": { + "status": "stable", + "findings": "CFO departure in November 2025 with successor appointed. Board composition stable.", + "severity": "LOW" + }, + "esg_reputation": { + "status": "stable", + "findings": "No significant environmental or labor issues. ESG rating maintained at B+.", + "severity": "LOW" + }, + "adverse_events": [ + { + "title": "Moody's Credit Downgrade", + "date": "2026-01-15", + "category": "financial", + "severity": "HIGH", + "source_url": "https://www.moodys.com/research/acme-downgrade-2026", + "description": "Moody's downgraded Acme Corp from A to BBB citing deteriorating cash flow and rising debt levels." + }, + { + "title": "SEC Investigation Disclosed", + "date": "2025-12-01", + "category": "legal", + "severity": "MEDIUM", + "source_url": "https://www.sec.gov/litigation/acme-investigation", + "description": "SEC opened formal investigation into revenue recognition practices following whistleblower complaint." + } + ], + "recommendation": "ESCALATE" +} diff --git a/typescript-recipes/parallel-n8n-procurement/tests/fixtures/monitor-webhook-payload.json b/typescript-recipes/parallel-n8n-procurement/tests/fixtures/monitor-webhook-payload.json new file mode 100644 index 0000000..d013a68 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/tests/fixtures/monitor-webhook-payload.json @@ -0,0 +1,27 @@ +{ + "type": "monitor.event.detected", + "data": { + "monitor_id": "mon_acme_legal_001", + "event": { + "event_group_id": "eg_20260305_001", + "event_id": "evt_001", + "event_date": "2026-03-05", + "output": { + "event_summary": "Federal court rules against Acme Corp in patent infringement case, awarding $45M in damages", + "severity": "HIGH", + "adverse": true, + "event_type": "legal_regulatory" + }, + "source_urls": [ + "https://www.reuters.com/legal/acme-patent-ruling-2026", + "https://www.law360.com/articles/acme-infringement" + ] + }, + "metadata": { + "vendor_name": "Acme Corp", + "vendor_domain": "https://acme.com", + "monitor_category": "Legal & Regulatory", + "risk_dimension": "legal" + } + } +} diff --git a/typescript-recipes/parallel-n8n-procurement/tests/fixtures/sample-vendors.csv b/typescript-recipes/parallel-n8n-procurement/tests/fixtures/sample-vendors.csv new file mode 100644 index 0000000..ccdf0d7 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/tests/fixtures/sample-vendors.csv @@ -0,0 +1,11 @@ +vendor_name,vendor_domain,vendor_category,risk_tier_override,active,monitoring_priority +Acme Corp,https://acme.com,technology,,true,high +GlobalTech Solutions,https://globaltech.io,technology,,true,high +CyberShield Inc,https://cybershield.com,technology,HIGH,true,high +FinServ Partners,https://finserv.com,financial_services,,true,medium +MedHealth Systems,https://medhealth.org,healthcare,,true,medium +DataFlow Analytics,https://dataflow.ai,financial_services,,true,medium +Precision Manufacturing,https://precisionmfg.com,manufacturing,,true,low +"BuildRight, LLC",https://buildright.com,manufacturing,,true,low +EcoGreen Services,https://ecogreen.com,other,,true,low +Legacy Systems Corp,https://legacysys.com,other,,false,low diff --git a/typescript-recipes/parallel-n8n-procurement/tests/fixtures/vendor-registry.json b/typescript-recipes/parallel-n8n-procurement/tests/fixtures/vendor-registry.json new file mode 100644 index 0000000..be827c9 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/tests/fixtures/vendor-registry.json @@ -0,0 +1,56 @@ +{ + "vendors": [ + { + "vendor_name": "Acme Corp", + "vendor_domain": "https://acme.com", + "vendor_category": "technology", + "monitoring_priority": "high", + "active": true, + "monitor_ids": ["mon_acme_legal_001", "mon_acme_cyber_001", "mon_acme_fin_001", "mon_acme_lead_001", "mon_acme_esg_001"], + "last_synced_at": "2026-03-01T00:00:00.000Z", + "next_research_date": "2026-03-08T00:00:00.000Z" + }, + { + "vendor_name": "GlobalTech Solutions", + "vendor_domain": "https://globaltech.io", + "vendor_category": "technology", + "monitoring_priority": "high", + "active": true, + "monitor_ids": ["mon_gt_legal_001", "mon_gt_cyber_001", "mon_gt_fin_001", "mon_gt_lead_001", "mon_gt_esg_001"], + "last_synced_at": "2026-03-01T00:00:00.000Z", + "next_research_date": "2026-03-08T00:00:00.000Z" + }, + { + "vendor_name": "FinServ Partners", + "vendor_domain": "https://finserv.com", + "vendor_category": "financial_services", + "monitoring_priority": "medium", + "active": true, + "monitor_ids": ["mon_fs_legal_001", "mon_fs_cyber_001", "mon_fs_fin_001"], + "last_synced_at": "2026-03-01T00:00:00.000Z", + "next_research_date": "2026-03-10T00:00:00.000Z" + }, + { + "vendor_name": "Precision Manufacturing", + "vendor_domain": "https://precisionmfg.com", + "vendor_category": "manufacturing", + "monitoring_priority": "low", + "active": true, + "monitor_ids": ["mon_pm_legal_001", "mon_pm_fin_001"], + "last_synced_at": "2026-03-01T00:00:00.000Z", + "next_research_date": "2026-03-15T00:00:00.000Z" + }, + { + "vendor_name": "EcoGreen Services", + "vendor_domain": "https://ecogreen.com", + "vendor_category": "other", + "monitoring_priority": "low", + "active": true, + "monitor_ids": ["mon_eg_legal_001", "mon_eg_fin_001"], + "last_synced_at": "2026-03-01T00:00:00.000Z", + "next_research_date": "2026-03-15T00:00:00.000Z" + } + ], + "last_sync_timestamp": "2026-03-01T00:00:00.000Z", + "total_count": 5 +} diff --git a/typescript-recipes/parallel-n8n-procurement/tests/integration/error-scenarios.test.ts b/typescript-recipes/parallel-n8n-procurement/tests/integration/error-scenarios.test.ts new file mode 100644 index 0000000..a2988ad --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/tests/integration/error-scenarios.test.ts @@ -0,0 +1,223 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { VendorIngestionService } from "@/services/vendor-ingestion.js"; +import { RiskScorer } from "@/services/risk-scorer.js"; +import { SlackCommandHandler } from "@/services/slack-command-handler.js"; +import { MonitorEventHandler } from "@/services/monitor-event-handler.js"; +import { EventDedupCache } from "@/services/event-dedup-cache.js"; +import { ParallelApiError, TaskGroupTimeoutError } from "@/models/task-api.js"; +import type { ParallelTaskClient } from "@/services/parallel-task-client.js"; +import type { MonitorPortfolioManager } from "@/services/monitor-portfolio-manager.js"; +import type { ParallelMonitorClient } from "@/services/parallel-monitor-client.js"; +import type { SlackDeliveryService } from "@/services/slack-delivery.js"; +import type { SlackFormatter } from "@/services/slack-formatter.js"; +import type { AuditLogger } from "@/services/audit-logger.js"; +import type { Vendor } from "@/models/vendor.js"; + +const silentLogger = { debug: vi.fn(), warn: vi.fn(), error: vi.fn() }; + +function makeVendor(overrides: Partial = {}): Vendor { + return { + vendor_name: "Acme Corp", + vendor_domain: "https://acme.com", + vendor_category: "technology", + monitoring_priority: "high", + active: true, + ...overrides, + }; +} + +describe("Error Scenarios", () => { + // ── 1. 429 Retry ───────────────────────────────────────────────────── + + describe("Parallel API 429 rate limit", () => { + it("retries and eventually succeeds", async () => { + // This tests the concept — the actual retry is in parallel-task-client.test.ts + // Here we verify the error type is recognized + const err = new ParallelApiError("Rate limited", 429, ""); + expect(err.status).toBe(429); + expect(err).toBeInstanceOf(ParallelApiError); + }); + }); + + // ── 2. 500 Retry Exhaustion ────────────────────────────────────────── + + describe("Parallel API 500 retry exhaustion", () => { + it("throws ParallelApiError after retries", () => { + const err = new ParallelApiError("Server error", 500, "Internal Server Error"); + expect(err.status).toBe(500); + expect(err.responseBody).toBe("Internal Server Error"); + }); + }); + + // ── 3. Task Group Timeout ──────────────────────────────────────────── + + describe("Task Group polling timeout", () => { + it("throws TaskGroupTimeoutError with elapsed time", () => { + const err = new TaskGroupTimeoutError("tg_123", 3600000); + expect(err.taskGroupId).toBe("tg_123"); + expect(err.elapsedMs).toBe(3600000); + expect(err.message).toContain("3600s"); + }); + }); + + // ── 4. Malformed Webhook Payload ───────────────────────────────────── + + describe("Malformed monitor webhook payload", () => { + it("returns error for payload missing event_group_id", async () => { + const mockMonitorClient = { + getEventGroupDetails: vi.fn(), + }; + const handler = new MonitorEventHandler({ + monitorClient: mockMonitorClient as unknown as ParallelMonitorClient, + riskScorer: new RiskScorer(), + formatter: { formatMonitorAlert: vi.fn() } as unknown as SlackFormatter, + deliveryService: { sendAlert: vi.fn() } as unknown as SlackDeliveryService, + auditLogger: { logAssessment: vi.fn() } as unknown as AuditLogger, + dedupCache: new EventDedupCache(), + monitorRegistry: () => undefined, + logger: silentLogger, + }); + + // Payload with unknown monitor + const result = await handler.handleWebhookEvent({ + type: "monitor.event.detected", + data: { + monitor_id: "mon_unknown", + event: { event_group_id: "eg_1" }, + }, + }); + + expect(result.processed).toBe(false); + expect(result.error).toContain("Unknown monitor"); + expect(mockMonitorClient.getEventGroupDetails).not.toHaveBeenCalled(); + }); + }); + + // ── 5. Slack API Error ─────────────────────────────────────────────── + + describe("Slack API returns error", () => { + it("error is returned, not thrown", async () => { + // SlackDeliveryService returns the error response, doesn't throw + const mockResponse = { ok: false, error: "channel_not_found" }; + expect(mockResponse.ok).toBe(false); + expect(mockResponse.error).toBe("channel_not_found"); + }); + }); + + // ── 6. Malformed CSV Rows ──────────────────────────────────────────── + + describe("CSV with malformed rows", () => { + it("processes valid rows and skips invalid ones", async () => { + const ingestion = new VendorIngestionService({ logger: silentLogger }); + const csv = [ + "vendor_name,vendor_domain,vendor_category,risk_tier_override,active,monitoring_priority", + "Good Corp,https://good.com,technology,,true,high", + "Bad Corp,https://bad.com,invalid_category,,true,high", + "Also Good,https://alsogood.com,healthcare,,true,medium", + ].join("\n"); + + const vendors = await ingestion.ingestFromCSV(csv); + + expect(vendors).toHaveLength(2); + expect(vendors[0].vendor_name).toBe("Good Corp"); + expect(vendors[1].vendor_name).toBe("Also Good"); + expect(silentLogger.warn).toHaveBeenCalled(); + }); + }); + + // ── 7. Monitor Creation Partial Failure ────────────────────────────── + + describe("Monitor creation fails for 1 vendor in batch", () => { + it("other vendors proceed normally, error collected", async () => { + const ingestion = new VendorIngestionService({ logger: silentLogger }); + const mockPortfolio = { + deployMonitors: vi.fn().mockImplementation(async (vendors: Vendor[]) => { + // Simulate: first vendor fails, rest succeed + const map = new Map(); + for (let i = 0; i < vendors.length; i++) { + if (i === 0) continue; // skip first (simulating failure below) + map.set(vendors[i].vendor_domain, [`mon_${i}`]); + } + return map; + }), + removeMonitors: vi.fn().mockResolvedValue(undefined), + }; + + const diff = { + added: [ + makeVendor({ vendor_domain: "https://fail.com" }), + makeVendor({ vendor_domain: "https://ok1.com" }), + makeVendor({ vendor_domain: "https://ok2.com" }), + ], + removed: [], + unchanged: [], + modified: [], + }; + + const result = await ingestion.applyDiff( + diff, + mockPortfolio as unknown as MonitorPortfolioManager, + ); + + // deployMonitors was called (it doesn't throw, just doesn't include failed vendor in map) + expect(mockPortfolio.deployMonitors).toHaveBeenCalled(); + // The map returned by mock only has 2 entries (ok1, ok2) + expect(result.monitors_created.size).toBe(2); + }); + }); + + // ── 8. Empty Research Output ───────────────────────────────────────── + + describe("Deep research returns empty output", () => { + it("risk scorer handles gracefully with defaults", () => { + const scorer = new RiskScorer(); + // Minimal output with all LOW (simulating empty/default response) + const emptyOutput = { + vendor_name: "Unknown", + assessment_date: "2026-03-05", + overall_risk_level: "LOW" as const, + financial_health: { status: "unknown", findings: "", severity: "LOW" as const }, + legal_regulatory: { status: "unknown", findings: "", severity: "LOW" as const }, + cybersecurity: { status: "unknown", findings: "", severity: "LOW" as const }, + leadership_governance: { status: "unknown", findings: "", severity: "LOW" as const }, + esg_reputation: { status: "unknown", findings: "", severity: "LOW" as const }, + adverse_events: [], + recommendation: "APPROVE", + }; + + const assessment = scorer.scoreDeepResearch(emptyOutput); + expect(assessment.risk_level).toBe("LOW"); + expect(assessment.adverse_flag).toBe(false); + expect(assessment.action_required).toBe(false); + }); + }); + + // ── 9. Slash Command with Empty Vendor ─────────────────────────────── + + describe("Slash command with empty vendor name", () => { + it("throws descriptive error", () => { + const handler = new SlackCommandHandler({ + deliveryService: {} as unknown as SlackDeliveryService, + taskClient: {} as unknown as ParallelTaskClient, + riskScorer: new RiskScorer(), + promptBuilder: {} as any, + formatter: {} as any, + vendorLookup: () => undefined, + logger: silentLogger, + }); + + expect(() => + handler.parseSlashCommand({ + command: "/vendor-research", + text: "", + user_id: "U1", + user_name: "user", + channel_id: "C1", + channel_name: "test", + response_url: "https://hooks.slack.com/response", + trigger_id: "T1", + }), + ).toThrow("Vendor name is required"); + }); + }); +}); diff --git a/typescript-recipes/parallel-n8n-procurement/tests/integration/full-pipeline.test.ts b/typescript-recipes/parallel-n8n-procurement/tests/integration/full-pipeline.test.ts new file mode 100644 index 0000000..ed3d4e8 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/tests/integration/full-pipeline.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { VendorIngestionService } from "@/services/vendor-ingestion.js"; +import { MonitorQueryGenerator } from "@/services/monitor-query-generator.js"; +import { ResearchPromptBuilder } from "@/services/research-prompt-builder.js"; +import { RiskScorer } from "@/services/risk-scorer.js"; +import { SlackFormatter } from "@/services/slack-formatter.js"; +import { BatchPlanner } from "@/services/batch-planner.js"; +import type { Vendor } from "@/models/vendor.js"; +import type { DeepResearchOutput } from "@/models/risk-assessment.js"; + +const silentLogger = { debug: vi.fn(), warn: vi.fn(), error: vi.fn() }; + +const fixturesDir = join(import.meta.dirname ?? __dirname, "..", "fixtures"); +const sampleCsv = readFileSync(join(fixturesDir, "sample-vendors.csv"), "utf-8"); +const deepResearchOutput = JSON.parse( + readFileSync(join(fixturesDir, "deep-research-output.json"), "utf-8"), +) as DeepResearchOutput; + +describe("Full Pipeline Integration", () => { + const ingestion = new VendorIngestionService({ logger: silentLogger }); + const queryGenerator = new MonitorQueryGenerator(); + const promptBuilder = new ResearchPromptBuilder(); + const riskScorer = new RiskScorer(); + const formatter = new SlackFormatter(); + const batchPlanner = new BatchPlanner(); + + let vendors: Vendor[]; + + beforeEach(async () => { + vendors = await ingestion.ingestFromCSV(sampleCsv); + }); + + it("ingests 10 vendors from CSV fixture (1 inactive)", () => { + expect(vendors).toHaveLength(10); + const active = vendors.filter((v) => v.active); + expect(active).toHaveLength(9); + }); + + it("computes diff showing all as added when no previous state", () => { + const diff = ingestion.computeDiff(vendors, []); + expect(diff.added).toHaveLength(10); + expect(diff.removed).toHaveLength(0); + expect(diff.unchanged).toHaveLength(0); + expect(diff.modified).toHaveLength(0); + }); + + it("generates correct monitor counts per priority", () => { + const highVendors = vendors.filter((v) => v.monitoring_priority === "high"); + const medVendors = vendors.filter((v) => v.monitoring_priority === "medium"); + const lowVendors = vendors.filter((v) => v.monitoring_priority === "low"); + + expect(highVendors).toHaveLength(3); + expect(medVendors).toHaveLength(3); + expect(lowVendors).toHaveLength(4); + + // high = 5 monitors each, medium = 3, low = 2 + const totalMonitors = + highVendors.length * 5 + medVendors.length * 3 + lowVendors.length * 2; + expect(totalMonitors).toBe(3 * 5 + 3 * 3 + 4 * 2); // 15 + 9 + 8 = 32 + + // Verify via query generator + for (const v of highVendors) { + expect(queryGenerator.generateQueries(v)).toHaveLength(5); + } + for (const v of medVendors) { + expect(queryGenerator.generateQueries(v)).toHaveLength(3); + } + for (const v of lowVendors) { + expect(queryGenerator.generateQueries(v)).toHaveLength(2); + } + }); + + it("builds prompts for each vendor", () => { + for (const v of vendors) { + const prompt = promptBuilder.buildPrompt(v); + expect(prompt).toContain(v.vendor_name); + expect(prompt).toContain(v.vendor_domain); + expect(prompt.length).toBeGreaterThan(100); + } + }); + + it("plans batches correctly", () => { + const activeVendors = vendors.filter((v) => v.active); + const batches = batchPlanner.planBatches(activeVendors, 50); + expect(batches).toHaveLength(1); // 9 active < 50 + expect(batches[0].vendors).toHaveLength(9); + }); + + it("scores deep research output and produces valid assessment", () => { + const assessment = riskScorer.scoreDeepResearch(deepResearchOutput); + + expect(assessment.risk_level).toBe("HIGH"); + expect(assessment.adverse_flag).toBe(true); + expect(assessment.action_required).toBe(true); + expect(assessment.recommendation).toBe("initiate_contingency"); + expect(assessment.severity_counts.high).toBe(1); + expect(assessment.severity_counts.medium).toBe(1); + expect(assessment.severity_counts.low).toBe(3); + expect(assessment.risk_categories).toContain("financial_health"); + }); + + it("formats CRITICAL/HIGH assessments as critical alerts", () => { + const assessment = riskScorer.scoreDeepResearch(deepResearchOutput); + const msg = formatter.formatCriticalAlert( + assessment, + vendors[0], + deepResearchOutput.adverse_events, + ); + + expect(msg.channel).toBeDefined(); + expect(msg.text).toContain("Acme Corp"); + expect(msg.blocks.length).toBeGreaterThan(3); + }); + + it("routes assessments by risk level", () => { + const lowOutput: DeepResearchOutput = { + ...deepResearchOutput, + financial_health: { status: "stable", findings: "ok", severity: "LOW" }, + legal_regulatory: { status: "stable", findings: "ok", severity: "LOW" }, + }; + const lowAssessment = riskScorer.scoreDeepResearch(lowOutput); + expect(lowAssessment.risk_level).toBe("LOW"); + + const channel = formatter.routeByRiskLevel(lowAssessment.risk_level); + expect(channel).toContain("digest"); + }); + + it("output schema has all required fields", () => { + const schema = promptBuilder.getOutputSchema(); + expect(schema.type).toBe("json"); + const props = (schema.json_schema as Record).properties as Record; + expect(props).toHaveProperty("vendor_name"); + expect(props).toHaveProperty("financial_health"); + expect(props).toHaveProperty("adverse_events"); + expect(props).toHaveProperty("recommendation"); + }); +}); diff --git a/typescript-recipes/parallel-n8n-procurement/tests/integration/scale-simulation.test.ts b/typescript-recipes/parallel-n8n-procurement/tests/integration/scale-simulation.test.ts new file mode 100644 index 0000000..ad8cd99 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/tests/integration/scale-simulation.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect } from "vitest"; +import { BatchPlanner } from "@/services/batch-planner.js"; +import { MonitorQueryGenerator } from "@/services/monitor-query-generator.js"; +import type { Vendor } from "@/models/vendor.js"; + +function makeVendor( + index: number, + priority: "high" | "medium" | "low" = "high", + nextResearchDate?: string, +): Vendor { + return { + vendor_name: `Vendor ${index}`, + vendor_domain: `https://vendor${index}.com`, + vendor_category: "technology", + monitoring_priority: priority, + active: true, + next_research_date: nextResearchDate, + }; +} + +const batchPlanner = new BatchPlanner(); +const queryGenerator = new MonitorQueryGenerator(); + +describe("Scale Simulation", () => { + // ── 200 Vendors → 4 Batches ────────────────────────────────────────── + + describe("200 vendors batching", () => { + it("produces 4 batches of 50", () => { + const vendors = Array.from({ length: 200 }, (_, i) => makeVendor(i)); + const batches = batchPlanner.planBatches(vendors, 50); + + expect(batches).toHaveLength(4); + expect(batches[0].vendors).toHaveLength(50); + expect(batches[1].vendors).toHaveLength(50); + expect(batches[2].vendors).toHaveLength(50); + expect(batches[3].vendors).toHaveLength(50); + }); + }); + + // ── 3000 Vendors with 15-Day Rotation ──────────────────────────────── + + describe("3000 vendors with 15-day rotation", () => { + it("~200 vendors are due per day", () => { + // Distribute 3000 vendors across 15 days + const vendors: Vendor[] = []; + for (let i = 0; i < 3000; i++) { + const dayOffset = i % 15; // 0-14 + const date = new Date("2026-03-01"); + date.setDate(date.getDate() + dayOffset); + vendors.push(makeVendor(i, "high", date.toISOString())); + } + + // Check how many are due on day 5 (2026-03-06) + const due = batchPlanner.getVendorsDueForResearch(vendors, "2026-03-06"); + // Days 0-6 should be due (7 days × 200 per day = 1400) + // But specifically, vendors with dates on day 0-6 (March 1-7) + // Each day has 3000/15 = 200 vendors + // Due = days 0 through 5 = 6 days × 200 = 1200 + expect(due.length).toBe(1200); + + // On just day 0 (March 1), exactly 200 should be due + const dueDay0 = batchPlanner.getVendorsDueForResearch(vendors, "2026-03-01"); + expect(dueDay0.length).toBe(200); // Only day 0 vendors + }); + + it("daily batch of 200 produces 4 batches of 50", () => { + const dailyVendors = Array.from({ length: 200 }, (_, i) => makeVendor(i)); + const batches = batchPlanner.planBatches(dailyVendors, 50); + expect(batches).toHaveLength(4); + }); + }); + + // ── Monitor Count Calculations ─────────────────────────────────────── + + describe("monitor count calculations", () => { + it("200 high-priority vendors × 5 monitors = 1000", () => { + const vendors = Array.from({ length: 200 }, (_, i) => makeVendor(i, "high")); + let totalMonitors = 0; + for (const v of vendors) { + totalMonitors += queryGenerator.generateQueries(v).length; + } + expect(totalMonitors).toBe(1000); + }); + + it("mixed priorities: 100 high + 50 medium + 50 low = 750 monitors", () => { + const high = Array.from({ length: 100 }, (_, i) => makeVendor(i, "high")); + const medium = Array.from({ length: 50 }, (_, i) => makeVendor(100 + i, "medium")); + const low = Array.from({ length: 50 }, (_, i) => makeVendor(150 + i, "low")); + + let total = 0; + for (const v of [...high, ...medium, ...low]) { + total += queryGenerator.generateQueries(v).length; + } + + // 100×5 + 50×3 + 50×2 = 500 + 150 + 100 = 750 + expect(total).toBe(750); + }); + + it("each high vendor gets exactly 5 distinct risk dimensions", () => { + const vendor = makeVendor(0, "high"); + const queries = queryGenerator.generateQueries(vendor); + const dimensions = new Set(queries.map((q) => q.risk_dimension)); + expect(dimensions.size).toBe(5); + expect(dimensions).toEqual(new Set(["legal", "cyber", "financial", "leadership", "esg"])); + }); + + it("each medium vendor gets exactly 3 dimensions", () => { + const vendor = makeVendor(0, "medium"); + const queries = queryGenerator.generateQueries(vendor); + const dimensions = new Set(queries.map((q) => q.risk_dimension)); + expect(dimensions.size).toBe(3); + expect(dimensions).toEqual(new Set(["legal", "cyber", "financial"])); + }); + + it("each low vendor gets exactly 2 dimensions", () => { + const vendor = makeVendor(0, "low"); + const queries = queryGenerator.generateQueries(vendor); + const dimensions = new Set(queries.map((q) => q.risk_dimension)); + expect(dimensions.size).toBe(2); + expect(dimensions).toEqual(new Set(["legal", "financial"])); + }); + }); + + // ── Large Batch Splitting ──────────────────────────────────────────── + + describe("large batch edge cases", () => { + it("1000 vendors → 20 batches", () => { + const vendors = Array.from({ length: 1000 }, (_, i) => makeVendor(i)); + const batches = batchPlanner.planBatches(vendors, 50); + expect(batches).toHaveLength(20); + }); + + it("batch indices are sequential", () => { + const vendors = Array.from({ length: 250 }, (_, i) => makeVendor(i)); + const batches = batchPlanner.planBatches(vendors, 50); + expect(batches.map((b) => b.batch_index)).toEqual([0, 1, 2, 3, 4]); + }); + + it("remainder batch is correctly sized", () => { + const vendors = Array.from({ length: 237 }, (_, i) => makeVendor(i)); + const batches = batchPlanner.planBatches(vendors, 50); + expect(batches).toHaveLength(5); + expect(batches[4].vendors).toHaveLength(37); + }); + }); +}); diff --git a/typescript-recipes/parallel-n8n-procurement/tests/integration/vendor-lifecycle.test.ts b/typescript-recipes/parallel-n8n-procurement/tests/integration/vendor-lifecycle.test.ts new file mode 100644 index 0000000..5c46a1f --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/tests/integration/vendor-lifecycle.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { VendorIngestionService } from "@/services/vendor-ingestion.js"; +import type { MonitorPortfolioManager } from "@/services/monitor-portfolio-manager.js"; +import type { Vendor } from "@/models/vendor.js"; + +const silentLogger = { debug: vi.fn(), warn: vi.fn(), error: vi.fn() }; + +function v(domain: string, priority: "high" | "medium" | "low" = "high", monitorIds?: string[]): Vendor { + return { + vendor_name: domain.replace("https://", "").replace(".com", ""), + vendor_domain: domain, + vendor_category: "technology", + monitoring_priority: priority, + active: true, + ...(monitorIds ? { monitor_ids: monitorIds } : {}), + }; +} + +describe("Vendor Lifecycle Integration", () => { + const ingestion = new VendorIngestionService({ logger: silentLogger }); + let mockPortfolio: { + deployMonitors: ReturnType; + removeMonitors: ReturnType; + }; + + beforeEach(() => { + vi.clearAllMocks(); + let counter = 0; + mockPortfolio = { + deployMonitors: vi.fn().mockImplementation(async (vendors: Vendor[]) => { + const map = new Map(); + for (const vendor of vendors) { + counter++; + map.set(vendor.vendor_domain, [`mon_${counter}_a`, `mon_${counter}_b`]); + } + return map; + }), + removeMonitors: vi.fn().mockResolvedValue(undefined), + }; + }); + + it("Cycle 1: initial sync — 10 added, monitors deployed", async () => { + const incoming = Array.from({ length: 10 }, (_, i) => v(`https://v${i}.com`)); + const previous: Vendor[] = []; + + const diff = ingestion.computeDiff(incoming, previous); + expect(diff.added).toHaveLength(10); + expect(diff.removed).toHaveLength(0); + + const result = await ingestion.applyDiff( + diff, + mockPortfolio as unknown as MonitorPortfolioManager, + ); + expect(mockPortfolio.deployMonitors).toHaveBeenCalledTimes(1); + expect(mockPortfolio.deployMonitors).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ vendor_domain: "https://v0.com" })]), + ); + expect(result.monitors_created.size).toBe(10); + }); + + it("Cycle 2: 2 removed, 3 added, 1 priority changed", async () => { + // Previous state (from cycle 1) + const previous = Array.from({ length: 10 }, (_, i) => + v(`https://v${i}.com`, "high", [`mon_prev_${i}`]), + ); + + // New state: remove v0, v1; add v10, v11, v12; change v2 priority low→high + const incoming = [ + ...previous.slice(2).map((vendor, i) => + i === 0 + ? { ...vendor, monitoring_priority: "low" as const } // v2 changed from high to low + : vendor, + ), + v("https://v10.com"), + v("https://v11.com"), + v("https://v12.com"), + ]; + + const diff = ingestion.computeDiff(incoming, previous); + + expect(diff.added).toHaveLength(3); + expect(diff.added.map((a) => a.vendor_domain)).toEqual([ + "https://v10.com", + "https://v11.com", + "https://v12.com", + ]); + + expect(diff.removed).toHaveLength(2); + expect(diff.removed.map((r) => r.vendor_domain).sort()).toEqual([ + "https://v0.com", + "https://v1.com", + ]); + + expect(diff.modified).toHaveLength(1); + expect(diff.modified[0].vendor.vendor_domain).toBe("https://v2.com"); + expect(diff.modified[0].changes).toContain("monitoring_priority"); + + expect(diff.unchanged).toHaveLength(7); + + // Apply diff + const result = await ingestion.applyDiff( + diff, + mockPortfolio as unknown as MonitorPortfolioManager, + ); + + // Verify: monitors created for 3 new + 1 adjusted = deployMonitors called for added + modified + expect(mockPortfolio.deployMonitors).toHaveBeenCalled(); + + // Verify: monitors deleted for 2 removed + 1 modified (old monitors) + expect(mockPortfolio.removeMonitors).toHaveBeenCalled(); + expect(result.monitors_deleted).toContain("mon_prev_0"); // v0 removed + expect(result.monitors_deleted).toContain("mon_prev_1"); // v1 removed + expect(result.monitors_deleted).toContain("mon_prev_2"); // v2 adjusted + + expect(result.monitors_adjusted).toContain("https://v2.com"); + }); + + it("Cycle 3: no changes — no monitor API calls", async () => { + const state = Array.from({ length: 8 }, (_, i) => + v(`https://v${i + 2}.com`, "high", [`mon_${i}`]), + ); + + const diff = ingestion.computeDiff(state, state); + + expect(diff.added).toHaveLength(0); + expect(diff.removed).toHaveLength(0); + expect(diff.modified).toHaveLength(0); + expect(diff.unchanged).toHaveLength(8); + + const result = await ingestion.applyDiff( + diff, + mockPortfolio as unknown as MonitorPortfolioManager, + ); + + expect(mockPortfolio.deployMonitors).not.toHaveBeenCalled(); + expect(mockPortfolio.removeMonitors).not.toHaveBeenCalled(); + expect(result.monitors_created.size).toBe(0); + expect(result.monitors_deleted).toHaveLength(0); + }); +}); diff --git a/typescript-recipes/parallel-n8n-procurement/tests/models/monitor-api.test.ts b/typescript-recipes/parallel-n8n-procurement/tests/models/monitor-api.test.ts new file mode 100644 index 0000000..4caa2e4 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/tests/models/monitor-api.test.ts @@ -0,0 +1,373 @@ +import { describe, it, expect } from "vitest"; +import { + MonitorSchema, + MonitorListResponseSchema, + MonitorEventSchema, + MonitorWebhookSchema, + MonitorMetadataSchema, + MonitorOutputSchemaDefinition, + MonitorWebhookPayloadSchema, + EventGroupDetailsSchema, + MonitorCadenceSchema, + MonitorStatusSchema, + MonitorEventTypeSchema, +} from "@/models/monitor-api.js"; + +// ── Enums ────────────────────────────────────────────────────────────────── + +describe("MonitorCadenceSchema", () => { + it("accepts daily and weekly", () => { + expect(MonitorCadenceSchema.safeParse("daily").success).toBe(true); + expect(MonitorCadenceSchema.safeParse("weekly").success).toBe(true); + }); + + it("rejects invalid cadence", () => { + expect(MonitorCadenceSchema.safeParse("hourly").success).toBe(false); + expect(MonitorCadenceSchema.safeParse("monthly").success).toBe(false); + }); +}); + +describe("MonitorStatusSchema", () => { + it("accepts active and canceled", () => { + expect(MonitorStatusSchema.safeParse("active").success).toBe(true); + expect(MonitorStatusSchema.safeParse("canceled").success).toBe(true); + }); + + it("rejects invalid status", () => { + expect(MonitorStatusSchema.safeParse("paused").success).toBe(false); + }); +}); + +describe("MonitorEventTypeSchema", () => { + it("accepts all event types", () => { + for (const t of ["event", "error", "completion"]) { + expect(MonitorEventTypeSchema.safeParse(t).success).toBe(true); + } + }); +}); + +// ── MonitorSchema ────────────────────────────────────────────────────────── + +describe("MonitorSchema", () => { + const validMonitor = { + monitor_id: "mon_abc123", + query: "Monitor Acme Corp for regulatory changes", + status: "active", + cadence: "daily", + }; + + it("accepts a minimal valid monitor", () => { + const result = MonitorSchema.safeParse(validMonitor); + expect(result.success).toBe(true); + }); + + it("accepts a fully populated monitor", () => { + const result = MonitorSchema.safeParse({ + ...validMonitor, + metadata: { + vendor_name: "Acme Corp", + vendor_domain: "https://acme.com", + monitor_category: "regulatory", + risk_dimension: "compliance", + }, + webhook: { url: "https://example.com/hook", event_types: ["monitor.event.detected"] }, + output_schema: { type: "json", json_schema: {} }, + created_at: "2026-03-05T00:00:00Z", + last_run_at: "2026-03-05T06:00:00Z", + }); + expect(result.success).toBe(true); + }); + + it("accepts webhook as a plain string", () => { + const result = MonitorSchema.safeParse({ + ...validMonitor, + webhook: "https://example.com/hook", + }); + expect(result.success).toBe(true); + }); + + it("accepts null webhook and last_run_at", () => { + const result = MonitorSchema.safeParse({ + ...validMonitor, + webhook: null, + last_run_at: null, + }); + expect(result.success).toBe(true); + }); + + it("passes through extra fields", () => { + const result = MonitorSchema.parse({ + ...validMonitor, + some_future_field: "value", + }); + expect((result as Record).some_future_field).toBe("value"); + }); + + it("rejects missing monitor_id", () => { + const { monitor_id, ...rest } = validMonitor; + expect(MonitorSchema.safeParse(rest).success).toBe(false); + }); + + it("rejects missing query", () => { + const { query, ...rest } = validMonitor; + expect(MonitorSchema.safeParse(rest).success).toBe(false); + }); + + it("rejects invalid status", () => { + expect( + MonitorSchema.safeParse({ ...validMonitor, status: "paused" }).success, + ).toBe(false); + }); +}); + +// ── MonitorListResponseSchema ────────────────────────────────────────────── + +describe("MonitorListResponseSchema", () => { + it("accepts a response with monitors", () => { + const result = MonitorListResponseSchema.safeParse({ + monitors: [ + { monitor_id: "m1", query: "q1", status: "active", cadence: "daily" }, + ], + total_count: 1, + }); + expect(result.success).toBe(true); + }); + + it("accepts empty monitors array", () => { + const result = MonitorListResponseSchema.safeParse({ + monitors: [], + }); + expect(result.success).toBe(true); + }); +}); + +// ── MonitorWebhookSchema ─────────────────────────────────────────────────── + +describe("MonitorWebhookSchema", () => { + it("accepts valid webhook", () => { + const result = MonitorWebhookSchema.safeParse({ + url: "https://example.com/hook", + event_types: ["monitor.event.detected"], + }); + expect(result.success).toBe(true); + }); + + it("defaults event_types", () => { + const result = MonitorWebhookSchema.parse({ + url: "https://example.com/hook", + }); + expect(result.event_types).toEqual(["monitor.event.detected"]); + }); + + it("rejects invalid URL", () => { + expect( + MonitorWebhookSchema.safeParse({ url: "not-a-url" }).success, + ).toBe(false); + }); +}); + +// ── MonitorMetadataSchema ────────────────────────────────────────────────── + +describe("MonitorMetadataSchema", () => { + it("accepts valid PRD metadata", () => { + const result = MonitorMetadataSchema.safeParse({ + vendor_name: "Acme Corp", + vendor_domain: "https://acme.com", + monitor_category: "regulatory", + risk_dimension: "compliance", + }); + expect(result.success).toBe(true); + }); + + it("allows additional properties", () => { + const result = MonitorMetadataSchema.parse({ + vendor_name: "Acme", + vendor_domain: "https://acme.com", + monitor_category: "financial", + risk_dimension: "credit", + custom_field: "extra_value", + }); + expect(result.custom_field).toBe("extra_value"); + }); + + it("rejects missing required fields", () => { + expect( + MonitorMetadataSchema.safeParse({ vendor_name: "Acme" }).success, + ).toBe(false); + }); +}); + +// ── MonitorOutputSchemaDefinition ────────────────────────────────────────── + +describe("MonitorOutputSchemaDefinition", () => { + it("accepts valid PRD Section 5.3 output", () => { + const result = MonitorOutputSchemaDefinition.safeParse({ + event_summary: "Regulatory fine imposed on vendor", + severity: "HIGH", + adverse: true, + event_type: "regulatory_action", + }); + expect(result.success).toBe(true); + }); + + it("rejects missing event_summary", () => { + expect( + MonitorOutputSchemaDefinition.safeParse({ + severity: "LOW", + adverse: false, + event_type: "news", + }).success, + ).toBe(false); + }); + + it("rejects non-boolean adverse", () => { + expect( + MonitorOutputSchemaDefinition.safeParse({ + event_summary: "test", + severity: "LOW", + adverse: "yes", + event_type: "news", + }).success, + ).toBe(false); + }); +}); + +// ── MonitorEventSchema ───────────────────────────────────────────────────── + +describe("MonitorEventSchema", () => { + it("accepts an event type", () => { + const result = MonitorEventSchema.safeParse({ + type: "event", + event_id: "evt_123", + event_group_id: "eg_456", + monitor_id: "mon_789", + event_date: "2026-03-05", + output: "Vendor announced layoffs", + source_urls: ["https://news.example.com/article"], + }); + expect(result.success).toBe(true); + }); + + it("accepts an error type", () => { + const result = MonitorEventSchema.safeParse({ + type: "error", + event_id: "evt_123", + error: "Failed to process", + }); + expect(result.success).toBe(true); + }); + + it("accepts a completion type", () => { + const result = MonitorEventSchema.safeParse({ + type: "completion", + event_id: "evt_123", + monitor_id: "mon_789", + }); + expect(result.success).toBe(true); + }); + + it("accepts output as object", () => { + const result = MonitorEventSchema.safeParse({ + type: "event", + output: { event_summary: "Layoffs announced", severity: "HIGH" }, + }); + expect(result.success).toBe(true); + }); + + it("rejects invalid type", () => { + expect( + MonitorEventSchema.safeParse({ type: "unknown" }).success, + ).toBe(false); + }); +}); + +// ── EventGroupDetailsSchema ──────────────────────────────────────────────── + +describe("EventGroupDetailsSchema", () => { + it("accepts valid event group details", () => { + const result = EventGroupDetailsSchema.safeParse({ + event_group_id: "eg_123", + monitor_id: "mon_456", + events: [ + { type: "event", event_id: "evt_1", output: "Some finding" }, + ], + }); + expect(result.success).toBe(true); + }); + + it("accepts with metadata", () => { + const result = EventGroupDetailsSchema.safeParse({ + event_group_id: "eg_123", + monitor_id: "mon_456", + events: [], + metadata: { vendor_name: "Acme" }, + }); + expect(result.success).toBe(true); + }); + + it("rejects missing event_group_id", () => { + expect( + EventGroupDetailsSchema.safeParse({ + monitor_id: "mon_456", + events: [], + }).success, + ).toBe(false); + }); +}); + +// ── MonitorWebhookPayloadSchema ──────────────────────────────────────────── + +describe("MonitorWebhookPayloadSchema", () => { + it("accepts a valid inbound webhook payload", () => { + const result = MonitorWebhookPayloadSchema.safeParse({ + type: "monitor.event.detected", + data: { + monitor_id: "mon_123", + event: { + event_group_id: "eg_456", + output: "Adverse finding", + source_urls: ["https://example.com"], + }, + metadata: { + vendor_name: "Acme Corp", + vendor_domain: "https://acme.com", + }, + }, + }); + expect(result.success).toBe(true); + }); + + it("accepts without metadata", () => { + const result = MonitorWebhookPayloadSchema.safeParse({ + type: "monitor.event.detected", + data: { + monitor_id: "mon_123", + event: { event_group_id: "eg_456" }, + }, + }); + expect(result.success).toBe(true); + }); + + it("rejects missing data.monitor_id", () => { + expect( + MonitorWebhookPayloadSchema.safeParse({ + type: "monitor.event.detected", + data: { + event: { event_group_id: "eg_456" }, + }, + }).success, + ).toBe(false); + }); + + it("rejects missing data.event.event_group_id", () => { + expect( + MonitorWebhookPayloadSchema.safeParse({ + type: "monitor.event.detected", + data: { + monitor_id: "mon_123", + event: {}, + }, + }).success, + ).toBe(false); + }); +}); diff --git a/typescript-recipes/parallel-n8n-procurement/tests/models/monitor-query.test.ts b/typescript-recipes/parallel-n8n-procurement/tests/models/monitor-query.test.ts new file mode 100644 index 0000000..84a2b68 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/tests/models/monitor-query.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect } from "vitest"; +import { + RiskDimensionSchema, + MonitorQuerySetSchema, + MonitorRegistryEntrySchema, + ReconcileResultSchema, +} from "@/models/monitor-query.js"; + +describe("RiskDimensionSchema", () => { + it("accepts all valid dimensions", () => { + for (const d of ["legal", "cyber", "financial", "leadership", "esg"]) { + expect(RiskDimensionSchema.safeParse(d).success).toBe(true); + } + }); + + it("rejects invalid dimension", () => { + expect(RiskDimensionSchema.safeParse("political").success).toBe(false); + }); +}); + +describe("MonitorQuerySetSchema", () => { + it("accepts a valid query set", () => { + const result = MonitorQuerySetSchema.safeParse({ + query: '"Acme" lawsuit OR litigation', + risk_dimension: "legal", + cadence: "daily", + monitor_category: "Legal & Regulatory", + }); + expect(result.success).toBe(true); + }); + + it("rejects missing query", () => { + expect( + MonitorQuerySetSchema.safeParse({ + risk_dimension: "legal", + cadence: "daily", + monitor_category: "Legal", + }).success, + ).toBe(false); + }); + + it("rejects invalid risk_dimension", () => { + expect( + MonitorQuerySetSchema.safeParse({ + query: "test", + risk_dimension: "unknown", + cadence: "daily", + monitor_category: "Test", + }).success, + ).toBe(false); + }); + + it("rejects invalid cadence", () => { + expect( + MonitorQuerySetSchema.safeParse({ + query: "test", + risk_dimension: "legal", + cadence: "hourly", + monitor_category: "Test", + }).success, + ).toBe(false); + }); +}); + +describe("MonitorRegistryEntrySchema", () => { + it("accepts a valid entry", () => { + const result = MonitorRegistryEntrySchema.safeParse({ + monitor_id: "mon_123", + vendor_domain: "https://acme.com", + risk_dimension: "cyber", + }); + expect(result.success).toBe(true); + }); + + it("rejects missing monitor_id", () => { + expect( + MonitorRegistryEntrySchema.safeParse({ + vendor_domain: "https://acme.com", + risk_dimension: "cyber", + }).success, + ).toBe(false); + }); +}); + +describe("ReconcileResultSchema", () => { + it("accepts a valid reconcile result", () => { + const result = ReconcileResultSchema.safeParse({ + to_create: [ + { + vendor: { + vendor_name: "Acme", + vendor_domain: "https://acme.com", + vendor_category: "technology", + monitoring_priority: "high", + }, + queries: [ + { + query: '"Acme" lawsuit', + risk_dimension: "legal", + cadence: "daily", + monitor_category: "Legal & Regulatory", + }, + ], + }, + ], + to_delete: [ + { vendor_domain: "https://old.com", monitor_ids: ["mon_1", "mon_2"] }, + ], + unchanged: [{ vendor_domain: "https://stable.com" }], + }); + expect(result.success).toBe(true); + }); + + it("accepts empty arrays", () => { + const result = ReconcileResultSchema.safeParse({ + to_create: [], + to_delete: [], + unchanged: [], + }); + expect(result.success).toBe(true); + }); +}); diff --git a/typescript-recipes/parallel-n8n-procurement/tests/models/risk-assessment.test.ts b/typescript-recipes/parallel-n8n-procurement/tests/models/risk-assessment.test.ts new file mode 100644 index 0000000..7020b6b --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/tests/models/risk-assessment.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect } from "vitest"; +import { + DeepResearchOutputSchema, + MonitorEventOutputSchema, + RiskAssessmentSchema, + SeverityCountsSchema, +} from "@/models/risk-assessment.js"; + +const makeDimension = (severity = "LOW") => ({ + status: severity === "LOW" ? "stable" : "issues", + findings: "Test findings", + severity, +}); + +describe("DeepResearchOutputSchema", () => { + it("accepts a valid full output", () => { + const result = DeepResearchOutputSchema.safeParse({ + vendor_name: "Acme", + assessment_date: "2026-03-05", + overall_risk_level: "MEDIUM", + financial_health: makeDimension("MEDIUM"), + legal_regulatory: makeDimension("LOW"), + cybersecurity: makeDimension("LOW"), + leadership_governance: makeDimension("LOW"), + esg_reputation: makeDimension("LOW"), + adverse_events: [ + { + title: "Fine", + date: "2026-01-15", + category: "financial", + severity: "MEDIUM", + description: "Regulatory fine", + }, + ], + recommendation: "MONITOR", + }); + expect(result.success).toBe(true); + }); + + it("rejects invalid severity in dimension", () => { + const result = DeepResearchOutputSchema.safeParse({ + vendor_name: "Acme", + assessment_date: "2026-03-05", + overall_risk_level: "LOW", + financial_health: makeDimension("INVALID"), + legal_regulatory: makeDimension(), + cybersecurity: makeDimension(), + leadership_governance: makeDimension(), + esg_reputation: makeDimension(), + adverse_events: [], + recommendation: "APPROVE", + }); + expect(result.success).toBe(false); + }); +}); + +describe("MonitorEventOutputSchema", () => { + it("accepts valid monitor event output", () => { + const result = MonitorEventOutputSchema.safeParse({ + event_summary: "Data breach disclosed", + severity: "CRITICAL", + adverse: true, + event_type: "cybersecurity", + }); + expect(result.success).toBe(true); + }); + + it("requires adverse as boolean", () => { + expect( + MonitorEventOutputSchema.safeParse({ + event_summary: "test", + severity: "LOW", + adverse: "yes", + event_type: "legal", + }).success, + ).toBe(false); + }); +}); + +describe("RiskAssessmentSchema", () => { + it("accepts a valid assessment", () => { + const result = RiskAssessmentSchema.safeParse({ + risk_level: "HIGH", + adverse_flag: true, + risk_categories: ["cybersecurity", "legal"], + summary: "Elevated risk due to breach and litigation.", + action_required: true, + recommendation: "initiate_contingency", + severity_counts: { critical: 0, high: 2, medium: 0, low: 3 }, + triggered_overrides: [], + }); + expect(result.success).toBe(true); + }); + + it("rejects invalid recommendation", () => { + expect( + RiskAssessmentSchema.safeParse({ + risk_level: "LOW", + adverse_flag: false, + risk_categories: [], + summary: "All clear", + action_required: false, + recommendation: "do_nothing", + severity_counts: { critical: 0, high: 0, medium: 0, low: 5 }, + triggered_overrides: [], + }).success, + ).toBe(false); + }); +}); + +describe("SeverityCountsSchema", () => { + it("rejects negative counts", () => { + expect( + SeverityCountsSchema.safeParse({ + critical: -1, + high: 0, + medium: 0, + low: 0, + }).success, + ).toBe(false); + }); +}); diff --git a/typescript-recipes/parallel-n8n-procurement/tests/models/task-api.test.ts b/typescript-recipes/parallel-n8n-procurement/tests/models/task-api.test.ts new file mode 100644 index 0000000..62030c7 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/tests/models/task-api.test.ts @@ -0,0 +1,261 @@ +import { describe, it, expect } from "vitest"; +import { + TaskRunSchema, + TaskRunResultSchema, + TaskRunInputSchema, + TaskGroupSchema, + TaskGroupStatusSchema, + TaskGroupRunSchema, + TaskGroupResultsSchema, + WebhookConfigSchema, + ParallelApiError, + RunNotCompleteError, + TaskGroupTimeoutError, +} from "@/models/task-api.js"; + +// ── Error Classes ────────────────────────────────────────────────────────── + +describe("ParallelApiError", () => { + it("has correct name, status, and message", () => { + const err = new ParallelApiError("Bad request", 400, '{"detail":"invalid"}'); + expect(err.name).toBe("ParallelApiError"); + expect(err.status).toBe(400); + expect(err.message).toBe("Bad request"); + expect(err.responseBody).toBe('{"detail":"invalid"}'); + expect(err).toBeInstanceOf(Error); + }); + + it("defaults responseBody to empty string", () => { + const err = new ParallelApiError("Server error", 500); + expect(err.responseBody).toBe(""); + }); +}); + +describe("RunNotCompleteError", () => { + it("has correct name, runId, and currentStatus", () => { + const err = new RunNotCompleteError("run_123", "running"); + expect(err.name).toBe("RunNotCompleteError"); + expect(err.runId).toBe("run_123"); + expect(err.currentStatus).toBe("running"); + expect(err.message).toContain("run_123"); + expect(err.message).toContain("running"); + expect(err).toBeInstanceOf(Error); + }); +}); + +describe("TaskGroupTimeoutError", () => { + it("has correct name, taskGroupId, and elapsedMs", () => { + const err = new TaskGroupTimeoutError("tg_456", 120000); + expect(err.name).toBe("TaskGroupTimeoutError"); + expect(err.taskGroupId).toBe("tg_456"); + expect(err.elapsedMs).toBe(120000); + expect(err.message).toContain("tg_456"); + expect(err.message).toContain("120s"); + expect(err).toBeInstanceOf(Error); + }); +}); + +// ── Zod Schemas ──────────────────────────────────────────────────────────── + +describe("TaskRunSchema", () => { + it("accepts a valid task run", () => { + const result = TaskRunSchema.safeParse({ + run_id: "run_abc", + status: "queued", + }); + expect(result.success).toBe(true); + }); + + it("accepts a task run with optional fields", () => { + const result = TaskRunSchema.safeParse({ + run_id: "run_abc", + status: "failed", + is_active: false, + error: "Something went wrong", + }); + expect(result.success).toBe(true); + }); + + it("passes through extra fields from API", () => { + const result = TaskRunSchema.parse({ + run_id: "run_abc", + status: "completed", + some_future_field: true, + }); + expect((result as Record).some_future_field).toBe(true); + }); + + it("rejects missing run_id", () => { + const result = TaskRunSchema.safeParse({ status: "queued" }); + expect(result.success).toBe(false); + }); + + it("rejects invalid status", () => { + const result = TaskRunSchema.safeParse({ + run_id: "run_abc", + status: "unknown", + }); + expect(result.success).toBe(false); + }); + + it("accepts all valid statuses", () => { + for (const status of ["queued", "running", "completed", "failed", "cancelled"]) { + expect( + TaskRunSchema.safeParse({ run_id: "r", status }).success + ).toBe(true); + } + }); +}); + +describe("TaskRunResultSchema", () => { + it("accepts a text output", () => { + const result = TaskRunResultSchema.safeParse({ + output: { type: "text", content: "Some research result" }, + }); + expect(result.success).toBe(true); + }); + + it("accepts a json output", () => { + const result = TaskRunResultSchema.safeParse({ + output: { + type: "json", + content: { risk_score: "HIGH", summary: "Risky vendor" }, + }, + }); + expect(result.success).toBe(true); + }); + + it("accepts output with basis", () => { + const result = TaskRunResultSchema.safeParse({ + output: { + type: "json", + content: { score: 85 }, + basis: [ + { + field: "score", + reasoning: "Based on financial data", + citations: [{ url: "https://example.com", title: "Source" }], + confidence: "high", + }, + ], + }, + }); + expect(result.success).toBe(true); + }); + + it("rejects missing output", () => { + const result = TaskRunResultSchema.safeParse({}); + expect(result.success).toBe(false); + }); +}); + +describe("TaskRunInputSchema", () => { + it("accepts string input", () => { + const result = TaskRunInputSchema.safeParse({ input: "Research Acme Corp" }); + expect(result.success).toBe(true); + }); + + it("accepts object input", () => { + const result = TaskRunInputSchema.safeParse({ + input: { entity_name: "Acme", website: "https://acme.com" }, + }); + expect(result.success).toBe(true); + }); + + it("accepts input with processor", () => { + const result = TaskRunInputSchema.safeParse({ + input: "Research Acme", + processor: "base-fast", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.processor).toBe("base-fast"); + } + }); + + it("processor is optional", () => { + const result = TaskRunInputSchema.parse({ input: "test" }); + expect(result.processor).toBeUndefined(); + }); +}); + +describe("TaskGroupSchema", () => { + it("accepts a valid task group", () => { + const result = TaskGroupSchema.safeParse({ taskgroup_id: "tg_abc" }); + expect(result.success).toBe(true); + }); + + it("rejects missing taskgroup_id", () => { + const result = TaskGroupSchema.safeParse({}); + expect(result.success).toBe(false); + }); +}); + +describe("TaskGroupStatusSchema", () => { + it("accepts a valid status", () => { + const result = TaskGroupStatusSchema.safeParse({ + taskgroup_id: "tg_abc", + status: { + is_active: true, + num_task_runs: 10, + task_run_status_counts: { queued: 5, running: 3, completed: 2 }, + }, + }); + expect(result.success).toBe(true); + }); + + it("defaults task_run_status_counts to empty object", () => { + const result = TaskGroupStatusSchema.parse({ + taskgroup_id: "tg_abc", + status: { is_active: false, num_task_runs: 0 }, + }); + expect(result.status.task_run_status_counts).toEqual({}); + }); + + it("rejects missing is_active", () => { + const result = TaskGroupStatusSchema.safeParse({ + taskgroup_id: "tg_abc", + status: { num_task_runs: 0, task_run_status_counts: {} }, + }); + expect(result.success).toBe(false); + }); +}); + +describe("TaskGroupResultsSchema", () => { + it("accepts an array of runs", () => { + const result = TaskGroupResultsSchema.safeParse([ + { run_id: "r1", status: "completed", output: { type: "text", content: "done" } }, + { run_id: "r2", status: "failed", error: "timeout" }, + ]); + expect(result.success).toBe(true); + }); + + it("accepts an empty array", () => { + const result = TaskGroupResultsSchema.safeParse([]); + expect(result.success).toBe(true); + }); +}); + +describe("WebhookConfigSchema", () => { + it("accepts a valid webhook config", () => { + const result = WebhookConfigSchema.safeParse({ + url: "https://example.com/webhook", + events: ["task_run.status", "task_run.completed"], + }); + expect(result.success).toBe(true); + }); + + it("defaults events to ['task_run.status']", () => { + const result = WebhookConfigSchema.parse({ + url: "https://example.com/webhook", + }); + expect(result.events).toEqual(["task_run.status"]); + }); + + it("rejects invalid URL", () => { + const result = WebhookConfigSchema.safeParse({ + url: "not-a-url", + }); + expect(result.success).toBe(false); + }); +}); diff --git a/typescript-recipes/parallel-n8n-procurement/tests/models/vendor.test.ts b/typescript-recipes/parallel-n8n-procurement/tests/models/vendor.test.ts new file mode 100644 index 0000000..3dba493 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/tests/models/vendor.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect } from "vitest"; +import { + VendorSchema, + VendorRegistrySchema, + VendorCategorySchema, + RiskTierSchema, + MonitoringPrioritySchema, +} from "@/models/vendor.js"; + +describe("VendorSchema", () => { + const validVendor = { + vendor_name: "Arcesium", + vendor_domain: "https://arcesium.com", + vendor_category: "technology", + monitoring_priority: "high", + }; + + describe("valid vendors", () => { + it("accepts a minimal valid vendor", () => { + const result = VendorSchema.safeParse(validVendor); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.vendor_name).toBe("Arcesium"); + expect(result.data.active).toBe(true); + } + }); + + it("accepts a fully populated vendor", () => { + const full = { + ...validVendor, + risk_tier_override: "HIGH", + active: false, + next_research_date: "2026-04-01T00:00:00.000Z", + monitor_ids: ["mon_abc123", "mon_def456"], + last_synced_at: "2026-03-05T12:00:00.000Z", + }; + const result = VendorSchema.safeParse(full); + expect(result.success).toBe(true); + }); + }); + + describe("defaults", () => { + it("applies active=true when not provided", () => { + const result = VendorSchema.parse(validVendor); + expect(result.active).toBe(true); + }); + + it("does not override explicit active=false", () => { + const result = VendorSchema.parse({ ...validVendor, active: false }); + expect(result.active).toBe(false); + }); + }); + + describe("required field validation", () => { + it("rejects missing vendor_name", () => { + const { vendor_name, ...rest } = validVendor; + const result = VendorSchema.safeParse(rest); + expect(result.success).toBe(false); + }); + + it("rejects empty vendor_name", () => { + const result = VendorSchema.safeParse({ + ...validVendor, + vendor_name: "", + }); + expect(result.success).toBe(false); + }); + + it("rejects missing vendor_domain", () => { + const { vendor_domain, ...rest } = validVendor; + const result = VendorSchema.safeParse(rest); + expect(result.success).toBe(false); + }); + + it("rejects missing vendor_category", () => { + const { vendor_category, ...rest } = validVendor; + const result = VendorSchema.safeParse(rest); + expect(result.success).toBe(false); + }); + + it("rejects missing monitoring_priority", () => { + const { monitoring_priority, ...rest } = validVendor; + const result = VendorSchema.safeParse(rest); + expect(result.success).toBe(false); + }); + }); + + describe("enum validation", () => { + it("rejects invalid vendor_category", () => { + const result = VendorSchema.safeParse({ + ...validVendor, + vendor_category: "invalid_category", + }); + expect(result.success).toBe(false); + }); + + it("rejects invalid risk_tier_override", () => { + const result = VendorSchema.safeParse({ + ...validVendor, + risk_tier_override: "EXTREME", + }); + expect(result.success).toBe(false); + }); + + it("rejects invalid monitoring_priority", () => { + const result = VendorSchema.safeParse({ + ...validVendor, + monitoring_priority: "urgent", + }); + expect(result.success).toBe(false); + }); + + it("accepts all valid vendor categories", () => { + const categories = [ + "technology", + "financial_services", + "manufacturing", + "healthcare", + "professional_services", + "other", + ]; + for (const cat of categories) { + const result = VendorCategorySchema.safeParse(cat); + expect(result.success).toBe(true); + } + }); + + it("accepts all valid risk tiers", () => { + for (const tier of ["LOW", "MEDIUM", "HIGH", "CRITICAL"]) { + expect(RiskTierSchema.safeParse(tier).success).toBe(true); + } + }); + + it("accepts all valid monitoring priorities", () => { + for (const p of ["high", "medium", "low"]) { + expect(MonitoringPrioritySchema.safeParse(p).success).toBe(true); + } + }); + }); + + describe("format validation", () => { + it("rejects non-URL vendor_domain", () => { + const result = VendorSchema.safeParse({ + ...validVendor, + vendor_domain: "not-a-url", + }); + expect(result.success).toBe(false); + }); + + it("rejects non-ISO next_research_date", () => { + const result = VendorSchema.safeParse({ + ...validVendor, + next_research_date: "March 5, 2026", + }); + expect(result.success).toBe(false); + }); + + it("rejects non-ISO last_synced_at", () => { + const result = VendorSchema.safeParse({ + ...validVendor, + last_synced_at: "yesterday", + }); + expect(result.success).toBe(false); + }); + }); +}); + +describe("VendorRegistrySchema", () => { + it("accepts a valid registry", () => { + const registry = { + vendors: [ + { + vendor_name: "Bloomberg LP", + vendor_domain: "https://bloomberg.com", + vendor_category: "financial_services", + monitoring_priority: "low", + }, + ], + total_count: 1, + }; + const result = VendorRegistrySchema.safeParse(registry); + expect(result.success).toBe(true); + }); + + it("accepts an empty registry", () => { + const result = VendorRegistrySchema.safeParse({ + vendors: [], + total_count: 0, + }); + expect(result.success).toBe(true); + }); + + it("accepts a registry with last_sync_timestamp", () => { + const result = VendorRegistrySchema.safeParse({ + vendors: [], + total_count: 0, + last_sync_timestamp: "2026-03-05T12:00:00.000Z", + }); + expect(result.success).toBe(true); + }); + + it("rejects negative total_count", () => { + const result = VendorRegistrySchema.safeParse({ + vendors: [], + total_count: -1, + }); + expect(result.success).toBe(false); + }); + + it("rejects non-integer total_count", () => { + const result = VendorRegistrySchema.safeParse({ + vendors: [], + total_count: 1.5, + }); + expect(result.success).toBe(false); + }); +}); diff --git a/typescript-recipes/parallel-n8n-procurement/tests/services/audit-logger.test.ts b/typescript-recipes/parallel-n8n-procurement/tests/services/audit-logger.test.ts new file mode 100644 index 0000000..d4ddfb1 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/tests/services/audit-logger.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { AuditLogger } from "@/services/audit-logger.js"; +import type { AuditLogEntry } from "@/models/research-run.js"; + +// ── Mock fs ──────────────────────────────────────────────────────────────── + +vi.mock("node:fs/promises", () => ({ + appendFile: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockResolvedValue(""), +})); + +import { appendFile, readFile } from "node:fs/promises"; +const mockAppendFile = vi.mocked(appendFile); +const mockReadFile = vi.mocked(readFile); + +function makeEntry(overrides: Partial = {}): AuditLogEntry { + return { + timestamp: "2026-03-05T12:00:00.000Z", + vendor_name: "Acme Corp", + risk_level: "HIGH", + adverse_flag: true, + categories: "cybersecurity, legal_regulatory", + summary: "Elevated risk due to breach.", + run_id: "run_123", + source: "deep_research", + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockReadFile.mockResolvedValue(""); +}); + +// ── logAssessment ────────────────────────────────────────────────────────── + +describe("logAssessment", () => { + it("appends JSON line to file", async () => { + const logger = new AuditLogger("/tmp/test-audit.jsonl"); + const entry = makeEntry(); + + await logger.logAssessment(entry); + + expect(mockAppendFile).toHaveBeenCalledWith( + "/tmp/test-audit.jsonl", + expect.stringContaining("Acme Corp"), + ); + const written = mockAppendFile.mock.calls[0][1] as string; + expect(written.endsWith("\n")).toBe(true); + expect(JSON.parse(written.trim())).toEqual(entry); + }); +}); + +// ── getHistory ───────────────────────────────────────────────────────────── + +describe("getHistory", () => { + it("returns entries for specific vendor", async () => { + const logger = new AuditLogger("/tmp/test-audit.jsonl"); + const lines = [ + JSON.stringify(makeEntry({ vendor_name: "Acme Corp" })), + JSON.stringify(makeEntry({ vendor_name: "Other Co" })), + JSON.stringify(makeEntry({ vendor_name: "Acme Corp", risk_level: "LOW" })), + ].join("\n"); + mockReadFile.mockResolvedValueOnce(lines); + + const history = await logger.getHistory("Acme Corp"); + + expect(history).toHaveLength(2); + expect(history[0].vendor_name).toBe("Acme Corp"); + expect(history[1].risk_level).toBe("LOW"); + }); + + it("respects limit", async () => { + const logger = new AuditLogger("/tmp/test-audit.jsonl"); + const lines = [ + JSON.stringify(makeEntry({ summary: "First" })), + JSON.stringify(makeEntry({ summary: "Second" })), + JSON.stringify(makeEntry({ summary: "Third" })), + ].join("\n"); + mockReadFile.mockResolvedValueOnce(lines); + + const history = await logger.getHistory("Acme Corp", 2); + + expect(history).toHaveLength(2); + expect(history[0].summary).toBe("Second"); + expect(history[1].summary).toBe("Third"); + }); + + it("returns empty for nonexistent file", async () => { + const logger = new AuditLogger("/tmp/nonexistent.jsonl"); + mockReadFile.mockRejectedValueOnce(new Error("ENOENT")); + + const history = await logger.getHistory("Acme Corp"); + + expect(history).toEqual([]); + }); + + it("returns empty for vendor with no entries", async () => { + const logger = new AuditLogger("/tmp/test-audit.jsonl"); + mockReadFile.mockResolvedValueOnce( + JSON.stringify(makeEntry({ vendor_name: "Other Co" })), + ); + + const history = await logger.getHistory("Acme Corp"); + + expect(history).toEqual([]); + }); +}); diff --git a/typescript-recipes/parallel-n8n-procurement/tests/services/batch-planner.test.ts b/typescript-recipes/parallel-n8n-procurement/tests/services/batch-planner.test.ts new file mode 100644 index 0000000..becdfe1 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/tests/services/batch-planner.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { BatchPlanner } from "@/services/batch-planner.js"; +import type { Vendor } from "@/models/vendor.js"; + +function makeVendor(overrides: Partial = {}): Vendor { + return { + vendor_name: "Acme Corp", + vendor_domain: "https://acme.com", + vendor_category: "technology", + monitoring_priority: "high", + active: true, + ...overrides, + }; +} + +function makeVendors(count: number): Vendor[] { + return Array.from({ length: count }, (_, i) => + makeVendor({ + vendor_name: `Vendor ${i}`, + vendor_domain: `https://vendor${i}.com`, + }), + ); +} + +describe("BatchPlanner", () => { + const planner = new BatchPlanner(); + + // ── planBatches ──────────────────────────────────────────────────────── + + describe("planBatches", () => { + it("returns empty array for empty list", () => { + const batches = planner.planBatches([]); + expect(batches).toEqual([]); + }); + + it("returns 1 batch when list is smaller than batch size", () => { + const vendors = makeVendors(3); + const batches = planner.planBatches(vendors, 50); + + expect(batches).toHaveLength(1); + expect(batches[0].batch_index).toBe(0); + expect(batches[0].vendors).toHaveLength(3); + }); + + it("returns exact number of batches for exact multiple", () => { + const vendors = makeVendors(100); + const batches = planner.planBatches(vendors, 50); + + expect(batches).toHaveLength(2); + expect(batches[0].vendors).toHaveLength(50); + expect(batches[1].vendors).toHaveLength(50); + }); + + it("handles remainder correctly", () => { + const vendors = makeVendors(125); + const batches = planner.planBatches(vendors, 50); + + expect(batches).toHaveLength(3); + expect(batches[0].vendors).toHaveLength(50); + expect(batches[1].vendors).toHaveLength(50); + expect(batches[2].vendors).toHaveLength(25); + }); + + it("assigns correct batch_index values", () => { + const vendors = makeVendors(150); + const batches = planner.planBatches(vendors, 50); + + expect(batches.map((b) => b.batch_index)).toEqual([0, 1, 2]); + }); + + it("uses default batch size of 50", () => { + const vendors = makeVendors(75); + const batches = planner.planBatches(vendors); + + expect(batches).toHaveLength(2); + expect(batches[0].vendors).toHaveLength(50); + expect(batches[1].vendors).toHaveLength(25); + }); + + it("handles single vendor", () => { + const batches = planner.planBatches([makeVendor()]); + expect(batches).toHaveLength(1); + expect(batches[0].vendors).toHaveLength(1); + }); + + it("handles batch size of 1", () => { + const vendors = makeVendors(3); + const batches = planner.planBatches(vendors, 1); + + expect(batches).toHaveLength(3); + for (const b of batches) { + expect(b.vendors).toHaveLength(1); + } + }); + }); + + // ── getVendorsDueForResearch ─────────────────────────────────────────── + + describe("getVendorsDueForResearch", () => { + it("includes vendor with past next_research_date", () => { + const vendors = [ + makeVendor({ next_research_date: "2026-03-01T00:00:00.000Z" }), + ]; + const due = planner.getVendorsDueForResearch(vendors, "2026-03-05"); + expect(due).toHaveLength(1); + }); + + it("includes vendor with today's next_research_date", () => { + const vendors = [ + makeVendor({ next_research_date: "2026-03-05T00:00:00.000Z" }), + ]; + const due = planner.getVendorsDueForResearch(vendors, "2026-03-05"); + expect(due).toHaveLength(1); + }); + + it("excludes vendor with future next_research_date", () => { + const vendors = [ + makeVendor({ next_research_date: "2026-03-10T00:00:00.000Z" }), + ]; + const due = planner.getVendorsDueForResearch(vendors, "2026-03-05"); + expect(due).toHaveLength(0); + }); + + it("includes vendor with no next_research_date (never researched)", () => { + const vendors = [makeVendor({ next_research_date: undefined })]; + const due = planner.getVendorsDueForResearch(vendors, "2026-03-05"); + expect(due).toHaveLength(1); + }); + + it("excludes inactive vendors", () => { + const vendors = [ + makeVendor({ active: false, next_research_date: "2026-03-01T00:00:00.000Z" }), + ]; + const due = planner.getVendorsDueForResearch(vendors, "2026-03-05"); + expect(due).toHaveLength(0); + }); + + it("filters correctly with mixed vendors", () => { + const vendors = [ + makeVendor({ vendor_name: "Past", next_research_date: "2026-03-01T00:00:00.000Z" }), + makeVendor({ vendor_name: "Future", next_research_date: "2026-03-10T00:00:00.000Z" }), + makeVendor({ vendor_name: "NeverResearched", next_research_date: undefined }), + makeVendor({ vendor_name: "Inactive", active: false }), + ]; + const due = planner.getVendorsDueForResearch(vendors, "2026-03-05"); + + expect(due).toHaveLength(2); + expect(due.map((v) => v.vendor_name)).toEqual(["Past", "NeverResearched"]); + }); + }); + + // ── updateNextResearchDates ──────────────────────────────────────────── + + describe("updateNextResearchDates", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + function expectedDate(cycleDays: number): string { + const d = new Date(); + d.setDate(d.getDate() + cycleDays); + return d.toISOString(); + } + + it("advances dates by cycle length", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-05T12:00:00.000Z")); + + const vendors = [makeVendor()]; + const updated = planner.updateNextResearchDates(vendors, 7); + + expect(updated[0].next_research_date).toBe(expectedDate(7)); + }); + + it("returns new array without mutating original", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-05T12:00:00.000Z")); + + const vendors = [makeVendor({ next_research_date: "2026-03-01T00:00:00.000Z" })]; + const updated = planner.updateNextResearchDates(vendors, 14); + + expect(updated).not.toBe(vendors); + expect(vendors[0].next_research_date).toBe("2026-03-01T00:00:00.000Z"); + expect(updated[0].next_research_date).toBe(expectedDate(14)); + }); + + it("sets same date for all vendors in batch", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-05T12:00:00.000Z")); + + const vendors = makeVendors(3); + const updated = planner.updateNextResearchDates(vendors, 30); + + const dates = updated.map((v) => v.next_research_date); + expect(new Set(dates).size).toBe(1); + expect(dates[0]).toBe(expectedDate(30)); + }); + + it("preserves other vendor fields", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-05T12:00:00.000Z")); + + const vendors = [ + makeVendor({ + vendor_name: "TestCo", + monitoring_priority: "medium", + monitor_ids: ["mon_1"], + }), + ]; + const updated = planner.updateNextResearchDates(vendors, 7); + + expect(updated[0].vendor_name).toBe("TestCo"); + expect(updated[0].monitoring_priority).toBe("medium"); + expect(updated[0].monitor_ids).toEqual(["mon_1"]); + }); + }); +}); diff --git a/typescript-recipes/parallel-n8n-procurement/tests/services/event-dedup-cache.test.ts b/typescript-recipes/parallel-n8n-procurement/tests/services/event-dedup-cache.test.ts new file mode 100644 index 0000000..02998b6 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/tests/services/event-dedup-cache.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { EventDedupCache } from "@/services/event-dedup-cache.js"; +import type { EnrichedEvent } from "@/models/monitor-events.js"; + +function makeEvent(overrides: Partial = {}): EnrichedEvent { + return { + event_group_id: "eg_1", + monitor_id: "mon_1", + vendor_name: "Acme Corp", + vendor_domain: "https://acme.com", + risk_dimension: "legal", + monitoring_priority: "high", + monitor_category: "Legal & Regulatory", + event_summary: "Lawsuit filed", + severity: "HIGH", + adverse: true, + event_type: "legal_regulatory", + ...overrides, + }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("EventDedupCache", () => { + describe("generateKey", () => { + it("produces correct format", () => { + const cache = new EventDedupCache(); + const key = cache.generateKey(makeEvent()); + expect(key).toBe("https://acme.com:legal_regulatory:HIGH"); + }); + + it("differs by event_type", () => { + const cache = new EventDedupCache(); + const k1 = cache.generateKey(makeEvent({ event_type: "legal" })); + const k2 = cache.generateKey(makeEvent({ event_type: "cyber" })); + expect(k1).not.toBe(k2); + }); + + it("differs by severity", () => { + const cache = new EventDedupCache(); + const k1 = cache.generateKey(makeEvent({ severity: "HIGH" })); + const k2 = cache.generateKey(makeEvent({ severity: "CRITICAL" })); + expect(k1).not.toBe(k2); + }); + }); + + describe("has", () => { + it("returns false for nonexistent key", () => { + const cache = new EventDedupCache(); + expect(cache.has("nonexistent")).toBe(false); + }); + + it("returns true within window", () => { + vi.useFakeTimers(); + const cache = new EventDedupCache(60_000); // 1 minute + + cache.add("test-key"); + vi.advanceTimersByTime(30_000); // 30s + + expect(cache.has("test-key")).toBe(true); + }); + + it("returns false outside window", () => { + vi.useFakeTimers(); + const cache = new EventDedupCache(60_000); // 1 minute + + cache.add("test-key"); + vi.advanceTimersByTime(120_000); // 2 minutes + + expect(cache.has("test-key")).toBe(false); + }); + + it("respects custom window parameter", () => { + vi.useFakeTimers(); + const cache = new EventDedupCache(60_000); + + cache.add("test-key"); + vi.advanceTimersByTime(30_000); + + expect(cache.has("test-key", 10_000)).toBe(false); // 10s window + expect(cache.has("test-key", 60_000)).toBe(true); // 60s window + }); + }); + + describe("add", () => { + it("adds key to cache", () => { + const cache = new EventDedupCache(); + cache.add("key1"); + expect(cache.has("key1")).toBe(true); + expect(cache.size).toBe(1); + }); + + it("overwrites existing key timestamp", () => { + vi.useFakeTimers(); + const cache = new EventDedupCache(60_000); + + cache.add("key1"); + vi.advanceTimersByTime(50_000); + cache.add("key1"); // refresh + vi.advanceTimersByTime(30_000); // 80s from first add, 30s from refresh + + expect(cache.has("key1")).toBe(true); // still within window from refresh + }); + }); + + describe("cleanup", () => { + it("removes expired entries", () => { + vi.useFakeTimers(); + const cache = new EventDedupCache(60_000); + + cache.add("old"); + vi.advanceTimersByTime(120_000); + cache.add("fresh"); + + cache.cleanup(); + + expect(cache.size).toBe(1); + expect(cache.has("old")).toBe(false); + expect(cache.has("fresh")).toBe(true); + }); + + it("keeps all entries when none expired", () => { + const cache = new EventDedupCache(60_000); + cache.add("a"); + cache.add("b"); + + cache.cleanup(); + + expect(cache.size).toBe(2); + }); + + it("respects custom maxAge", () => { + vi.useFakeTimers(); + const cache = new EventDedupCache(60_000); + + cache.add("key1"); + vi.advanceTimersByTime(10_000); + + cache.cleanup(5_000); // 5s max age + + expect(cache.size).toBe(0); + }); + }); +}); diff --git a/typescript-recipes/parallel-n8n-procurement/tests/services/monitor-event-handler.test.ts b/typescript-recipes/parallel-n8n-procurement/tests/services/monitor-event-handler.test.ts new file mode 100644 index 0000000..b89d351 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/tests/services/monitor-event-handler.test.ts @@ -0,0 +1,345 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { MonitorEventHandler } from "@/services/monitor-event-handler.js"; +import { EventDedupCache } from "@/services/event-dedup-cache.js"; +import type { ParallelMonitorClient } from "@/services/parallel-monitor-client.js"; +import type { RiskScorer } from "@/services/risk-scorer.js"; +import type { SlackFormatter } from "@/services/slack-formatter.js"; +import type { SlackDeliveryService } from "@/services/slack-delivery.js"; +import type { AuditLogger } from "@/services/audit-logger.js"; +import type { MonitorWebhookPayload, EventGroupDetails } from "@/models/monitor-api.js"; +import type { MonitorRegistryContext } from "@/models/monitor-events.js"; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +const silentLogger = { debug: vi.fn(), warn: vi.fn(), error: vi.fn() }; + +function makePayload(overrides: Partial = {}): MonitorWebhookPayload { + return { + type: "monitor.event.detected", + data: { + monitor_id: "mon_1", + event: { event_group_id: "eg_1" }, + metadata: { vendor_name: "Acme Corp" }, + }, + ...overrides, + }; +} + +function makeEventDetails(): EventGroupDetails { + return { + event_group_id: "eg_1", + monitor_id: "mon_1", + events: [ + { + type: "event", + event_id: "evt_1", + event_group_id: "eg_1", + monitor_id: "mon_1", + event_date: "2026-03-05", + output: { + event_summary: "Regulatory fine imposed on vendor", + severity: "HIGH", + adverse: true, + event_type: "legal_regulatory", + }, + source_urls: ["https://news.example.com/article"], + }, + { + type: "completion", + event_id: "evt_2", + monitor_id: "mon_1", + }, + ], + }; +} + +const defaultContext: MonitorRegistryContext = { + vendor_name: "Acme Corp", + vendor_domain: "https://acme.com", + risk_dimension: "legal", + monitoring_priority: "high", + monitor_category: "Legal & Regulatory", +}; + +// ── Mocks ────────────────────────────────────────────────────────────────── + +let mockMonitorClient: { getEventGroupDetails: ReturnType }; +let mockRiskScorer: { scoreMonitorEvent: ReturnType }; +let mockFormatter: { formatMonitorAlert: ReturnType }; +let mockDelivery: { sendAlert: ReturnType }; +let mockAuditLogger: { logAssessment: ReturnType }; +let mockRegistry: ReturnType; +let dedupCache: EventDedupCache; + +beforeEach(() => { + vi.clearAllMocks(); + + mockMonitorClient = { + getEventGroupDetails: vi.fn().mockResolvedValue(makeEventDetails()), + }; + + mockRiskScorer = { + scoreMonitorEvent: vi.fn().mockReturnValue({ + risk_level: "HIGH", + adverse_flag: true, + risk_categories: ["legal_regulatory"], + summary: "High risk from regulatory fine.", + action_required: true, + recommendation: "initiate_contingency", + severity_counts: { critical: 0, high: 1, medium: 0, low: 0 }, + triggered_overrides: [], + }), + }; + + mockFormatter = { + formatMonitorAlert: vi.fn().mockReturnValue({ + channel: "#alerts", + text: "Monitor alert", + blocks: [], + }), + }; + + mockDelivery = { + sendAlert: vi.fn().mockResolvedValue({ ok: true }), + }; + + mockAuditLogger = { + logAssessment: vi.fn().mockResolvedValue(undefined), + }; + + mockRegistry = vi.fn().mockReturnValue(defaultContext); + dedupCache = new EventDedupCache(60_000); // 1 minute for tests +}); + +function createHandler() { + return new MonitorEventHandler({ + monitorClient: mockMonitorClient as unknown as ParallelMonitorClient, + riskScorer: mockRiskScorer as unknown as RiskScorer, + formatter: mockFormatter as unknown as SlackFormatter, + deliveryService: mockDelivery as unknown as SlackDeliveryService, + auditLogger: mockAuditLogger as unknown as AuditLogger, + dedupCache, + monitorRegistry: mockRegistry, + logger: silentLogger, + }); +} + +// ── handleWebhookEvent ───────────────────────────────────────────────────── + +describe("handleWebhookEvent", () => { + it("processes valid payload end-to-end", async () => { + const handler = createHandler(); + const result = await handler.handleWebhookEvent(makePayload()); + + expect(result.processed).toBe(true); + expect(result.duplicate).toBe(false); + expect(result.vendor_domain).toBe("https://acme.com"); + expect(result.event_group_id).toBe("eg_1"); + expect(result.assessment).toBeDefined(); + expect(result.assessment!.risk_level).toBe("HIGH"); + + expect(mockMonitorClient.getEventGroupDetails).toHaveBeenCalledWith("mon_1", "eg_1"); + expect(mockRiskScorer.scoreMonitorEvent).toHaveBeenCalled(); + expect(mockFormatter.formatMonitorAlert).toHaveBeenCalled(); + expect(mockDelivery.sendAlert).toHaveBeenCalled(); + expect(mockAuditLogger.logAssessment).toHaveBeenCalledWith( + expect.objectContaining({ source: "monitor_event" }), + ); + }); + + it("returns error for unknown monitor_id", async () => { + mockRegistry.mockReturnValueOnce(undefined); + const handler = createHandler(); + + const result = await handler.handleWebhookEvent(makePayload()); + + expect(result.processed).toBe(false); + expect(result.error).toContain("Unknown monitor"); + expect(mockMonitorClient.getEventGroupDetails).not.toHaveBeenCalled(); + expect(mockDelivery.sendAlert).not.toHaveBeenCalled(); + }); + + it("returns duplicate=true for same event within window", async () => { + const handler = createHandler(); + + // First call — processed + const first = await handler.handleWebhookEvent(makePayload()); + expect(first.processed).toBe(true); + + // Second call — duplicate + const second = await handler.handleWebhookEvent(makePayload()); + expect(second.processed).toBe(false); + expect(second.duplicate).toBe(true); + expect(mockDelivery.sendAlert).toHaveBeenCalledTimes(1); // only first + }); + + it("processes different event_type as unique", async () => { + const handler = createHandler(); + + // First event + await handler.handleWebhookEvent(makePayload()); + + // Second event with different output + const differentDetails: EventGroupDetails = { + ...makeEventDetails(), + events: [ + { + type: "event", + event_id: "evt_3", + output: { + event_summary: "Data breach", + severity: "CRITICAL", + adverse: true, + event_type: "cybersecurity", + }, + }, + ], + }; + mockMonitorClient.getEventGroupDetails.mockResolvedValueOnce(differentDetails); + + const result = await handler.handleWebhookEvent( + makePayload({ + data: { + monitor_id: "mon_2", + event: { event_group_id: "eg_2" }, + }, + }), + ); + + expect(result.processed).toBe(true); + expect(result.duplicate).toBe(false); + }); +}); + +// ── enrichEvent ──────────────────────────────────────────────────────────── + +describe("enrichEvent", () => { + it("merges vendor context with event data", () => { + const handler = createHandler(); + const enriched = handler.enrichEvent("mon_1", makeEventDetails(), defaultContext); + + expect(enriched.vendor_name).toBe("Acme Corp"); + expect(enriched.vendor_domain).toBe("https://acme.com"); + expect(enriched.risk_dimension).toBe("legal"); + expect(enriched.monitor_category).toBe("Legal & Regulatory"); + expect(enriched.event_group_id).toBe("eg_1"); + expect(enriched.monitor_id).toBe("mon_1"); + }); + + it("parses structured output", () => { + const handler = createHandler(); + const enriched = handler.enrichEvent("mon_1", makeEventDetails(), defaultContext); + + expect(enriched.event_summary).toBe("Regulatory fine imposed on vendor"); + expect(enriched.severity).toBe("HIGH"); + expect(enriched.adverse).toBe(true); + expect(enriched.event_type).toBe("legal_regulatory"); + }); + + it("skips completion events and uses event type", () => { + const handler = createHandler(); + const details: EventGroupDetails = { + event_group_id: "eg_1", + monitor_id: "mon_1", + events: [ + { type: "completion", event_id: "evt_c" }, + { + type: "event", + event_id: "evt_1", + output: { + event_summary: "Finding", + severity: "MEDIUM", + adverse: false, + event_type: "financial", + }, + }, + ], + }; + + const enriched = handler.enrichEvent("mon_1", details, defaultContext); + + expect(enriched.event_id).toBe("evt_1"); + expect(enriched.event_summary).toBe("Finding"); + }); + + it("handles string output", () => { + const handler = createHandler(); + const details: EventGroupDetails = { + event_group_id: "eg_1", + monitor_id: "mon_1", + events: [ + { type: "event", event_id: "evt_1", output: "Plain text finding" }, + ], + }; + + const enriched = handler.enrichEvent("mon_1", details, defaultContext); + + expect(enriched.event_summary).toBe("Plain text finding"); + expect(enriched.severity).toBe("LOW"); // default + }); + + it("handles no event entries gracefully", () => { + const handler = createHandler(); + const details: EventGroupDetails = { + event_group_id: "eg_1", + monitor_id: "mon_1", + events: [{ type: "completion", event_id: "evt_c" }], + }; + + const enriched = handler.enrichEvent("mon_1", details, defaultContext); + + expect(enriched.event_summary).toBe(""); + expect(enriched.severity).toBe("LOW"); + }); +}); + +// ── isDuplicate ──────────────────────────────────────────────────────────── + +describe("isDuplicate", () => { + it("returns false for first event", () => { + const handler = createHandler(); + const enriched = handler.enrichEvent("mon_1", makeEventDetails(), defaultContext); + + expect(handler.isDuplicate(enriched)).toBe(false); + }); + + it("returns true after event is recorded", async () => { + const handler = createHandler(); + const enriched = handler.enrichEvent("mon_1", makeEventDetails(), defaultContext); + const assessment = mockRiskScorer.scoreMonitorEvent(); + + await handler.recordEvent(enriched, assessment); + + expect(handler.isDuplicate(enriched)).toBe(true); + }); +}); + +// ── recordEvent ──────────────────────────────────────────────────────────── + +describe("recordEvent", () => { + it("adds to dedup cache", async () => { + const handler = createHandler(); + const enriched = handler.enrichEvent("mon_1", makeEventDetails(), defaultContext); + const assessment = mockRiskScorer.scoreMonitorEvent(); + + await handler.recordEvent(enriched, assessment); + + expect(dedupCache.size).toBe(1); + }); + + it("logs to audit logger", async () => { + const handler = createHandler(); + const enriched = handler.enrichEvent("mon_1", makeEventDetails(), defaultContext); + const assessment = mockRiskScorer.scoreMonitorEvent(); + + await handler.recordEvent(enriched, assessment); + + expect(mockAuditLogger.logAssessment).toHaveBeenCalledWith( + expect.objectContaining({ + vendor_name: "Acme Corp", + source: "monitor_event", + run_id: "eg_1", + }), + ); + }); +}); diff --git a/typescript-recipes/parallel-n8n-procurement/tests/services/monitor-health-checker.test.ts b/typescript-recipes/parallel-n8n-procurement/tests/services/monitor-health-checker.test.ts new file mode 100644 index 0000000..22cb94c --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/tests/services/monitor-health-checker.test.ts @@ -0,0 +1,249 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import axios from "axios"; +import { MonitorHealthChecker } from "@/services/monitor-health-checker.js"; +import type { ParallelMonitorClient } from "@/services/parallel-monitor-client.js"; +import type { Monitor } from "@/models/monitor-api.js"; +import type { Vendor } from "@/models/vendor.js"; + +vi.mock("axios", () => ({ default: { get: vi.fn() } })); +const mockAxiosGet = vi.mocked(axios.get); + +const silentLogger = { debug: vi.fn(), warn: vi.fn(), error: vi.fn() }; + +function makeMonitor(overrides: Partial = {}): Monitor { + return { + monitor_id: "mon_1", + query: "test query", + status: "active", + cadence: "daily", + metadata: { vendor_name: "Acme", vendor_domain: "https://acme.com", monitor_category: "Legal", risk_dimension: "legal" }, + ...overrides, + }; +} + +function makeVendor(overrides: Partial = {}): Vendor { + return { + vendor_name: "Acme Corp", + vendor_domain: "https://acme.com", + vendor_category: "technology", + monitoring_priority: "high", + active: true, + ...overrides, + }; +} + +let mockClient: { + listMonitors: ReturnType; + deleteMonitor: ReturnType; + createMonitor: ReturnType; +}; + +beforeEach(() => { + vi.clearAllMocks(); + let createCount = 0; + mockClient = { + listMonitors: vi.fn().mockResolvedValue({ monitors: [] }), + deleteMonitor: vi.fn().mockResolvedValue(undefined), + createMonitor: vi.fn().mockImplementation(async () => { + createCount++; + return makeMonitor({ monitor_id: `mon_new_${createCount}` }); + }), + }; + mockAxiosGet.mockResolvedValue({ status: 200, data: "ok" }); +}); + +function createChecker(webhookUrl?: string) { + return new MonitorHealthChecker({ + monitorClient: mockClient as unknown as ParallelMonitorClient, + webhookUrl, + logger: silentLogger, + }); +} + +// ── detectOrphanedMonitors ───────────────────────────────────────────────── + +describe("detectOrphanedMonitors", () => { + it("monitor with vendor in registry → not orphan", () => { + const checker = createChecker(); + const monitors = [makeMonitor({ metadata: { vendor_domain: "https://acme.com" } })]; + const vendors = [makeVendor({ vendor_domain: "https://acme.com" })]; + + const orphans = checker.detectOrphanedMonitors(monitors, vendors); + expect(orphans).toHaveLength(0); + }); + + it("monitor with vendor NOT in registry → orphan", () => { + const checker = createChecker(); + const monitors = [makeMonitor({ metadata: { vendor_domain: "https://gone.com" } })]; + const vendors = [makeVendor({ vendor_domain: "https://acme.com" })]; + + const orphans = checker.detectOrphanedMonitors(monitors, vendors); + expect(orphans).toHaveLength(1); + }); + + it("monitor with no metadata → orphan", () => { + const checker = createChecker(); + const monitors = [makeMonitor({ metadata: undefined })]; + + const orphans = checker.detectOrphanedMonitors(monitors, [makeVendor()]); + expect(orphans).toHaveLength(1); + }); + + it("inactive vendor's monitor → orphan", () => { + const checker = createChecker(); + const monitors = [makeMonitor({ metadata: { vendor_domain: "https://acme.com" } })]; + const vendors = [makeVendor({ vendor_domain: "https://acme.com", active: false })]; + + const orphans = checker.detectOrphanedMonitors(monitors, vendors); + expect(orphans).toHaveLength(1); + }); + + it("empty monitors → empty", () => { + const checker = createChecker(); + expect(checker.detectOrphanedMonitors([], [makeVendor()])).toEqual([]); + }); +}); + +// ── detectFailedMonitors ─────────────────────────────────────────────────── + +describe("detectFailedMonitors", () => { + it("active monitor → not failed", () => { + const checker = createChecker(); + expect(checker.detectFailedMonitors([makeMonitor({ status: "active" })])).toHaveLength(0); + }); + + it("canceled monitor → failed", () => { + const checker = createChecker(); + const result = checker.detectFailedMonitors([makeMonitor({ status: "canceled" })]); + expect(result).toHaveLength(1); + }); + + it("mixed list → correct filtering", () => { + const checker = createChecker(); + const monitors = [ + makeMonitor({ monitor_id: "m1", status: "active" }), + makeMonitor({ monitor_id: "m2", status: "canceled" }), + makeMonitor({ monitor_id: "m3", status: "active" }), + ]; + const result = checker.detectFailedMonitors(monitors); + expect(result).toHaveLength(1); + expect(result[0].monitor_id).toBe("m2"); + }); +}); + +// ── cleanupOrphans ───────────────────────────────────────────────────────── + +describe("cleanupOrphans", () => { + it("deletes each orphan and returns counts", async () => { + const checker = createChecker(); + const orphans = [makeMonitor({ monitor_id: "o1" }), makeMonitor({ monitor_id: "o2" })]; + + const result = await checker.cleanupOrphans(orphans); + + expect(mockClient.deleteMonitor).toHaveBeenCalledTimes(2); + expect(result.deleted).toBe(2); + expect(result.failed).toBe(0); + expect(result.errors).toHaveLength(0); + }); + + it("handles deletion errors", async () => { + const checker = createChecker(); + mockClient.deleteMonitor + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error("API error")); + + const result = await checker.cleanupOrphans([ + makeMonitor({ monitor_id: "o1" }), + makeMonitor({ monitor_id: "o2" }), + ]); + + expect(result.deleted).toBe(1); + expect(result.failed).toBe(1); + expect(result.errors).toHaveLength(1); + expect(result.errors[0]).toContain("o2"); + }); +}); + +// ── recreateFailedMonitors ───────────────────────────────────────────────── + +describe("recreateFailedMonitors", () => { + it("deletes failed and creates new with same config", async () => { + const checker = createChecker(); + const failed = [makeMonitor({ monitor_id: "f1", metadata: { vendor_domain: "https://acme.com", vendor_name: "Acme", monitor_category: "Legal", risk_dimension: "legal" } })]; + const vendors = [makeVendor()]; + + const result = await checker.recreateFailedMonitors(failed, vendors); + + expect(mockClient.deleteMonitor).toHaveBeenCalledWith("f1"); + expect(mockClient.createMonitor).toHaveBeenCalledWith( + expect.objectContaining({ query: "test query", cadence: "daily" }), + ); + expect(result.recreated).toBe(1); + expect(result.new_monitor_ids).toHaveLength(1); + }); + + it("skips if vendor not found", async () => { + const checker = createChecker(); + const failed = [makeMonitor({ metadata: { vendor_domain: "https://unknown.com" } })]; + + const result = await checker.recreateFailedMonitors(failed, [makeVendor()]); + + expect(result.recreated).toBe(0); + expect(result.failed).toBe(1); + expect(mockClient.createMonitor).not.toHaveBeenCalled(); + }); + + it("returns new monitor IDs", async () => { + const checker = createChecker(); + const failed = [ + makeMonitor({ monitor_id: "f1", metadata: { vendor_domain: "https://acme.com", vendor_name: "A", monitor_category: "L", risk_dimension: "l" } }), + ]; + + const result = await checker.recreateFailedMonitors(failed, [makeVendor()]); + expect(result.new_monitor_ids[0]).toMatch(/^mon_new_/); + }); +}); + +// ── selfPingWebhook ──────────────────────────────────────────────────────── + +describe("selfPingWebhook", () => { + it("returns true for 200", async () => { + const checker = createChecker(); + mockAxiosGet.mockResolvedValueOnce({ status: 200, data: "ok" }); + + expect(await checker.selfPingWebhook("https://example.com/webhook")).toBe(true); + }); + + it("returns false for network error", async () => { + const checker = createChecker(); + mockAxiosGet.mockRejectedValueOnce(new Error("ECONNREFUSED")); + + expect(await checker.selfPingWebhook("https://example.com/webhook")).toBe(false); + }); +}); + +// ── runHealthCheck ───────────────────────────────────────────────────────── + +describe("runHealthCheck", () => { + it("runs end-to-end and compiles report", async () => { + const checker = createChecker("https://example.com/webhook"); + mockClient.listMonitors.mockResolvedValueOnce({ + monitors: [ + makeMonitor({ monitor_id: "m1", status: "active", metadata: { vendor_domain: "https://acme.com" } }), + makeMonitor({ monitor_id: "m2", status: "canceled", metadata: { vendor_domain: "https://acme.com", vendor_name: "A", monitor_category: "L", risk_dimension: "l" } }), + makeMonitor({ monitor_id: "m3", status: "active", metadata: { vendor_domain: "https://gone.com" } }), + ], + }); + + const vendors = [makeVendor({ vendor_domain: "https://acme.com" })]; + const report = await checker.runHealthCheck(vendors); + + expect(report.total_monitors).toBe(3); + expect(report.orphan_count).toBe(1); // m3 (gone.com) + expect(report.failed_count).toBe(1); // m2 (canceled) + expect(report.orphans_deleted).toBe(1); + expect(report.monitors_recreated).toBe(1); + expect(report.webhook_healthy).toBe(true); + expect(report.timestamp).toBeDefined(); + }); +}); diff --git a/typescript-recipes/parallel-n8n-procurement/tests/services/monitor-portfolio-manager.test.ts b/typescript-recipes/parallel-n8n-procurement/tests/services/monitor-portfolio-manager.test.ts new file mode 100644 index 0000000..64e05ad --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/tests/services/monitor-portfolio-manager.test.ts @@ -0,0 +1,343 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { MonitorPortfolioManager } from "@/services/monitor-portfolio-manager.js"; +import { MonitorQueryGenerator } from "@/services/monitor-query-generator.js"; +import type { ParallelMonitorClient } from "@/services/parallel-monitor-client.js"; +import type { Vendor } from "@/models/vendor.js"; +import type { MonitorRegistryEntry } from "@/models/monitor-query.js"; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function makeVendor(overrides: Partial = {}): Vendor { + return { + vendor_name: "Acme Corp", + vendor_domain: "https://acme.com", + vendor_category: "technology", + monitoring_priority: "high", + active: true, + ...overrides, + }; +} + +function makeEntry( + overrides: Partial = {}, +): MonitorRegistryEntry { + return { + monitor_id: "mon_1", + vendor_domain: "https://acme.com", + risk_dimension: "legal", + ...overrides, + }; +} + +const silentLogger = { debug: vi.fn(), warn: vi.fn(), error: vi.fn() }; + +let mockCreateMonitor: ReturnType; +let mockDeleteMonitor: ReturnType; +let mockClient: ParallelMonitorClient; +let queryGenerator: MonitorQueryGenerator; + +beforeEach(() => { + vi.clearAllMocks(); + + let callCount = 0; + mockCreateMonitor = vi.fn().mockImplementation(() => { + callCount++; + return Promise.resolve({ + monitor_id: `mon_new_${callCount}`, + query: "q", + status: "active", + cadence: "daily", + }); + }); + mockDeleteMonitor = vi.fn().mockResolvedValue(undefined); + + mockClient = { + createMonitor: mockCreateMonitor, + deleteMonitor: mockDeleteMonitor, + } as unknown as ParallelMonitorClient; + + queryGenerator = new MonitorQueryGenerator(); +}); + +function createManager(webhook?: { url: string; event_types: string[] }) { + return new MonitorPortfolioManager({ + monitorClient: mockClient, + queryGenerator, + webhook, + logger: silentLogger, + }); +} + +// ── reconcileMonitors ────────────────────────────────────────────────────── + +describe("reconcileMonitors", () => { + it("puts all new vendors into to_create", () => { + const manager = createManager(); + const vendors = [ + makeVendor({ vendor_domain: "https://a.com" }), + makeVendor({ vendor_domain: "https://b.com" }), + ]; + const registered: MonitorRegistryEntry[] = []; + + const result = manager.reconcileMonitors(vendors, registered); + + expect(result.to_create).toHaveLength(2); + expect(result.to_delete).toHaveLength(0); + expect(result.unchanged).toHaveLength(0); + }); + + it("puts all removed vendors into to_delete", () => { + const manager = createManager(); + const vendors: Vendor[] = []; + const registered = [ + makeEntry({ monitor_id: "mon_1", vendor_domain: "https://old.com" }), + makeEntry({ monitor_id: "mon_2", vendor_domain: "https://old.com" }), + ]; + + const result = manager.reconcileMonitors(vendors, registered); + + expect(result.to_create).toHaveLength(0); + expect(result.to_delete).toHaveLength(1); + expect(result.to_delete[0].vendor_domain).toBe("https://old.com"); + expect(result.to_delete[0].monitor_ids).toEqual(["mon_1", "mon_2"]); + expect(result.unchanged).toHaveLength(0); + }); + + it("handles mix of new, removed, and unchanged", () => { + const manager = createManager(); + const vendors = [ + makeVendor({ vendor_domain: "https://new.com", vendor_name: "New" }), + makeVendor({ vendor_domain: "https://stable.com", vendor_name: "Stable" }), + ]; + const registered = [ + makeEntry({ monitor_id: "mon_1", vendor_domain: "https://stable.com" }), + makeEntry({ monitor_id: "mon_2", vendor_domain: "https://gone.com" }), + ]; + + const result = manager.reconcileMonitors(vendors, registered); + + expect(result.to_create).toHaveLength(1); + expect(result.to_create[0].vendor.vendor_domain).toBe("https://new.com"); + + expect(result.to_delete).toHaveLength(1); + expect(result.to_delete[0].vendor_domain).toBe("https://gone.com"); + + expect(result.unchanged).toHaveLength(1); + expect(result.unchanged[0].vendor_domain).toBe("https://stable.com"); + }); + + it("excludes inactive vendors from to_create", () => { + const manager = createManager(); + const vendors = [ + makeVendor({ vendor_domain: "https://active.com", active: true }), + makeVendor({ vendor_domain: "https://inactive.com", active: false }), + ]; + + const result = manager.reconcileMonitors(vendors, []); + + expect(result.to_create).toHaveLength(1); + expect(result.to_create[0].vendor.vendor_domain).toBe("https://active.com"); + }); + + it("returns empty results for empty inputs", () => { + const manager = createManager(); + + const result = manager.reconcileMonitors([], []); + + expect(result.to_create).toHaveLength(0); + expect(result.to_delete).toHaveLength(0); + expect(result.unchanged).toHaveLength(0); + }); + + it("attaches generated queries to each to_create entry", () => { + const manager = createManager(); + const vendors = [makeVendor({ monitoring_priority: "medium" })]; + + const result = manager.reconcileMonitors(vendors, []); + + expect(result.to_create[0].queries).toHaveLength(3); // medium = legal, cyber, financial + }); + + it("groups multiple monitors for same vendor_domain in to_delete", () => { + const manager = createManager(); + const registered = [ + makeEntry({ monitor_id: "mon_1", vendor_domain: "https://old.com", risk_dimension: "legal" }), + makeEntry({ monitor_id: "mon_2", vendor_domain: "https://old.com", risk_dimension: "cyber" }), + makeEntry({ monitor_id: "mon_3", vendor_domain: "https://old.com", risk_dimension: "financial" }), + ]; + + const result = manager.reconcileMonitors([], registered); + + expect(result.to_delete).toHaveLength(1); + expect(result.to_delete[0].monitor_ids).toEqual(["mon_1", "mon_2", "mon_3"]); + }); +}); + +// ── deployMonitors ───────────────────────────────────────────────────────── + +describe("deployMonitors", () => { + it("creates 5 monitors for a high priority vendor", async () => { + const manager = createManager(); + const vendors = [makeVendor({ monitoring_priority: "high" })]; + + const result = await manager.deployMonitors(vendors); + + expect(mockCreateMonitor).toHaveBeenCalledTimes(5); + expect(result.get("https://acme.com")).toHaveLength(5); + }); + + it("creates 2 monitors for a low priority vendor", async () => { + const manager = createManager(); + const vendors = [makeVendor({ monitoring_priority: "low" })]; + + const result = await manager.deployMonitors(vendors); + + expect(mockCreateMonitor).toHaveBeenCalledTimes(2); + expect(result.get("https://acme.com")).toHaveLength(2); + }); + + it("passes correct metadata to createMonitor", async () => { + const manager = createManager(); + const vendors = [ + makeVendor({ + vendor_name: "TestCo", + vendor_domain: "https://testco.com", + monitoring_priority: "low", + }), + ]; + + await manager.deployMonitors(vendors); + + // First call should be legal + expect(mockCreateMonitor).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + vendor_name: "TestCo", + vendor_domain: "https://testco.com", + monitor_category: "Legal & Regulatory", + risk_dimension: "legal", + }), + }), + ); + }); + + it("passes webhook when configured", async () => { + const webhook = { + url: "https://example.com/hook", + event_types: ["monitor.event.detected"], + }; + const manager = createManager(webhook); + + await manager.deployMonitors([makeVendor({ monitoring_priority: "low" })]); + + expect(mockCreateMonitor).toHaveBeenCalledWith( + expect.objectContaining({ webhook }), + ); + }); + + it("passes output_schema to createMonitor", async () => { + const manager = createManager(); + + await manager.deployMonitors([makeVendor({ monitoring_priority: "low" })]); + + expect(mockCreateMonitor).toHaveBeenCalledWith( + expect.objectContaining({ + output_schema: expect.objectContaining({ + type: "json", + json_schema: expect.objectContaining({ + properties: expect.objectContaining({ + event_summary: { type: "string" }, + severity: expect.objectContaining({ type: "string" }), + adverse: { type: "boolean" }, + event_type: { type: "string" }, + }), + }), + }), + }), + ); + }); + + it("returns correct domain-to-ids mapping for multiple vendors", async () => { + const manager = createManager(); + const vendors = [ + makeVendor({ vendor_domain: "https://a.com", monitoring_priority: "low" }), + makeVendor({ vendor_domain: "https://b.com", monitoring_priority: "low" }), + ]; + + const result = await manager.deployMonitors(vendors); + + expect(result.get("https://a.com")).toHaveLength(2); + expect(result.get("https://b.com")).toHaveLength(2); + expect(result.size).toBe(2); + }); +}); + +// ── removeMonitors ───────────────────────────────────────────────────────── + +describe("removeMonitors", () => { + it("calls deleteMonitor for each ID", async () => { + const manager = createManager(); + + await manager.removeMonitors(["mon_1", "mon_2", "mon_3"]); + + expect(mockDeleteMonitor).toHaveBeenCalledTimes(3); + expect(mockDeleteMonitor).toHaveBeenCalledWith("mon_1"); + expect(mockDeleteMonitor).toHaveBeenCalledWith("mon_2"); + expect(mockDeleteMonitor).toHaveBeenCalledWith("mon_3"); + }); + + it("handles empty array", async () => { + const manager = createManager(); + + await manager.removeMonitors([]); + + expect(mockDeleteMonitor).not.toHaveBeenCalled(); + }); +}); + +// ── applyReconciliation ──────────────────────────────────────────────────── + +describe("applyReconciliation", () => { + it("creates and deletes monitors as specified", async () => { + const manager = createManager(); + const reconcileResult = { + to_create: [ + { + vendor: makeVendor({ monitoring_priority: "low" }), + queries: queryGenerator.generateQueries( + makeVendor({ monitoring_priority: "low" }), + ), + }, + ], + to_delete: [ + { vendor_domain: "https://old.com", monitor_ids: ["mon_old_1", "mon_old_2"] }, + ], + unchanged: [{ vendor_domain: "https://stable.com" }], + }; + + const result = await manager.applyReconciliation(reconcileResult); + + // 2 monitors created (low priority = legal + financial) + expect(mockCreateMonitor).toHaveBeenCalledTimes(2); + expect(result.created.get("https://acme.com")).toHaveLength(2); + + // 2 monitors deleted + expect(mockDeleteMonitor).toHaveBeenCalledTimes(2); + expect(result.deleted).toEqual(["mon_old_1", "mon_old_2"]); + }); + + it("handles empty reconciliation", async () => { + const manager = createManager(); + + const result = await manager.applyReconciliation({ + to_create: [], + to_delete: [], + unchanged: [], + }); + + expect(mockCreateMonitor).not.toHaveBeenCalled(); + expect(mockDeleteMonitor).not.toHaveBeenCalled(); + expect(result.created.size).toBe(0); + expect(result.deleted).toEqual([]); + }); +}); diff --git a/typescript-recipes/parallel-n8n-procurement/tests/services/monitor-query-generator.test.ts b/typescript-recipes/parallel-n8n-procurement/tests/services/monitor-query-generator.test.ts new file mode 100644 index 0000000..8880d6a --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/tests/services/monitor-query-generator.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect } from "vitest"; +import { MonitorQueryGenerator } from "@/services/monitor-query-generator.js"; +import type { Vendor } from "@/models/vendor.js"; + +function makeVendor( + overrides: Partial = {}, +): Vendor { + return { + vendor_name: "Acme Corp", + vendor_domain: "https://acme.com", + vendor_category: "technology", + monitoring_priority: "high", + active: true, + ...overrides, + }; +} + +describe("MonitorQueryGenerator", () => { + const generator = new MonitorQueryGenerator(); + + describe("high priority", () => { + it("returns 5 queries for all risk dimensions", () => { + const queries = generator.generateQueries(makeVendor({ monitoring_priority: "high" })); + expect(queries).toHaveLength(5); + + const dimensions = queries.map((q) => q.risk_dimension); + expect(dimensions).toEqual(["legal", "cyber", "financial", "leadership", "esg"]); + }); + + it("all queries have daily cadence", () => { + const queries = generator.generateQueries(makeVendor({ monitoring_priority: "high" })); + for (const q of queries) { + expect(q.cadence).toBe("daily"); + } + }); + }); + + describe("medium priority", () => { + it("returns 3 queries for legal, cyber, financial", () => { + const queries = generator.generateQueries(makeVendor({ monitoring_priority: "medium" })); + expect(queries).toHaveLength(3); + + const dimensions = queries.map((q) => q.risk_dimension); + expect(dimensions).toEqual(["legal", "cyber", "financial"]); + }); + + it("all queries have daily cadence", () => { + const queries = generator.generateQueries(makeVendor({ monitoring_priority: "medium" })); + for (const q of queries) { + expect(q.cadence).toBe("daily"); + } + }); + }); + + describe("low priority", () => { + it("returns 2 queries for legal and financial only", () => { + const queries = generator.generateQueries(makeVendor({ monitoring_priority: "low" })); + expect(queries).toHaveLength(2); + + const dimensions = queries.map((q) => q.risk_dimension); + expect(dimensions).toEqual(["legal", "financial"]); + }); + + it("all queries have weekly cadence", () => { + const queries = generator.generateQueries(makeVendor({ monitoring_priority: "low" })); + for (const q of queries) { + expect(q.cadence).toBe("weekly"); + } + }); + }); + + describe("vendor name interpolation", () => { + it("interpolates vendor_name into each query", () => { + const queries = generator.generateQueries( + makeVendor({ vendor_name: "Tesla Inc", monitoring_priority: "low" }), + ); + for (const q of queries) { + expect(q.query).toContain('"Tesla Inc"'); + expect(q.query).not.toContain("{vendor_name}"); + } + }); + + it("handles vendor names with special characters", () => { + const queries = generator.generateQueries( + makeVendor({ vendor_name: "O'Reilly & Associates", monitoring_priority: "low" }), + ); + expect(queries[0].query).toContain('"O\'Reilly & Associates"'); + }); + }); + + describe("monitor categories", () => { + it("assigns correct human-readable categories", () => { + const queries = generator.generateQueries(makeVendor({ monitoring_priority: "high" })); + const categories = queries.map((q) => q.monitor_category); + expect(categories).toEqual([ + "Legal & Regulatory", + "Cybersecurity", + "Financial Health", + "Leadership & Governance", + "ESG & Reputation", + ]); + }); + }); + + describe("query content", () => { + it("legal query contains expected keywords", () => { + const queries = generator.generateQueries(makeVendor()); + const legal = queries.find((q) => q.risk_dimension === "legal")!; + expect(legal.query).toContain("lawsuit"); + expect(legal.query).toContain("litigation"); + expect(legal.query).toContain("SEC investigation"); + }); + + it("cyber query contains expected keywords", () => { + const queries = generator.generateQueries(makeVendor()); + const cyber = queries.find((q) => q.risk_dimension === "cyber")!; + expect(cyber.query).toContain("data breach"); + expect(cyber.query).toContain("ransomware"); + }); + + it("financial query contains expected keywords", () => { + const queries = generator.generateQueries(makeVendor()); + const fin = queries.find((q) => q.risk_dimension === "financial")!; + expect(fin.query).toContain("bankruptcy"); + expect(fin.query).toContain("credit downgrade"); + }); + + it("leadership query contains expected keywords", () => { + const queries = generator.generateQueries(makeVendor()); + const lead = queries.find((q) => q.risk_dimension === "leadership")!; + expect(lead.query).toContain("CEO departure"); + expect(lead.query).toContain("merger"); + }); + + it("esg query contains expected keywords", () => { + const queries = generator.generateQueries(makeVendor()); + const esg = queries.find((q) => q.risk_dimension === "esg")!; + expect(esg.query).toContain("environmental fine"); + expect(esg.query).toContain("ESG controversy"); + }); + }); +}); diff --git a/typescript-recipes/parallel-n8n-procurement/tests/services/parallel-monitor-client.test.ts b/typescript-recipes/parallel-n8n-procurement/tests/services/parallel-monitor-client.test.ts new file mode 100644 index 0000000..3386765 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/tests/services/parallel-monitor-client.test.ts @@ -0,0 +1,508 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import axios, { AxiosError } from "axios"; +import { ParallelMonitorClient } from "@/services/parallel-monitor-client.js"; +import { ParallelApiError } from "@/models/task-api.js"; + +// ── Mock Setup ───────────────────────────────────────────────────────────── + +const mockRequest = vi.fn(); + +vi.mock("axios", () => ({ + default: { + create: vi.fn(() => ({ + request: mockRequest, + })), + }, + AxiosError: class MockAxiosError extends Error { + response: unknown; + isAxiosError = true; + constructor(message: string, _code?: string, _config?: unknown, _request?: unknown, response?: unknown) { + super(message); + this.response = response; + } + }, +})); + +const silentLogger = { + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +}; + +function createClient() { + return new ParallelMonitorClient({ + apiKey: "test-key", + baseUrl: "https://api.parallel.ai", + logger: silentLogger, + }); +} + +function mockResponse(data: T, status = 200) { + return { data, status, statusText: "OK", headers: {}, config: {} }; +} + +function makeAxiosError(status: number, body: unknown = "", headers: Record = {}) { + return new AxiosError( + `Request failed with status ${status}`, + String(status), + undefined, + undefined, + { data: body, status, statusText: "", headers, config: {} as never } as never, + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); +}); + +// ── Constructor ──────────────────────────────────────────────────────────── + +describe("ParallelMonitorClient constructor", () => { + it("creates an axios instance with correct config", () => { + createClient(); + expect(axios.create).toHaveBeenCalledWith( + expect.objectContaining({ + baseURL: "https://api.parallel.ai", + headers: expect.objectContaining({ + "x-api-key": "test-key", + "Content-Type": "application/json", + }), + }), + ); + }); +}); + +// ── createMonitor ────────────────────────────────────────────────────────── + +describe("createMonitor", () => { + const monitorConfig = { + query: "Monitor Acme Corp for regulatory changes and adverse news", + cadence: "daily" as const, + webhook: { + url: "https://example.com/webhook", + event_types: ["monitor.event.detected"], + }, + metadata: { + vendor_name: "Acme Corp", + vendor_domain: "https://acme.com", + monitor_category: "regulatory", + risk_dimension: "compliance", + }, + output_schema: { + type: "json", + json_schema: { + properties: { + event_summary: { type: "string" }, + severity: { type: "string" }, + adverse: { type: "boolean" }, + event_type: { type: "string" }, + }, + }, + }, + }; + + it("sends POST with full payload", async () => { + const client = createClient(); + mockRequest.mockResolvedValueOnce( + mockResponse({ + monitor_id: "mon_abc", + query: monitorConfig.query, + status: "active", + cadence: "daily", + metadata: monitorConfig.metadata, + created_at: "2026-03-05T00:00:00Z", + }), + ); + + const result = await client.createMonitor(monitorConfig); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: "POST", + url: "/v1alpha/monitors", + data: monitorConfig, + }), + ); + expect(result.monitor_id).toBe("mon_abc"); + expect(result.status).toBe("active"); + }); + + it("sends minimal payload without webhook or metadata", async () => { + const client = createClient(); + mockRequest.mockResolvedValueOnce( + mockResponse({ + monitor_id: "mon_abc", + query: "Monitor vendor", + status: "active", + cadence: "weekly", + }), + ); + + await client.createMonitor({ + query: "Monitor vendor", + cadence: "weekly", + }); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + data: { query: "Monitor vendor", cadence: "weekly" }, + }), + ); + }); + + it("throws ParallelApiError on 400", async () => { + const client = createClient(); + mockRequest.mockRejectedValueOnce(makeAxiosError(400, "Invalid payload")); + + await expect( + client.createMonitor({ query: "", cadence: "daily" }), + ).rejects.toThrow(ParallelApiError); + }); +}); + +// ── listMonitors ─────────────────────────────────────────────────────────── + +describe("listMonitors", () => { + it("sends GET with no params", async () => { + const client = createClient(); + mockRequest.mockResolvedValueOnce( + mockResponse({ + monitors: [ + { monitor_id: "m1", query: "q1", status: "active", cadence: "daily" }, + ], + total_count: 1, + }), + ); + + const result = await client.listMonitors(); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: "GET", + url: "/v1alpha/monitors", + }), + ); + expect(result.monitors).toHaveLength(1); + }); + + it("sends GET with pagination params", async () => { + const client = createClient(); + mockRequest.mockResolvedValueOnce( + mockResponse({ monitors: [], total_count: 0 }), + ); + + await client.listMonitors({ limit: 10, offset: 20 }); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + params: { limit: 10, offset: 20 }, + }), + ); + }); +}); + +// ── getMonitor ───────────────────────────────────────────────────────────── + +describe("getMonitor", () => { + it("sends GET to correct endpoint", async () => { + const client = createClient(); + mockRequest.mockResolvedValueOnce( + mockResponse({ + monitor_id: "mon_123", + query: "Monitor vendor", + status: "active", + cadence: "daily", + }), + ); + + const result = await client.getMonitor("mon_123"); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: "GET", + url: "/v1alpha/monitors/mon_123", + }), + ); + expect(result.monitor_id).toBe("mon_123"); + }); +}); + +// ── updateMonitor ────────────────────────────────────────────────────────── + +describe("updateMonitor", () => { + it("sends PATCH with updates", async () => { + const client = createClient(); + mockRequest.mockResolvedValueOnce( + mockResponse({ + monitor_id: "mon_123", + query: "Monitor vendor", + status: "active", + cadence: "weekly", + }), + ); + + const result = await client.updateMonitor("mon_123", { + cadence: "weekly", + }); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: "PATCH", + url: "/v1alpha/monitors/mon_123", + data: { cadence: "weekly" }, + }), + ); + expect(result.cadence).toBe("weekly"); + }); + + it("sends PATCH with webhook and metadata updates", async () => { + const client = createClient(); + mockRequest.mockResolvedValueOnce( + mockResponse({ + monitor_id: "mon_123", + query: "q", + status: "active", + cadence: "daily", + webhook: { url: "https://new.com/hook", event_types: [] }, + }), + ); + + await client.updateMonitor("mon_123", { + webhook: { url: "https://new.com/hook", event_types: [] }, + metadata: { vendor_name: "Updated Corp" }, + }); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + data: { + webhook: { url: "https://new.com/hook", event_types: [] }, + metadata: { vendor_name: "Updated Corp" }, + }, + }), + ); + }); +}); + +// ── deleteMonitor ────────────────────────────────────────────────────────── + +describe("deleteMonitor", () => { + it("sends DELETE and returns void", async () => { + const client = createClient(); + mockRequest.mockResolvedValueOnce(mockResponse(null, 204)); + + const result = await client.deleteMonitor("mon_123"); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: "DELETE", + url: "/v1alpha/monitors/mon_123", + }), + ); + expect(result).toBeUndefined(); + }); + + it("throws ParallelApiError on 404", async () => { + const client = createClient(); + mockRequest.mockRejectedValueOnce(makeAxiosError(404, "Not found")); + + await expect(client.deleteMonitor("mon_bad")).rejects.toThrow( + ParallelApiError, + ); + }); +}); + +// ── getMonitorEvents ─────────────────────────────────────────────────────── + +describe("getMonitorEvents", () => { + it("parses events from { events: [...] } response", async () => { + const client = createClient(); + mockRequest.mockResolvedValueOnce( + mockResponse({ + events: [ + { + type: "event", + event_id: "evt_1", + event_group_id: "eg_1", + monitor_id: "mon_123", + event_date: "2026-03-05", + output: "Vendor fined by regulator", + source_urls: ["https://news.example.com"], + }, + { type: "completion", event_id: "evt_2", monitor_id: "mon_123" }, + ], + }), + ); + + const events = await client.getMonitorEvents("mon_123"); + + expect(events).toHaveLength(2); + expect(events[0].type).toBe("event"); + expect(events[0].output).toBe("Vendor fined by regulator"); + expect(events[1].type).toBe("completion"); + }); + + it("parses events from bare array response", async () => { + const client = createClient(); + mockRequest.mockResolvedValueOnce( + mockResponse([ + { type: "event", event_id: "evt_1", output: "finding" }, + ]), + ); + + const events = await client.getMonitorEvents("mon_123"); + + expect(events).toHaveLength(1); + expect(events[0].type).toBe("event"); + }); + + it("sends limit param when provided", async () => { + const client = createClient(); + mockRequest.mockResolvedValueOnce( + mockResponse({ events: [] }), + ); + + await client.getMonitorEvents("mon_123", { limit: 5 }); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + url: "/v1alpha/monitors/mon_123/events", + params: { limit: 5 }, + }), + ); + }); + + it("handles events with object output", async () => { + const client = createClient(); + mockRequest.mockResolvedValueOnce( + mockResponse({ + events: [ + { + type: "event", + output: { + event_summary: "Regulatory action", + severity: "HIGH", + adverse: true, + event_type: "regulatory", + }, + }, + ], + }), + ); + + const events = await client.getMonitorEvents("mon_123"); + + expect(events[0].output).toEqual({ + event_summary: "Regulatory action", + severity: "HIGH", + adverse: true, + event_type: "regulatory", + }); + }); +}); + +// ── getEventGroupDetails ─────────────────────────────────────────────────── + +describe("getEventGroupDetails", () => { + it("sends GET to correct nested endpoint", async () => { + const client = createClient(); + mockRequest.mockResolvedValueOnce( + mockResponse({ + event_group_id: "eg_123", + monitor_id: "mon_456", + events: [ + { type: "event", event_id: "evt_1", output: "Details here" }, + ], + }), + ); + + const result = await client.getEventGroupDetails("mon_456", "eg_123"); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: "GET", + url: "/v1alpha/monitors/mon_456/event_groups/eg_123", + }), + ); + expect(result.event_group_id).toBe("eg_123"); + expect(result.events).toHaveLength(1); + }); + + it("includes metadata when present", async () => { + const client = createClient(); + mockRequest.mockResolvedValueOnce( + mockResponse({ + event_group_id: "eg_123", + monitor_id: "mon_456", + events: [], + metadata: { vendor_name: "Acme" }, + }), + ); + + const result = await client.getEventGroupDetails("mon_456", "eg_123"); + + expect(result.metadata).toEqual({ vendor_name: "Acme" }); + }); +}); + +// ── Retry Logic ──────────────────────────────────────────────────────────── + +describe("retry logic", () => { + it("retries on 429 and succeeds", async () => { + vi.useFakeTimers(); + const client = createClient(); + + mockRequest + .mockRejectedValueOnce(makeAxiosError(429)) + .mockResolvedValueOnce( + mockResponse({ + monitor_id: "mon_1", + query: "q", + status: "active", + cadence: "daily", + }), + ); + + const promise = client.getMonitor("mon_1"); + + await vi.advanceTimersByTimeAsync(1000); + + const result = await promise; + expect(result.monitor_id).toBe("mon_1"); + expect(mockRequest).toHaveBeenCalledTimes(2); + }); + + it("does not retry on 400", async () => { + const client = createClient(); + mockRequest.mockRejectedValueOnce(makeAxiosError(400)); + + await expect(client.getMonitor("mon_1")).rejects.toThrow(ParallelApiError); + expect(mockRequest).toHaveBeenCalledTimes(1); + }); + + it("retries on 500 with exponential backoff", async () => { + vi.useFakeTimers(); + const client = createClient(); + + mockRequest + .mockRejectedValueOnce(makeAxiosError(500)) + .mockRejectedValueOnce(makeAxiosError(500)) + .mockResolvedValueOnce( + mockResponse({ + monitor_id: "mon_1", + query: "q", + status: "active", + cadence: "daily", + }), + ); + + const promise = client.getMonitor("mon_1"); + + await vi.advanceTimersByTimeAsync(1000); // 1s + await vi.advanceTimersByTimeAsync(2000); // 2s + + const result = await promise; + expect(result.monitor_id).toBe("mon_1"); + expect(mockRequest).toHaveBeenCalledTimes(3); + }); +}); diff --git a/typescript-recipes/parallel-n8n-procurement/tests/services/parallel-task-client.test.ts b/typescript-recipes/parallel-n8n-procurement/tests/services/parallel-task-client.test.ts new file mode 100644 index 0000000..4aa55da --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/tests/services/parallel-task-client.test.ts @@ -0,0 +1,662 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import axios, { AxiosError, type AxiosHeaders } from "axios"; +import { ParallelTaskClient } from "@/services/parallel-task-client.js"; +import { + ParallelApiError, + RunNotCompleteError, + TaskGroupTimeoutError, +} from "@/models/task-api.js"; + +// ── Mock Setup ───────────────────────────────────────────────────────────── + +const mockRequest = vi.fn(); + +vi.mock("axios", () => ({ + default: { + create: vi.fn(() => ({ + request: mockRequest, + })), + }, + AxiosError: class MockAxiosError extends Error { + response: unknown; + isAxiosError = true; + constructor(message: string, _code?: string, _config?: unknown, _request?: unknown, response?: unknown) { + super(message); + this.response = response; + } + }, +})); + +const silentLogger = { + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +}; + +function createClient() { + return new ParallelTaskClient({ + apiKey: "test-key", + baseUrl: "https://api.parallel.ai", + logger: silentLogger, + }); +} + +function mockResponse(data: T, status = 200) { + return { data, status, statusText: "OK", headers: {}, config: {} }; +} + +function makeAxiosError(status: number, body: unknown = "", headers: Record = {}) { + const err = new AxiosError( + `Request failed with status ${status}`, + String(status), + undefined, + undefined, + { data: body, status, statusText: "", headers, config: {} as never } as never, + ); + return err; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); +}); + +// ── Constructor ──────────────────────────────────────────────────────────── + +describe("ParallelTaskClient constructor", () => { + it("creates an axios instance with correct config", () => { + createClient(); + expect(axios.create).toHaveBeenCalledWith( + expect.objectContaining({ + baseURL: "https://api.parallel.ai", + headers: expect.objectContaining({ + "x-api-key": "test-key", + "Content-Type": "application/json", + }), + }), + ); + }); +}); + +// ── createRun ────────────────────────────────────────────────────────────── + +describe("createRun", () => { + it("sends POST with correct body and default processor", async () => { + const client = createClient(); + mockRequest.mockResolvedValueOnce( + mockResponse({ run_id: "run_1", status: "queued" }), + ); + + const result = await client.createRun({ input: "Research Acme Corp" }); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: "POST", + url: "/v1/tasks/runs", + data: expect.objectContaining({ + input: "Research Acme Corp", + processor: "ultra8x", + }), + }), + ); + expect(result.run_id).toBe("run_1"); + expect(result.status).toBe("queued"); + }); + + it("uses custom processor when provided", async () => { + const client = createClient(); + mockRequest.mockResolvedValueOnce( + mockResponse({ run_id: "run_1", status: "queued" }), + ); + + await client.createRun({ input: "test", processor: "base-fast" }); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ processor: "base-fast" }), + }), + ); + }); + + it("includes output_schema in task_spec when provided", async () => { + const client = createClient(); + mockRequest.mockResolvedValueOnce( + mockResponse({ run_id: "run_1", status: "queued" }), + ); + + await client.createRun({ + input: "test", + outputSchema: { + type: "json", + json_schema: { properties: { score: { type: "number" } } }, + }, + }); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + task_spec: { + output_schema: { + type: "json", + json_schema: { properties: { score: { type: "number" } } }, + }, + }, + }), + }), + ); + }); + + it("includes webhook when provided", async () => { + const client = createClient(); + mockRequest.mockResolvedValueOnce( + mockResponse({ run_id: "run_1", status: "queued" }), + ); + + await client.createRun({ + input: "test", + webhook: { url: "https://example.com/hook", events: ["task_run.status"] }, + }); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + webhook: { url: "https://example.com/hook", events: ["task_run.status"] }, + }), + }), + ); + }); + + it("throws ParallelApiError on 400 response", async () => { + const client = createClient(); + mockRequest.mockRejectedValueOnce(makeAxiosError(400, "Bad request")); + + await expect(client.createRun({ input: "test" })).rejects.toThrow( + ParallelApiError, + ); + }); +}); + +// ── getRunStatus ─────────────────────────────────────────────────────────── + +describe("getRunStatus", () => { + it("sends GET to correct endpoint", async () => { + const client = createClient(); + mockRequest.mockResolvedValueOnce( + mockResponse({ run_id: "run_1", status: "running", is_active: true }), + ); + + const result = await client.getRunStatus("run_1"); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: "GET", + url: "/v1/tasks/runs/run_1", + }), + ); + expect(result.status).toBe("running"); + }); +}); + +// ── getRunResult ─────────────────────────────────────────────────────────── + +describe("getRunResult", () => { + it("returns result when run is completed", async () => { + const client = createClient(); + // First call: getRunStatus + mockRequest.mockResolvedValueOnce( + mockResponse({ run_id: "run_1", status: "completed" }), + ); + // Second call: get result + mockRequest.mockResolvedValueOnce( + mockResponse({ + output: { type: "json", content: { risk: "HIGH" } }, + }), + ); + + const result = await client.getRunResult("run_1"); + + expect(result.output.type).toBe("json"); + expect(result.output.content).toEqual({ risk: "HIGH" }); + }); + + it("throws RunNotCompleteError when run is still running", async () => { + const client = createClient(); + mockRequest.mockResolvedValueOnce( + mockResponse({ run_id: "run_1", status: "running" }), + ); + + await expect(client.getRunResult("run_1")).rejects.toThrow( + RunNotCompleteError, + ); + }); + + it("throws RunNotCompleteError when run is queued", async () => { + const client = createClient(); + mockRequest.mockResolvedValueOnce( + mockResponse({ run_id: "run_1", status: "queued" }), + ); + + const err = await client.getRunResult("run_1").catch((e: Error) => e); + expect(err).toBeInstanceOf(RunNotCompleteError); + expect((err as RunNotCompleteError).runId).toBe("run_1"); + expect((err as RunNotCompleteError).currentStatus).toBe("queued"); + }); + + it("returns output with basis when available", async () => { + const client = createClient(); + mockRequest.mockResolvedValueOnce( + mockResponse({ run_id: "run_1", status: "completed" }), + ); + mockRequest.mockResolvedValueOnce( + mockResponse({ + output: { + type: "json", + content: { score: 85 }, + basis: [ + { + field: "score", + reasoning: "Financial analysis", + citations: [{ url: "https://example.com" }], + confidence: "high", + }, + ], + }, + }), + ); + + const result = await client.getRunResult("run_1"); + expect(result.output.basis).toHaveLength(1); + expect(result.output.basis![0].field).toBe("score"); + }); +}); + +// ── createTaskGroup ──────────────────────────────────────────────────────── + +describe("createTaskGroup", () => { + it("sends POST and returns taskgroup_id", async () => { + const client = createClient(); + mockRequest.mockResolvedValueOnce( + mockResponse({ taskgroup_id: "tg_abc" }), + ); + + const result = await client.createTaskGroup(); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: "POST", + url: "/v1beta/tasks/groups", + data: {}, + }), + ); + expect(result.taskgroup_id).toBe("tg_abc"); + }); +}); + +// ── addRunsToGroup ───────────────────────────────────────────────────────── + +describe("addRunsToGroup", () => { + it("sends all runs in single request when under 1000", async () => { + const client = createClient(); + const runs = Array.from({ length: 5 }, (_, i) => ({ + input: `vendor_${i}`, + })); + mockRequest.mockResolvedValueOnce( + mockResponse({ run_ids: ["r1", "r2", "r3", "r4", "r5"] }), + ); + + const ids = await client.addRunsToGroup("tg_1", runs); + + expect(mockRequest).toHaveBeenCalledTimes(1); + expect(ids).toEqual(["r1", "r2", "r3", "r4", "r5"]); + }); + + it("chunks runs into batches of 1000", async () => { + const client = createClient(); + const runs = Array.from({ length: 2500 }, (_, i) => ({ + input: `vendor_${i}`, + })); + + // 3 batches: 1000, 1000, 500 + mockRequest + .mockResolvedValueOnce( + mockResponse({ run_ids: Array.from({ length: 1000 }, (_, i) => `r_${i}`) }), + ) + .mockResolvedValueOnce( + mockResponse({ run_ids: Array.from({ length: 1000 }, (_, i) => `r_${1000 + i}`) }), + ) + .mockResolvedValueOnce( + mockResponse({ run_ids: Array.from({ length: 500 }, (_, i) => `r_${2000 + i}`) }), + ); + + const ids = await client.addRunsToGroup("tg_1", runs); + + expect(mockRequest).toHaveBeenCalledTimes(3); + expect(ids).toHaveLength(2500); + + // Verify chunk sizes + const firstCallData = mockRequest.mock.calls[0][0].data; + const secondCallData = mockRequest.mock.calls[1][0].data; + const thirdCallData = mockRequest.mock.calls[2][0].data; + expect(firstCallData.inputs).toHaveLength(1000); + expect(secondCallData.inputs).toHaveLength(1000); + expect(thirdCallData.inputs).toHaveLength(500); + }); + + it("passes default_task_spec when provided", async () => { + const client = createClient(); + mockRequest.mockResolvedValueOnce(mockResponse({ run_ids: ["r1"] })); + + await client.addRunsToGroup( + "tg_1", + [{ input: "test" }], + { output_schema: { type: "json", json_schema: { properties: {} } } }, + ); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + default_task_spec: { + output_schema: { type: "json", json_schema: { properties: {} } }, + }, + }), + }), + ); + }); +}); + +// ── getTaskGroupStatus ───────────────────────────────────────────────────── + +describe("getTaskGroupStatus", () => { + it("returns parsed status", async () => { + const client = createClient(); + mockRequest.mockResolvedValueOnce( + mockResponse({ + taskgroup_id: "tg_1", + status: { + is_active: true, + num_task_runs: 10, + task_run_status_counts: { completed: 3, running: 7 }, + }, + }), + ); + + const result = await client.getTaskGroupStatus("tg_1"); + + expect(result.status.is_active).toBe(true); + expect(result.status.num_task_runs).toBe(10); + expect(result.status.task_run_status_counts.completed).toBe(3); + }); +}); + +// ── getTaskGroupResults ──────────────────────────────────────────────────── + +describe("getTaskGroupResults", () => { + it("sends GET with include_output=true by default", async () => { + const client = createClient(); + mockRequest.mockResolvedValueOnce( + mockResponse([ + { run_id: "r1", status: "completed", output: { type: "text", content: "done" } }, + ]), + ); + + await client.getTaskGroupResults("tg_1"); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + params: { include_output: true }, + }), + ); + }); + + it("sends GET with include_output=false when specified", async () => { + const client = createClient(); + mockRequest.mockResolvedValueOnce(mockResponse([{ run_id: "r1", status: "completed" }])); + + await client.getTaskGroupResults("tg_1", false); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + params: { include_output: false }, + }), + ); + }); + + it("parses run results correctly", async () => { + const client = createClient(); + mockRequest.mockResolvedValueOnce( + mockResponse([ + { run_id: "r1", status: "completed", output: { type: "json", content: { a: 1 } } }, + { run_id: "r2", status: "failed", error: "timeout" }, + ]), + ); + + const results = await client.getTaskGroupResults("tg_1"); + + expect(results).toHaveLength(2); + expect(results[0].run_id).toBe("r1"); + expect(results[1].status).toBe("failed"); + }); +}); + +// ── pollTaskGroupUntilComplete ───────────────────────────────────────────── + +describe("pollTaskGroupUntilComplete", () => { + it("polls until is_active becomes false then returns results", async () => { + vi.useFakeTimers(); + const client = createClient(); + + // 3 status polls: active, active, done + mockRequest + .mockResolvedValueOnce( + mockResponse({ + taskgroup_id: "tg_1", + status: { is_active: true, num_task_runs: 2, task_run_status_counts: { running: 2 } }, + }), + ) + .mockResolvedValueOnce( + mockResponse({ + taskgroup_id: "tg_1", + status: { is_active: true, num_task_runs: 2, task_run_status_counts: { running: 1, completed: 1 } }, + }), + ) + .mockResolvedValueOnce( + mockResponse({ + taskgroup_id: "tg_1", + status: { is_active: false, num_task_runs: 2, task_run_status_counts: { completed: 2 } }, + }), + ) + // Final getTaskGroupResults call + .mockResolvedValueOnce( + mockResponse([ + { run_id: "r1", status: "completed", output: { type: "text", content: "a" } }, + { run_id: "r2", status: "completed", output: { type: "text", content: "b" } }, + ]), + ); + + const promise = client.pollTaskGroupUntilComplete("tg_1", 1000, 60000); + + // Advance through the two sleep intervals + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(1000); + + const results = await promise; + + expect(results).toHaveLength(2); + // 3 status calls + 1 results call + expect(mockRequest).toHaveBeenCalledTimes(4); + }); + + it("throws TaskGroupTimeoutError when timeout exceeded", async () => { + vi.useFakeTimers(); + const client = createClient(); + + // Always active + mockRequest.mockResolvedValue( + mockResponse({ + taskgroup_id: "tg_1", + status: { is_active: true, num_task_runs: 1, task_run_status_counts: { running: 1 } }, + }), + ); + + const promise = client.pollTaskGroupUntilComplete("tg_1", 500, 1500); + // Attach catch handler immediately to prevent unhandled rejection + const errorPromise = promise.catch((e: Error) => e); + + // Advance past timeout + await vi.advanceTimersByTimeAsync(500); + await vi.advanceTimersByTimeAsync(500); + await vi.advanceTimersByTimeAsync(500); + await vi.advanceTimersByTimeAsync(500); + + const err = await errorPromise; + expect(err).toBeInstanceOf(TaskGroupTimeoutError); + }); +}); + +// ── Retry Logic ──────────────────────────────────────────────────────────── + +describe("retry logic", () => { + it("retries on 429 and succeeds on second attempt", async () => { + vi.useFakeTimers(); + const client = createClient(); + + mockRequest + .mockRejectedValueOnce(makeAxiosError(429)) + .mockResolvedValueOnce(mockResponse({ run_id: "run_1", status: "queued" })); + + const promise = client.createRun({ input: "test" }); + + // Advance past retry delay (1s for first retry) + await vi.advanceTimersByTimeAsync(1000); + + const result = await promise; + expect(result.run_id).toBe("run_1"); + expect(mockRequest).toHaveBeenCalledTimes(2); + }); + + it("retries on 500, 502, 503", async () => { + vi.useFakeTimers(); + const client = createClient(); + + mockRequest + .mockRejectedValueOnce(makeAxiosError(500)) + .mockRejectedValueOnce(makeAxiosError(502)) + .mockRejectedValueOnce(makeAxiosError(503)) + .mockResolvedValueOnce(mockResponse({ run_id: "run_1", status: "queued" })); + + const promise = client.createRun({ input: "test" }); + + await vi.advanceTimersByTimeAsync(1000); // retry 1 + await vi.advanceTimersByTimeAsync(2000); // retry 2 + await vi.advanceTimersByTimeAsync(4000); // retry 3 + + const result = await promise; + expect(result.run_id).toBe("run_1"); + expect(mockRequest).toHaveBeenCalledTimes(4); + }); + + it("does not retry on 400", async () => { + const client = createClient(); + mockRequest.mockRejectedValueOnce(makeAxiosError(400, "Bad request")); + + await expect(client.createRun({ input: "test" })).rejects.toThrow( + ParallelApiError, + ); + expect(mockRequest).toHaveBeenCalledTimes(1); + }); + + it("does not retry on 401", async () => { + const client = createClient(); + mockRequest.mockRejectedValueOnce(makeAxiosError(401, "Unauthorized")); + + await expect(client.createRun({ input: "test" })).rejects.toThrow( + ParallelApiError, + ); + expect(mockRequest).toHaveBeenCalledTimes(1); + }); + + it("does not retry on 404", async () => { + const client = createClient(); + mockRequest.mockRejectedValueOnce(makeAxiosError(404, "Not found")); + + await expect(client.createRun({ input: "test" })).rejects.toThrow( + ParallelApiError, + ); + expect(mockRequest).toHaveBeenCalledTimes(1); + }); + + it("throws ParallelApiError after max retries exhausted", async () => { + vi.useFakeTimers(); + const client = createClient(); + + mockRequest + .mockRejectedValueOnce(makeAxiosError(500)) + .mockRejectedValueOnce(makeAxiosError(500)) + .mockRejectedValueOnce(makeAxiosError(500)) + .mockRejectedValueOnce(makeAxiosError(500)); // 4th = exhausted (0 + 3 retries) + + const promise = client.createRun({ input: "test" }); + // Attach catch handler immediately to prevent unhandled rejection + const errorPromise = promise.catch((e: Error) => e); + + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(2000); + await vi.advanceTimersByTimeAsync(4000); + + const err = await errorPromise; + expect(err).toBeInstanceOf(ParallelApiError); + expect((err as ParallelApiError).status).toBe(500); + expect(mockRequest).toHaveBeenCalledTimes(4); + }); + + it("uses exponential backoff delays", async () => { + vi.useFakeTimers(); + const client = createClient(); + + mockRequest + .mockRejectedValueOnce(makeAxiosError(500)) + .mockRejectedValueOnce(makeAxiosError(500)) + .mockRejectedValueOnce(makeAxiosError(500)) + .mockResolvedValueOnce(mockResponse({ run_id: "run_1", status: "queued" })); + + const promise = client.createRun({ input: "test" }); + + // After 999ms, still on first retry wait + await vi.advanceTimersByTimeAsync(999); + expect(mockRequest).toHaveBeenCalledTimes(1); + + // At 1000ms, first retry fires + await vi.advanceTimersByTimeAsync(1); + expect(mockRequest).toHaveBeenCalledTimes(2); + + // Second retry at 2000ms additional + await vi.advanceTimersByTimeAsync(2000); + expect(mockRequest).toHaveBeenCalledTimes(3); + + // Third retry at 4000ms additional + await vi.advanceTimersByTimeAsync(4000); + expect(mockRequest).toHaveBeenCalledTimes(4); + + await promise; + }); + + it("respects Retry-After header on 429", async () => { + vi.useFakeTimers(); + const client = createClient(); + + mockRequest + .mockRejectedValueOnce(makeAxiosError(429, "", { "retry-after": "5" })) + .mockResolvedValueOnce(mockResponse({ run_id: "run_1", status: "queued" })); + + const promise = client.createRun({ input: "test" }); + + // Normal backoff would be 1s, but Retry-After says 5s + await vi.advanceTimersByTimeAsync(4999); + expect(mockRequest).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(1); + expect(mockRequest).toHaveBeenCalledTimes(2); + + await promise; + }); +}); diff --git a/typescript-recipes/parallel-n8n-procurement/tests/services/research-orchestrator.test.ts b/typescript-recipes/parallel-n8n-procurement/tests/services/research-orchestrator.test.ts new file mode 100644 index 0000000..1065acf --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/tests/services/research-orchestrator.test.ts @@ -0,0 +1,534 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { ResearchOrchestrator } from "@/services/research-orchestrator.js"; +import { BatchPlanner } from "@/services/batch-planner.js"; +import type { ParallelTaskClient } from "@/services/parallel-task-client.js"; +import type { ResearchPromptBuilder } from "@/services/research-prompt-builder.js"; +import type { RiskScorer } from "@/services/risk-scorer.js"; +import type { SlackFormatter } from "@/services/slack-formatter.js"; +import type { SlackDeliveryService } from "@/services/slack-delivery.js"; +import type { AuditLogger } from "@/services/audit-logger.js"; +import type { Vendor } from "@/models/vendor.js"; +import type { DeepResearchOutput, RiskAssessment } from "@/models/risk-assessment.js"; +import type { SlackOpsReporter } from "@/services/slack-ops-reporter.js"; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +const silentLogger = { debug: vi.fn(), warn: vi.fn(), error: vi.fn() }; + +function makeVendor(overrides: Partial = {}): Vendor { + return { + vendor_name: "Acme Corp", + vendor_domain: "https://acme.com", + vendor_category: "technology", + monitoring_priority: "high", + active: true, + ...overrides, + }; +} + +function makeResearchOutput(vendorName = "Acme Corp"): DeepResearchOutput { + const dim = (sev = "LOW") => ({ status: "stable", findings: "Ok", severity: sev }); + return { + vendor_name: vendorName, + assessment_date: "2026-03-05", + overall_risk_level: "LOW", + financial_health: dim(), + legal_regulatory: dim(), + cybersecurity: dim(), + leadership_governance: dim(), + esg_reputation: dim(), + adverse_events: [], + recommendation: "APPROVE", + } as DeepResearchOutput; +} + +function makeAssessment(overrides: Partial = {}): RiskAssessment { + return { + risk_level: "LOW", + adverse_flag: false, + risk_categories: [], + summary: "All clear.", + action_required: false, + recommendation: "continue_monitoring", + severity_counts: { critical: 0, high: 0, medium: 0, low: 5 }, + triggered_overrides: [], + ...overrides, + }; +} + +// ── Mocks ────────────────────────────────────────────────────────────────── + +let mockTaskClient: { + createTaskGroup: ReturnType; + addRunsToGroup: ReturnType; + pollTaskGroupUntilComplete: ReturnType; +}; +let mockPromptBuilder: { buildPrompt: ReturnType; getOutputSchema: ReturnType }; +let mockRiskScorer: { scoreDeepResearch: ReturnType }; +let mockFormatter: { formatCriticalAlert: ReturnType }; +let mockDelivery: { + sendAlert: ReturnType; + queueForDigest: ReturnType; +}; +let mockAuditLogger: { logAssessment: ReturnType }; +let mockOpsReporter: { sendRunSummary: ReturnType }; +let batchPlanner: BatchPlanner; + +beforeEach(() => { + vi.clearAllMocks(); + + mockTaskClient = { + createTaskGroup: vi.fn().mockResolvedValue({ taskgroup_id: "tg_1" }), + addRunsToGroup: vi.fn().mockResolvedValue(["run_1"]), + pollTaskGroupUntilComplete: vi.fn().mockResolvedValue([ + { + run_id: "run_1", + status: "completed", + output: { type: "json", content: makeResearchOutput() }, + }, + ]), + }; + + mockPromptBuilder = { + buildPrompt: vi.fn().mockReturnValue("Research prompt"), + getOutputSchema: vi.fn().mockReturnValue({ type: "json", json_schema: {} }), + }; + + mockRiskScorer = { + scoreDeepResearch: vi.fn().mockReturnValue(makeAssessment()), + }; + + mockFormatter = { + formatCriticalAlert: vi.fn().mockReturnValue({ + channel: "#alerts", + text: "Alert", + blocks: [], + }), + }; + + mockDelivery = { + sendAlert: vi.fn().mockResolvedValue({ ok: true }), + queueForDigest: vi.fn(), + }; + + mockAuditLogger = { + logAssessment: vi.fn().mockResolvedValue(undefined), + }; + + mockOpsReporter = { + sendRunSummary: vi.fn().mockResolvedValue(undefined), + }; + + batchPlanner = new BatchPlanner(); +}); + +function createOrchestrator() { + return new ResearchOrchestrator({ + taskClient: mockTaskClient as unknown as ParallelTaskClient, + batchPlanner, + promptBuilder: mockPromptBuilder as unknown as ResearchPromptBuilder, + riskScorer: mockRiskScorer as unknown as RiskScorer, + formatter: mockFormatter as unknown as SlackFormatter, + deliveryService: mockDelivery as unknown as SlackDeliveryService, + auditLogger: mockAuditLogger as unknown as AuditLogger, + cycleLength: 7, + pollIntervalMs: 100, + pollTimeoutMs: 5000, + logger: silentLogger, + }); +} + +// ── runScheduledResearch ─────────────────────────────────────────────────── + +describe("runScheduledResearch", () => { + it("returns zero summary when no vendors are due", async () => { + const orchestrator = createOrchestrator(); + // All vendors have future dates + const vendors = [ + makeVendor({ next_research_date: "2099-01-01T00:00:00.000Z" }), + ]; + + const summary = await orchestrator.runScheduledResearch(vendors); + + expect(summary.total_due).toBe(0); + expect(summary.total_researched).toBe(0); + expect(summary.batches_executed).toBe(0); + expect(mockTaskClient.createTaskGroup).not.toHaveBeenCalled(); + }); + + it("processes due vendors and returns correct summary", async () => { + const orchestrator = createOrchestrator(); + const vendors = [ + makeVendor({ vendor_domain: "https://a.com", vendor_name: "A" }), + ]; + + mockTaskClient.pollTaskGroupUntilComplete.mockResolvedValueOnce([ + { + run_id: "run_1", + status: "completed", + output: { type: "json", content: makeResearchOutput("A") }, + }, + ]); + + const summary = await orchestrator.runScheduledResearch(vendors); + + expect(summary.total_due).toBe(1); + expect(summary.total_researched).toBe(1); + expect(summary.total_failed).toBe(0); + expect(summary.batches_executed).toBe(1); + expect(summary.risk_counts.LOW).toBe(1); + expect(summary.duration_ms).toBeGreaterThanOrEqual(0); + }); + + it("handles multiple vendors across batches", async () => { + const orchestrator = createOrchestrator(); + const vendors = Array.from({ length: 3 }, (_, i) => + makeVendor({ vendor_domain: `https://v${i}.com`, vendor_name: `V${i}` }), + ); + + mockTaskClient.addRunsToGroup.mockResolvedValue(["r0", "r1", "r2"]); + mockTaskClient.pollTaskGroupUntilComplete.mockResolvedValue( + vendors.map((v, i) => ({ + run_id: `r${i}`, + status: "completed", + output: { type: "json", content: makeResearchOutput(v.vendor_name) }, + })), + ); + + const summary = await orchestrator.runScheduledResearch(vendors); + + expect(summary.total_due).toBe(3); + expect(summary.total_researched).toBe(3); + }); +}); + +// ── executeBatch ─────────────────────────────────────────────────────────── + +describe("executeBatch", () => { + it("creates task group, adds runs, polls, returns results", async () => { + const orchestrator = createOrchestrator(); + const batch = { batch_index: 0, vendors: [makeVendor()] }; + + const result = await orchestrator.executeBatch(batch); + + expect(mockTaskClient.createTaskGroup).toHaveBeenCalled(); + expect(mockTaskClient.addRunsToGroup).toHaveBeenCalledWith( + "tg_1", + [{ input: "Research prompt" }], + expect.objectContaining({ output_schema: expect.anything() }), + ); + expect(mockTaskClient.pollTaskGroupUntilComplete).toHaveBeenCalledWith( + "tg_1", + 100, + 5000, + ); + expect(result.results.size).toBe(1); + expect(result.failures).toHaveLength(0); + }); + + it("captures failed runs in failures array", async () => { + const orchestrator = createOrchestrator(); + const batch = { + batch_index: 0, + vendors: [ + makeVendor({ vendor_domain: "https://good.com" }), + makeVendor({ vendor_domain: "https://bad.com" }), + ], + }; + + mockTaskClient.addRunsToGroup.mockResolvedValueOnce(["run_good", "run_bad"]); + mockTaskClient.pollTaskGroupUntilComplete.mockResolvedValueOnce([ + { + run_id: "run_good", + status: "completed", + output: { type: "json", content: makeResearchOutput() }, + }, + { + run_id: "run_bad", + status: "failed", + error: "Timeout", + }, + ]); + + const result = await orchestrator.executeBatch(batch); + + expect(result.results.size).toBe(1); + expect(result.results.has("https://good.com")).toBe(true); + expect(result.failures).toHaveLength(1); + expect(result.failures[0].vendor_domain).toBe("https://bad.com"); + expect(result.failures[0].error).toBe("Timeout"); + }); + + it("all vendors fail → no results, only failures", async () => { + const orchestrator = createOrchestrator(); + const batch = { + batch_index: 0, + vendors: [makeVendor({ vendor_domain: "https://fail.com" })], + }; + + mockTaskClient.addRunsToGroup.mockResolvedValueOnce(["run_fail"]); + mockTaskClient.pollTaskGroupUntilComplete.mockResolvedValueOnce([ + { run_id: "run_fail", status: "failed", error: "Error" }, + ]); + + const result = await orchestrator.executeBatch(batch); + + expect(result.results.size).toBe(0); + expect(result.failures).toHaveLength(1); + }); +}); + +// ── processResults ───────────────────────────────────────────────────────── + +describe("processResults", () => { + it("CRITICAL assessment → sendAlert called", async () => { + mockRiskScorer.scoreDeepResearch.mockReturnValueOnce( + makeAssessment({ risk_level: "CRITICAL", adverse_flag: true }), + ); + + const orchestrator = createOrchestrator(); + const results = new Map([["https://acme.com", makeResearchOutput()]]); + const vendors = [makeVendor()]; + + await orchestrator.processResults(results, vendors); + + expect(mockDelivery.sendAlert).toHaveBeenCalled(); + expect(mockDelivery.queueForDigest).not.toHaveBeenCalled(); + }); + + it("HIGH assessment → sendAlert called", async () => { + mockRiskScorer.scoreDeepResearch.mockReturnValueOnce( + makeAssessment({ risk_level: "HIGH" }), + ); + + const orchestrator = createOrchestrator(); + await orchestrator.processResults( + new Map([["https://acme.com", makeResearchOutput()]]), + [makeVendor()], + ); + + expect(mockDelivery.sendAlert).toHaveBeenCalled(); + }); + + it("MEDIUM assessment → queueForDigest called", async () => { + mockRiskScorer.scoreDeepResearch.mockReturnValueOnce( + makeAssessment({ risk_level: "MEDIUM" }), + ); + + const orchestrator = createOrchestrator(); + await orchestrator.processResults( + new Map([["https://acme.com", makeResearchOutput()]]), + [makeVendor()], + ); + + expect(mockDelivery.queueForDigest).toHaveBeenCalled(); + expect(mockDelivery.sendAlert).not.toHaveBeenCalled(); + }); + + it("LOW assessment → no Slack call", async () => { + mockRiskScorer.scoreDeepResearch.mockReturnValueOnce( + makeAssessment({ risk_level: "LOW" }), + ); + + const orchestrator = createOrchestrator(); + await orchestrator.processResults( + new Map([["https://acme.com", makeResearchOutput()]]), + [makeVendor()], + ); + + expect(mockDelivery.sendAlert).not.toHaveBeenCalled(); + expect(mockDelivery.queueForDigest).not.toHaveBeenCalled(); + }); + + it("audit logger called for each result", async () => { + const orchestrator = createOrchestrator(); + const results = new Map([ + ["https://a.com", makeResearchOutput("A")], + ["https://b.com", makeResearchOutput("B")], + ]); + const vendors = [ + makeVendor({ vendor_domain: "https://a.com", vendor_name: "A" }), + makeVendor({ vendor_domain: "https://b.com", vendor_name: "B" }), + ]; + + await orchestrator.processResults(results, vendors); + + expect(mockAuditLogger.logAssessment).toHaveBeenCalledTimes(2); + expect(mockAuditLogger.logAssessment).toHaveBeenCalledWith( + expect.objectContaining({ + vendor_name: "A", + source: "deep_research", + }), + ); + }); + + it("returns errors for vendors not in input list", async () => { + const orchestrator = createOrchestrator(); + const results = new Map([ + ["https://unknown.com", makeResearchOutput()], + ]); + + const processed = await orchestrator.processResults(results, []); + + expect(processed.errors).toHaveLength(1); + expect(processed.errors[0].vendor_domain).toBe("https://unknown.com"); + }); +}); + +// ── handlePartialFailure ─────────────────────────────────────────────────── + +describe("handlePartialFailure", () => { + it("logs warning for each failed vendor", async () => { + const orchestrator = createOrchestrator(); + const batch = { batch_index: 0, vendors: [makeVendor()] }; + + await orchestrator.handlePartialFailure(batch, ["https://fail.com"]); + + expect(silentLogger.warn).toHaveBeenCalledWith( + expect.any(String), + "https://fail.com", + expect.anything(), + ); + }); +}); + +// ── Integration: failed vendors don't advance dates ──────────────────────── + +describe("date advancement", () => { + it("failed vendors do not get advanced dates", async () => { + const orchestrator = createOrchestrator(); + const updateSpy = vi.spyOn(batchPlanner, "updateNextResearchDates"); + + const vendors = [ + makeVendor({ vendor_domain: "https://good.com", vendor_name: "Good" }), + makeVendor({ vendor_domain: "https://bad.com", vendor_name: "Bad" }), + ]; + + mockTaskClient.addRunsToGroup.mockResolvedValueOnce(["run_good", "run_bad"]); + mockTaskClient.pollTaskGroupUntilComplete.mockResolvedValueOnce([ + { + run_id: "run_good", + status: "completed", + output: { type: "json", content: makeResearchOutput("Good") }, + }, + { + run_id: "run_bad", + status: "failed", + error: "Timeout", + }, + ]); + + await orchestrator.runScheduledResearch(vendors); + + expect(updateSpy).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ vendor_domain: "https://good.com" }), + ]), + 7, + ); + // The failed vendor should NOT be in the array + const updatedVendors = updateSpy.mock.calls[0][0]; + expect(updatedVendors.find((v: Vendor) => v.vendor_domain === "https://bad.com")).toBeUndefined(); + }); +}); + +// ── ops reporting on failure ──────────────────────────────────────────────── + +function createOrchestratorWithOps() { + return new ResearchOrchestrator({ + taskClient: mockTaskClient as unknown as ParallelTaskClient, + batchPlanner, + promptBuilder: mockPromptBuilder as unknown as ResearchPromptBuilder, + riskScorer: mockRiskScorer as unknown as RiskScorer, + formatter: mockFormatter as unknown as SlackFormatter, + deliveryService: mockDelivery as unknown as SlackDeliveryService, + auditLogger: mockAuditLogger as unknown as AuditLogger, + opsReporter: mockOpsReporter as unknown as SlackOpsReporter, + cycleLength: 7, + pollIntervalMs: 100, + pollTimeoutMs: 5000, + logger: silentLogger, + }); +} + +describe("ops reporting", () => { + it("sends run summary when there are failures", async () => { + const orchestrator = createOrchestratorWithOps(); + const vendors = [ + makeVendor({ vendor_domain: "https://fail.com", vendor_name: "Fail" }), + ]; + + mockTaskClient.addRunsToGroup.mockResolvedValueOnce(["run_fail"]); + mockTaskClient.pollTaskGroupUntilComplete.mockResolvedValueOnce([ + { run_id: "run_fail", status: "failed", error: "Timeout" }, + ]); + + await orchestrator.runScheduledResearch(vendors); + + expect(mockOpsReporter.sendRunSummary).toHaveBeenCalledWith( + expect.objectContaining({ total_failed: 1 }), + ); + }); + + it("sends run summary when there are adverse findings", async () => { + const orchestrator = createOrchestratorWithOps(); + const vendors = [makeVendor()]; + + mockRiskScorer.scoreDeepResearch.mockReturnValueOnce( + makeAssessment({ risk_level: "CRITICAL", adverse_flag: true }), + ); + + await orchestrator.runScheduledResearch(vendors); + + expect(mockOpsReporter.sendRunSummary).toHaveBeenCalledWith( + expect.objectContaining({ adverse_count: 1 }), + ); + }); + + it("does NOT send run summary when everything is clean", async () => { + const orchestrator = createOrchestratorWithOps(); + const vendors = [makeVendor()]; + + await orchestrator.runScheduledResearch(vendors); + + expect(mockOpsReporter.sendRunSummary).not.toHaveBeenCalled(); + }); + + it("does not throw if opsReporter.sendRunSummary fails", async () => { + mockOpsReporter.sendRunSummary.mockRejectedValueOnce(new Error("Slack down")); + const orchestrator = createOrchestratorWithOps(); + const vendors = [ + makeVendor({ vendor_domain: "https://fail.com", vendor_name: "Fail" }), + ]; + + mockTaskClient.addRunsToGroup.mockResolvedValueOnce(["run_fail"]); + mockTaskClient.pollTaskGroupUntilComplete.mockResolvedValueOnce([ + { run_id: "run_fail", status: "failed", error: "Timeout" }, + ]); + + const summary = await orchestrator.runScheduledResearch(vendors); + + expect(summary.total_failed).toBe(1); + expect(silentLogger.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to send ops run summary"), + "Slack down", + ); + }); + + it("works without opsReporter (backward compatible)", async () => { + const orchestrator = createOrchestrator(); // no opsReporter + const vendors = [ + makeVendor({ vendor_domain: "https://fail.com", vendor_name: "Fail" }), + ]; + + mockTaskClient.addRunsToGroup.mockResolvedValueOnce(["run_fail"]); + mockTaskClient.pollTaskGroupUntilComplete.mockResolvedValueOnce([ + { run_id: "run_fail", status: "failed", error: "Timeout" }, + ]); + + const summary = await orchestrator.runScheduledResearch(vendors); + + expect(summary.total_failed).toBe(1); + // Should not throw even though no opsReporter + }); +}); diff --git a/typescript-recipes/parallel-n8n-procurement/tests/services/research-prompt-builder.test.ts b/typescript-recipes/parallel-n8n-procurement/tests/services/research-prompt-builder.test.ts new file mode 100644 index 0000000..3559cbb --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/tests/services/research-prompt-builder.test.ts @@ -0,0 +1,191 @@ +import { describe, it, expect } from "vitest"; +import { ResearchPromptBuilder } from "@/services/research-prompt-builder.js"; +import type { Vendor } from "@/models/vendor.js"; + +function makeVendor(overrides: Partial = {}): Vendor { + return { + vendor_name: "Acme Corp", + vendor_domain: "https://acme.com", + vendor_category: "technology", + monitoring_priority: "high", + active: true, + ...overrides, + }; +} + +describe("ResearchPromptBuilder", () => { + const builder = new ResearchPromptBuilder(); + + describe("buildPrompt", () => { + it("includes vendor name", () => { + const prompt = builder.buildPrompt(makeVendor({ vendor_name: "Tesla Inc" })); + expect(prompt).toContain("Tesla Inc"); + }); + + it("includes vendor domain", () => { + const prompt = builder.buildPrompt(makeVendor({ vendor_domain: "https://tesla.com" })); + expect(prompt).toContain("https://tesla.com"); + }); + + it("includes vendor category", () => { + const prompt = builder.buildPrompt(makeVendor({ vendor_category: "manufacturing" })); + expect(prompt).toContain("manufacturing"); + }); + + it("covers financial health investigation area", () => { + const prompt = builder.buildPrompt(makeVendor()); + expect(prompt).toContain("FINANCIAL HEALTH"); + expect(prompt).toContain("earnings"); + expect(prompt).toContain("credit ratings"); + expect(prompt).toContain("debt"); + expect(prompt).toContain("funding"); + }); + + it("covers legal & regulatory investigation area", () => { + const prompt = builder.buildPrompt(makeVendor()); + expect(prompt).toContain("LEGAL & REGULATORY"); + expect(prompt).toContain("litigation"); + expect(prompt).toContain("regulatory actions"); + expect(prompt).toContain("sanctions"); + expect(prompt).toContain("compliance"); + }); + + it("covers operational risk investigation area", () => { + const prompt = builder.buildPrompt(makeVendor()); + expect(prompt).toContain("OPERATIONAL RISK"); + expect(prompt).toContain("outages"); + expect(prompt).toContain("data breaches"); + expect(prompt).toContain("supply chain"); + }); + + it("covers leadership & governance investigation area", () => { + const prompt = builder.buildPrompt(makeVendor()); + expect(prompt).toContain("LEADERSHIP & GOVERNANCE"); + expect(prompt).toContain("executive departures"); + expect(prompt).toContain("board"); + expect(prompt).toContain("M&A"); + }); + + it("covers ESG & reputation investigation area", () => { + const prompt = builder.buildPrompt(makeVendor()); + expect(prompt).toContain("ESG & REPUTATION"); + expect(prompt).toContain("environmental violations"); + expect(prompt).toContain("labor disputes"); + expect(prompt).toContain("negative press"); + }); + + it("covers cybersecurity posture investigation area", () => { + const prompt = builder.buildPrompt(makeVendor()); + expect(prompt).toContain("CYBERSECURITY POSTURE"); + expect(prompt).toContain("vulnerabilities"); + expect(prompt).toContain("breach history"); + expect(prompt).toContain("certifications"); + }); + + it("instructs severity classification", () => { + const prompt = builder.buildPrompt(makeVendor()); + expect(prompt).toContain("LOW, MEDIUM, HIGH, CRITICAL"); + }); + + it("instructs source URL and date requirements", () => { + const prompt = builder.buildPrompt(makeVendor()); + expect(prompt).toContain("source URLs"); + expect(prompt).toContain("dates"); + }); + }); + + describe("getOutputSchema", () => { + const schema = builder.getOutputSchema(); + + it("returns type json", () => { + expect(schema.type).toBe("json"); + }); + + it("has json_schema property", () => { + expect(schema.json_schema).toBeDefined(); + }); + + const jsonSchema = builder.getOutputSchema().json_schema!; + + it("has vendor_name property", () => { + expect(jsonSchema.properties).toHaveProperty("vendor_name"); + }); + + it("has assessment_date property", () => { + expect(jsonSchema.properties).toHaveProperty("assessment_date"); + }); + + it("has overall_risk_level with enum constraint", () => { + const props = jsonSchema.properties as Record>; + expect(props.overall_risk_level).toHaveProperty("enum"); + expect(props.overall_risk_level.enum).toEqual(["LOW", "MEDIUM", "HIGH", "CRITICAL"]); + }); + + it("has recommendation with enum constraint", () => { + const props = jsonSchema.properties as Record>; + expect(props.recommendation).toHaveProperty("enum"); + expect(props.recommendation.enum).toEqual(["APPROVE", "MONITOR", "ESCALATE", "REJECT"]); + }); + + it("has all 5 risk dimension objects", () => { + const dimensions = [ + "financial_health", + "legal_regulatory", + "cybersecurity", + "leadership_governance", + "esg_reputation", + ]; + for (const dim of dimensions) { + expect(jsonSchema.properties).toHaveProperty(dim); + } + }); + + it("each risk dimension has status, findings, and severity", () => { + const props = jsonSchema.properties as Record>; + const dimensions = [ + "financial_health", + "legal_regulatory", + "cybersecurity", + "leadership_governance", + "esg_reputation", + ]; + for (const dim of dimensions) { + const dimProps = (props[dim] as Record).properties as Record; + expect(dimProps).toHaveProperty("status"); + expect(dimProps).toHaveProperty("findings"); + expect(dimProps).toHaveProperty("severity"); + } + }); + + it("has adverse_events as array", () => { + const props = jsonSchema.properties as Record>; + expect(props.adverse_events.type).toBe("array"); + expect(props.adverse_events).toHaveProperty("items"); + }); + + it("adverse_events items have required fields", () => { + const props = jsonSchema.properties as Record>; + const items = props.adverse_events.items as Record; + const itemProps = items.properties as Record; + expect(itemProps).toHaveProperty("title"); + expect(itemProps).toHaveProperty("date"); + expect(itemProps).toHaveProperty("category"); + expect(itemProps).toHaveProperty("severity"); + expect(itemProps).toHaveProperty("description"); + }); + + it("lists all required top-level fields", () => { + const required = jsonSchema.required as string[]; + expect(required).toContain("vendor_name"); + expect(required).toContain("assessment_date"); + expect(required).toContain("overall_risk_level"); + expect(required).toContain("financial_health"); + expect(required).toContain("legal_regulatory"); + expect(required).toContain("cybersecurity"); + expect(required).toContain("leadership_governance"); + expect(required).toContain("esg_reputation"); + expect(required).toContain("adverse_events"); + expect(required).toContain("recommendation"); + }); + }); +}); diff --git a/typescript-recipes/parallel-n8n-procurement/tests/services/risk-scorer.test.ts b/typescript-recipes/parallel-n8n-procurement/tests/services/risk-scorer.test.ts new file mode 100644 index 0000000..8406b26 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/tests/services/risk-scorer.test.ts @@ -0,0 +1,426 @@ +import { describe, it, expect } from "vitest"; +import { RiskScorer } from "@/services/risk-scorer.js"; +import type { DeepResearchOutput, MonitorEventOutput } from "@/models/risk-assessment.js"; +import type { RiskTier } from "@/models/vendor.js"; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function dim(severity: RiskTier = "LOW", status?: string) { + return { + status: status ?? (severity === "LOW" ? "stable" : "issues"), + findings: `Test findings for ${severity}`, + severity, + }; +} + +function makeOutput(overrides: Partial = {}): DeepResearchOutput { + return { + vendor_name: "TestCo", + assessment_date: "2026-03-05", + overall_risk_level: "LOW", + financial_health: dim("LOW"), + legal_regulatory: dim("LOW"), + cybersecurity: dim("LOW"), + leadership_governance: dim("LOW"), + esg_reputation: dim("LOW"), + adverse_events: [], + recommendation: "APPROVE", + ...overrides, + }; +} + +const scorer = new RiskScorer(); + +// ── Risk Level Assignment Table ──────────────────────────────────────────── + +describe("Risk Level Assignment Table", () => { + it("any critical finding → CRITICAL, adverse=true", () => { + const result = scorer.scoreDeepResearch( + makeOutput({ cybersecurity: dim("CRITICAL") }), + ); + expect(result.risk_level).toBe("CRITICAL"); + expect(result.adverse_flag).toBe(true); + }); + + it("≥2 high findings → HIGH, adverse=true", () => { + const result = scorer.scoreDeepResearch( + makeOutput({ + financial_health: dim("HIGH"), + legal_regulatory: dim("HIGH"), + }), + ); + expect(result.risk_level).toBe("HIGH"); + expect(result.adverse_flag).toBe(true); + expect(result.severity_counts.high).toBe(2); + }); + + it("1 high finding → HIGH, adverse=true", () => { + const result = scorer.scoreDeepResearch( + makeOutput({ leadership_governance: dim("HIGH") }), + ); + expect(result.risk_level).toBe("HIGH"); + expect(result.adverse_flag).toBe(true); + expect(result.severity_counts.high).toBe(1); + }); + + it("≥3 medium findings across 2+ categories → MEDIUM, adverse=true", () => { + const result = scorer.scoreDeepResearch( + makeOutput({ + financial_health: dim("MEDIUM"), + legal_regulatory: dim("MEDIUM"), + esg_reputation: dim("MEDIUM"), + }), + ); + expect(result.risk_level).toBe("MEDIUM"); + expect(result.adverse_flag).toBe(true); + expect(result.severity_counts.medium).toBe(3); + }); + + it("≥3 medium findings in same-type categories → still counts distinct dimension names", () => { + // 3 different dimensions all MEDIUM → 3 distinct categories → adverse=true + const result = scorer.scoreDeepResearch( + makeOutput({ + financial_health: dim("MEDIUM"), + cybersecurity: dim("MEDIUM"), + leadership_governance: dim("MEDIUM"), + }), + ); + expect(result.risk_level).toBe("MEDIUM"); + expect(result.adverse_flag).toBe(true); + }); + + it("2 medium findings → MEDIUM, adverse=false", () => { + const result = scorer.scoreDeepResearch( + makeOutput({ + financial_health: dim("MEDIUM"), + legal_regulatory: dim("MEDIUM"), + }), + ); + expect(result.risk_level).toBe("MEDIUM"); + expect(result.adverse_flag).toBe(false); + }); + + it("1 medium finding → MEDIUM, adverse=false", () => { + const result = scorer.scoreDeepResearch( + makeOutput({ esg_reputation: dim("MEDIUM") }), + ); + expect(result.risk_level).toBe("MEDIUM"); + expect(result.adverse_flag).toBe(false); + }); + + it("all low → LOW, adverse=false", () => { + const result = scorer.scoreDeepResearch(makeOutput()); + expect(result.risk_level).toBe("LOW"); + expect(result.adverse_flag).toBe(false); + expect(result.severity_counts.low).toBe(5); + }); +}); + +// ── Risk Categories ──────────────────────────────────────────────────────── + +describe("risk_categories tracking", () => { + it("includes category names for HIGH+ dimensions", () => { + const result = scorer.scoreDeepResearch( + makeOutput({ + financial_health: dim("HIGH"), + cybersecurity: dim("CRITICAL"), + }), + ); + expect(result.risk_categories).toContain("financial_health"); + expect(result.risk_categories).toContain("cybersecurity"); + expect(result.risk_categories).not.toContain("legal_regulatory"); + }); +}); + +// ── Override Rules ───────────────────────────────────────────────────────── + +describe("Override Rules", () => { + it("risk_tier_override as floor raises computed LOW to HIGH", () => { + const result = scorer.scoreDeepResearch(makeOutput(), { + risk_tier_override: "HIGH", + }); + expect(result.risk_level).toBe("HIGH"); + expect(result.triggered_overrides).toContain("risk_tier_override_HIGH"); + }); + + it("risk_tier_override does not reduce — floor only", () => { + const result = scorer.scoreDeepResearch( + makeOutput({ financial_health: dim("HIGH") }), + { risk_tier_override: "MEDIUM" }, + ); + expect(result.risk_level).toBe("HIGH"); + expect(result.triggered_overrides).not.toContain("risk_tier_override_MEDIUM"); + }); + + it("cybersecurity status CRITICAL forces CRITICAL (active breach)", () => { + const result = scorer.scoreDeepResearch( + makeOutput({ cybersecurity: dim("LOW", "CRITICAL") }), + ); + expect(result.risk_level).toBe("CRITICAL"); + expect(result.adverse_flag).toBe(true); + expect(result.triggered_overrides).toContain("active_data_breach"); + }); + + it("legal_regulatory status CRITICAL forces HIGH minimum", () => { + const result = scorer.scoreDeepResearch( + makeOutput({ legal_regulatory: dim("LOW", "CRITICAL") }), + ); + expect(result.risk_level).toBe("HIGH"); + expect(result.adverse_flag).toBe(true); + expect(result.triggered_overrides).toContain("active_government_litigation"); + }); + + it("breach + govt litigation combined → CRITICAL (breach wins)", () => { + const result = scorer.scoreDeepResearch( + makeOutput({ + cybersecurity: dim("LOW", "CRITICAL"), + legal_regulatory: dim("LOW", "CRITICAL"), + }), + ); + expect(result.risk_level).toBe("CRITICAL"); + expect(result.adverse_flag).toBe(true); + expect(result.triggered_overrides).toContain("active_data_breach"); + expect(result.triggered_overrides).toContain("active_government_litigation"); + }); + + it("override + breach combined → both in triggered_overrides", () => { + const result = scorer.scoreDeepResearch( + makeOutput({ cybersecurity: dim("LOW", "CRITICAL") }), + { risk_tier_override: "HIGH" }, + ); + expect(result.risk_level).toBe("CRITICAL"); + expect(result.triggered_overrides).toContain("active_data_breach"); + // Override doesn't fire because CRITICAL > HIGH + expect(result.triggered_overrides).not.toContain("risk_tier_override_HIGH"); + }); + + it("override raises LOW to MEDIUM when no other overrides", () => { + const result = scorer.scoreDeepResearch(makeOutput(), { + risk_tier_override: "MEDIUM", + }); + expect(result.risk_level).toBe("MEDIUM"); + expect(result.triggered_overrides).toContain("risk_tier_override_MEDIUM"); + }); +}); + +// ── Derived Fields ───────────────────────────────────────────────────────── + +describe("Derived fields", () => { + it("HIGH → action_required=true", () => { + const result = scorer.scoreDeepResearch( + makeOutput({ financial_health: dim("HIGH") }), + ); + expect(result.action_required).toBe(true); + }); + + it("CRITICAL → action_required=true", () => { + const result = scorer.scoreDeepResearch( + makeOutput({ financial_health: dim("CRITICAL") }), + ); + expect(result.action_required).toBe(true); + }); + + it("MEDIUM → action_required=false", () => { + const result = scorer.scoreDeepResearch( + makeOutput({ financial_health: dim("MEDIUM") }), + ); + expect(result.action_required).toBe(false); + }); + + it("LOW → action_required=false", () => { + const result = scorer.scoreDeepResearch(makeOutput()); + expect(result.action_required).toBe(false); + }); + + it("LOW → recommendation=continue_monitoring", () => { + const result = scorer.scoreDeepResearch(makeOutput()); + expect(result.recommendation).toBe("continue_monitoring"); + }); + + it("MEDIUM → recommendation=escalate_review", () => { + const result = scorer.scoreDeepResearch( + makeOutput({ financial_health: dim("MEDIUM") }), + ); + expect(result.recommendation).toBe("escalate_review"); + }); + + it("HIGH → recommendation=initiate_contingency", () => { + const result = scorer.scoreDeepResearch( + makeOutput({ financial_health: dim("HIGH") }), + ); + expect(result.recommendation).toBe("initiate_contingency"); + }); + + it("CRITICAL → recommendation=suspend_relationship", () => { + const result = scorer.scoreDeepResearch( + makeOutput({ financial_health: dim("CRITICAL") }), + ); + expect(result.recommendation).toBe("suspend_relationship"); + }); + + it("summary includes vendor name and risk level", () => { + const result = scorer.scoreDeepResearch(makeOutput()); + expect(result.summary).toContain("TestCo"); + expect(result.summary).toContain("LOW"); + }); + + it("summary mentions adverse when flagged", () => { + const result = scorer.scoreDeepResearch( + makeOutput({ financial_health: dim("HIGH") }), + ); + expect(result.summary).toContain("Adverse"); + }); +}); + +// ── Edge Cases ───────────────────────────────────────────────────────────── + +describe("Edge cases", () => { + it("all dimensions LOW with empty adverse_events", () => { + const result = scorer.scoreDeepResearch(makeOutput()); + expect(result.risk_level).toBe("LOW"); + expect(result.adverse_flag).toBe(false); + expect(result.risk_categories).toEqual([]); + expect(result.severity_counts).toEqual({ + critical: 0, + high: 0, + medium: 0, + low: 5, + }); + expect(result.triggered_overrides).toEqual([]); + }); + + it("severity counts are correct with mixed severities", () => { + const result = scorer.scoreDeepResearch( + makeOutput({ + financial_health: dim("CRITICAL"), + legal_regulatory: dim("HIGH"), + cybersecurity: dim("MEDIUM"), + leadership_governance: dim("LOW"), + esg_reputation: dim("LOW"), + }), + ); + expect(result.severity_counts).toEqual({ + critical: 1, + high: 1, + medium: 1, + low: 2, + }); + }); +}); + +// ── Monitor Event Scoring ────────────────────────────────────────────────── + +describe("scoreMonitorEvent", () => { + const vendorContext = { + vendor_name: "Acme Corp", + vendor_domain: "https://acme.com", + monitoring_priority: "high", + }; + + it("HIGH severity event → HIGH risk_level", () => { + const event: MonitorEventOutput = { + event_summary: "Executive departures announced", + severity: "HIGH", + adverse: true, + event_type: "leadership", + }; + const result = scorer.scoreMonitorEvent(event, vendorContext); + expect(result.risk_level).toBe("HIGH"); + }); + + it("adverse=true maps correctly", () => { + const event: MonitorEventOutput = { + event_summary: "Data breach disclosed", + severity: "CRITICAL", + adverse: true, + event_type: "cybersecurity", + }; + const result = scorer.scoreMonitorEvent(event, vendorContext); + expect(result.adverse_flag).toBe(true); + }); + + it("adverse=false maps correctly", () => { + const event: MonitorEventOutput = { + event_summary: "Routine certification renewal", + severity: "LOW", + adverse: false, + event_type: "compliance", + }; + const result = scorer.scoreMonitorEvent(event, vendorContext); + expect(result.adverse_flag).toBe(false); + }); + + it("risk_categories contains event_type", () => { + const event: MonitorEventOutput = { + event_summary: "Regulatory fine", + severity: "MEDIUM", + adverse: false, + event_type: "legal_regulatory", + }; + const result = scorer.scoreMonitorEvent(event, vendorContext); + expect(result.risk_categories).toEqual(["legal_regulatory"]); + }); + + it("severity_counts has 1 in the correct bucket", () => { + const event: MonitorEventOutput = { + event_summary: "test", + severity: "HIGH", + adverse: true, + event_type: "financial", + }; + const result = scorer.scoreMonitorEvent(event, vendorContext); + expect(result.severity_counts).toEqual({ + critical: 0, + high: 1, + medium: 0, + low: 0, + }); + }); + + it("action_required=true for CRITICAL event", () => { + const event: MonitorEventOutput = { + event_summary: "Critical breach", + severity: "CRITICAL", + adverse: true, + event_type: "cyber", + }; + const result = scorer.scoreMonitorEvent(event, vendorContext); + expect(result.action_required).toBe(true); + expect(result.recommendation).toBe("suspend_relationship"); + }); + + it("action_required=false for LOW event", () => { + const event: MonitorEventOutput = { + event_summary: "Minor update", + severity: "LOW", + adverse: false, + event_type: "news", + }; + const result = scorer.scoreMonitorEvent(event, vendorContext); + expect(result.action_required).toBe(false); + expect(result.recommendation).toBe("continue_monitoring"); + }); + + it("summary includes vendor name and event summary", () => { + const event: MonitorEventOutput = { + event_summary: "Lawsuit filed", + severity: "HIGH", + adverse: true, + event_type: "legal", + }; + const result = scorer.scoreMonitorEvent(event, vendorContext); + expect(result.summary).toContain("Acme Corp"); + expect(result.summary).toContain("Lawsuit filed"); + }); + + it("triggered_overrides is always empty for monitor events", () => { + const event: MonitorEventOutput = { + event_summary: "test", + severity: "CRITICAL", + adverse: true, + event_type: "cyber", + }; + const result = scorer.scoreMonitorEvent(event, vendorContext); + expect(result.triggered_overrides).toEqual([]); + }); +}); diff --git a/typescript-recipes/parallel-n8n-procurement/tests/services/slack-command-handler.test.ts b/typescript-recipes/parallel-n8n-procurement/tests/services/slack-command-handler.test.ts new file mode 100644 index 0000000..d4352ed --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/tests/services/slack-command-handler.test.ts @@ -0,0 +1,271 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { SlackCommandHandler } from "@/services/slack-command-handler.js"; +import type { SlackDeliveryService } from "@/services/slack-delivery.js"; +import type { ParallelTaskClient } from "@/services/parallel-task-client.js"; +import type { RiskScorer } from "@/services/risk-scorer.js"; +import type { ResearchPromptBuilder } from "@/services/research-prompt-builder.js"; +import type { SlackFormatter } from "@/services/slack-formatter.js"; +import type { Vendor } from "@/models/vendor.js"; +import type { SlackSlashCommandPayload } from "@/models/slack-command.js"; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function makeVendor(overrides: Partial = {}): Vendor { + return { + vendor_name: "Acme Corp", + vendor_domain: "https://acme.com", + vendor_category: "technology", + monitoring_priority: "high", + active: true, + ...overrides, + }; +} + +function makePayload(overrides: Partial = {}): SlackSlashCommandPayload { + return { + command: "/vendor-research", + text: "Acme Corp", + user_id: "U123", + user_name: "jane.doe", + channel_id: "C456", + channel_name: "procurement", + response_url: "https://hooks.slack.com/response/123", + trigger_id: "T789", + ...overrides, + }; +} + +const silentLogger = { debug: vi.fn(), warn: vi.fn(), error: vi.fn() }; + +let mockDelivery: { + sendAlert: ReturnType; + sendAcknowledgment: ReturnType; + sendThreadReply: ReturnType; +}; +let mockTaskClient: { createRun: ReturnType; getRunResult: ReturnType }; +let mockRiskScorer: { scoreDeepResearch: ReturnType }; +let mockPromptBuilder: { buildPrompt: ReturnType; getOutputSchema: ReturnType }; +let mockFormatter: { formatAdHocResult: ReturnType }; +let mockVendorLookup: ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + + mockDelivery = { + sendAlert: vi.fn().mockResolvedValue({ ok: true, ts: "msg_ts" }), + sendAcknowledgment: vi.fn().mockResolvedValue("ack_ts_123"), + sendThreadReply: vi.fn().mockResolvedValue({ ok: true, ts: "reply_ts" }), + }; + + mockTaskClient = { + createRun: vi.fn().mockResolvedValue({ run_id: "run_abc", status: "queued" }), + getRunResult: vi.fn().mockResolvedValue({ + output: { + type: "json", + content: { + vendor_name: "Acme Corp", + assessment_date: "2026-03-05", + overall_risk_level: "HIGH", + financial_health: { status: "stable", findings: "Ok", severity: "LOW" }, + legal_regulatory: { status: "issues", findings: "Lawsuit", severity: "HIGH" }, + cybersecurity: { status: "stable", findings: "Ok", severity: "LOW" }, + leadership_governance: { status: "stable", findings: "Ok", severity: "LOW" }, + esg_reputation: { status: "stable", findings: "Ok", severity: "LOW" }, + adverse_events: [], + recommendation: "ESCALATE", + }, + }, + }), + }; + + mockRiskScorer = { + scoreDeepResearch: vi.fn().mockReturnValue({ + risk_level: "HIGH", + adverse_flag: true, + risk_categories: ["legal_regulatory"], + summary: "High risk from litigation.", + action_required: true, + recommendation: "initiate_contingency", + severity_counts: { critical: 0, high: 1, medium: 0, low: 4 }, + triggered_overrides: [], + }), + }; + + mockPromptBuilder = { + buildPrompt: vi.fn().mockReturnValue("Research prompt for Acme Corp"), + getOutputSchema: vi.fn().mockReturnValue({ type: "json", json_schema: {} }), + }; + + mockFormatter = { + formatAdHocResult: vi.fn().mockReturnValue({ + channel: "#alerts", + text: "Ad-hoc result", + blocks: [], + thread_ts: "pending", + }), + }; + + mockVendorLookup = vi.fn().mockReturnValue(makeVendor()); +}); + +function createHandler() { + return new SlackCommandHandler({ + deliveryService: mockDelivery as unknown as SlackDeliveryService, + taskClient: mockTaskClient as unknown as ParallelTaskClient, + riskScorer: mockRiskScorer as unknown as RiskScorer, + promptBuilder: mockPromptBuilder as unknown as ResearchPromptBuilder, + formatter: mockFormatter as unknown as SlackFormatter, + vendorLookup: mockVendorLookup, + logger: silentLogger, + }); +} + +// ── parseSlashCommand ────────────────────────────────────────────────────── + +describe("parseSlashCommand", () => { + it("extracts vendor_name from text", () => { + const handler = createHandler(); + const result = handler.parseSlashCommand(makePayload({ text: "Acme Corp" })); + expect(result.vendor_name).toBe("Acme Corp"); + }); + + it("trims whitespace from text", () => { + const handler = createHandler(); + const result = handler.parseSlashCommand(makePayload({ text: " Acme Corp " })); + expect(result.vendor_name).toBe("Acme Corp"); + }); + + it("extracts requesting_user", () => { + const handler = createHandler(); + const result = handler.parseSlashCommand(makePayload({ user_name: "john.smith" })); + expect(result.requesting_user).toBe("john.smith"); + }); + + it("extracts channel_id and response_url", () => { + const handler = createHandler(); + const result = handler.parseSlashCommand(makePayload()); + expect(result.channel_id).toBe("C456"); + expect(result.response_url).toBe("https://hooks.slack.com/response/123"); + }); + + it("throws on empty text", () => { + const handler = createHandler(); + expect(() => handler.parseSlashCommand(makePayload({ text: "" }))).toThrow( + "Vendor name is required", + ); + }); + + it("throws on whitespace-only text", () => { + const handler = createHandler(); + expect(() => handler.parseSlashCommand(makePayload({ text: " " }))).toThrow( + "Vendor name is required", + ); + }); +}); + +// ── handleResearchCommand ────────────────────────────────────────────────── + +describe("handleResearchCommand", () => { + it("sends acknowledgment and creates task run for found vendor", async () => { + const handler = createHandler(); + const command = handler.parseSlashCommand(makePayload()); + + await handler.handleResearchCommand(command); + + expect(mockDelivery.sendAcknowledgment).toHaveBeenCalledWith("C456", "Acme Corp"); + expect(mockTaskClient.createRun).toHaveBeenCalledWith( + expect.objectContaining({ + input: "Research prompt for Acme Corp", + outputSchema: { type: "json", json_schema: {} }, + }), + ); + }); + + it("stores pending request for webhook callback", async () => { + const handler = createHandler(); + const command = handler.parseSlashCommand(makePayload()); + + await handler.handleResearchCommand(command); + + expect(handler.getPendingCount()).toBe(1); + }); + + it("sends error for unknown vendor", async () => { + mockVendorLookup.mockReturnValueOnce(undefined); + const handler = createHandler(); + const command = handler.parseSlashCommand(makePayload({ text: "Unknown Co" })); + + await handler.handleResearchCommand(command); + + expect(mockDelivery.sendAlert).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "C456", + text: expect.stringContaining("Unknown Co"), + }), + ); + expect(mockDelivery.sendAcknowledgment).not.toHaveBeenCalled(); + expect(mockTaskClient.createRun).not.toHaveBeenCalled(); + }); + + it("does not store pending for unknown vendor", async () => { + mockVendorLookup.mockReturnValueOnce(undefined); + const handler = createHandler(); + const command = handler.parseSlashCommand(makePayload({ text: "Unknown" })); + + await handler.handleResearchCommand(command); + + expect(handler.getPendingCount()).toBe(0); + }); +}); + +// ── handleWebhookCallback ────────────────────────────────────────────────── + +describe("handleWebhookCallback", () => { + it("fetches result, scores, formats, and sends thread reply", async () => { + const handler = createHandler(); + // Set up pending request + await handler.handleResearchCommand(handler.parseSlashCommand(makePayload())); + + await handler.handleWebhookCallback({ + run_id: "run_abc", + status: "completed", + }); + + expect(mockTaskClient.getRunResult).toHaveBeenCalledWith("run_abc"); + expect(mockRiskScorer.scoreDeepResearch).toHaveBeenCalled(); + expect(mockFormatter.formatAdHocResult).toHaveBeenCalled(); + expect(mockDelivery.sendThreadReply).toHaveBeenCalledWith( + "C456", + "ack_ts_123", + expect.objectContaining({ text: "Ad-hoc result" }), + ); + }); + + it("removes pending request after callback", async () => { + const handler = createHandler(); + await handler.handleResearchCommand(handler.parseSlashCommand(makePayload())); + expect(handler.getPendingCount()).toBe(1); + + await handler.handleWebhookCallback({ + run_id: "run_abc", + status: "completed", + }); + + expect(handler.getPendingCount()).toBe(0); + }); + + it("does nothing for unknown run_id", async () => { + const handler = createHandler(); + + await handler.handleWebhookCallback({ + run_id: "run_unknown", + status: "completed", + }); + + expect(mockTaskClient.getRunResult).not.toHaveBeenCalled(); + expect(silentLogger.warn).toHaveBeenCalledWith( + expect.stringContaining("unknown run_id"), + "run_unknown", + ); + }); +}); diff --git a/typescript-recipes/parallel-n8n-procurement/tests/services/slack-delivery.test.ts b/typescript-recipes/parallel-n8n-procurement/tests/services/slack-delivery.test.ts new file mode 100644 index 0000000..0c697d4 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/tests/services/slack-delivery.test.ts @@ -0,0 +1,261 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import axios from "axios"; +import { SlackDeliveryService } from "@/services/slack-delivery.js"; +import type { SlackFormatter } from "@/services/slack-formatter.js"; +import type { SlackMessage } from "@/models/slack.js"; +import type { RiskAssessment } from "@/models/risk-assessment.js"; +import type { Vendor } from "@/models/vendor.js"; + +// ── Mocks ────────────────────────────────────────────────────────────────── + +vi.mock("axios", () => ({ + default: { + post: vi.fn(), + }, +})); + +const mockPost = vi.mocked(axios.post); + +const silentLogger = { debug: vi.fn(), warn: vi.fn(), error: vi.fn() }; + +function makeMessage(overrides: Partial = {}): SlackMessage { + return { + channel: "#test", + text: "Test message", + blocks: [{ type: "section", text: { type: "mrkdwn", text: "Hello" } }], + ...overrides, + }; +} + +function makeAssessment(overrides: Partial = {}): RiskAssessment { + return { + risk_level: "MEDIUM", + adverse_flag: false, + risk_categories: ["financial_health"], + summary: "Moderate risk.", + action_required: false, + recommendation: "escalate_review", + severity_counts: { critical: 0, high: 0, medium: 1, low: 4 }, + triggered_overrides: [], + ...overrides, + }; +} + +function makeVendor(overrides: Partial = {}): Vendor { + return { + vendor_name: "Acme Corp", + vendor_domain: "https://acme.com", + vendor_category: "technology", + monitoring_priority: "high", + active: true, + ...overrides, + }; +} + +const mockFormatter = { + formatDailyDigest: vi.fn().mockReturnValue(makeMessage({ channel: "#digest" })), + routeByRiskLevel: vi.fn().mockReturnValue("#test"), +} as unknown as SlackFormatter; + +function createService() { + return new SlackDeliveryService({ + webhookUrl: "https://hooks.slack.com/test", + formatter: mockFormatter, + logger: silentLogger, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockPost.mockResolvedValue({ data: { ok: true, ts: "1234567890.123456" } }); +}); + +// ── sendAlert ────────────────────────────────────────────────────────────── + +describe("sendAlert", () => { + it("sends POST with correct body", async () => { + const service = createService(); + const msg = makeMessage({ channel: "#alerts", text: "Alert!" }); + + await service.sendAlert(msg); + + expect(mockPost).toHaveBeenCalledWith( + "https://hooks.slack.com/test", + expect.objectContaining({ + channel: "#alerts", + text: "Alert!", + blocks: msg.blocks, + }), + ); + }); + + it("returns ts from response", async () => { + const service = createService(); + mockPost.mockResolvedValueOnce({ data: { ok: true, ts: "ts_123" } }); + + const result = await service.sendAlert(makeMessage()); + expect(result.ts).toBe("ts_123"); + }); + + it("handles string 'ok' response from webhook", async () => { + const service = createService(); + mockPost.mockResolvedValueOnce({ data: "ok" }); + + const result = await service.sendAlert(makeMessage()); + expect(result.ok).toBe(true); + }); + + it("returns error from Slack", async () => { + const service = createService(); + mockPost.mockResolvedValueOnce({ + data: { ok: false, error: "channel_not_found" }, + }); + + const result = await service.sendAlert(makeMessage()); + expect(result.ok).toBe(false); + expect(result.error).toBe("channel_not_found"); + }); +}); + +// ── sendThreadReply ──────────────────────────────────────────────────────── + +describe("sendThreadReply", () => { + it("sets thread_ts in payload", async () => { + const service = createService(); + + await service.sendThreadReply("#channel", "ts_parent", makeMessage()); + + expect(mockPost).toHaveBeenCalledWith( + "https://hooks.slack.com/test", + expect.objectContaining({ + thread_ts: "ts_parent", + channel: "#channel", + }), + ); + }); +}); + +// ── sendAcknowledgment ───────────────────────────────────────────────────── + +describe("sendAcknowledgment", () => { + it("sends message with vendor name", async () => { + const service = createService(); + mockPost.mockResolvedValueOnce({ data: { ok: true, ts: "ack_ts" } }); + + const ts = await service.sendAcknowledgment("#channel", "TestCo"); + + expect(mockPost).toHaveBeenCalledWith( + "https://hooks.slack.com/test", + expect.objectContaining({ + channel: "#channel", + }), + ); + const body = mockPost.mock.calls[0][1] as Record; + expect(body.text).toContain("TestCo"); + }); + + it("returns ts for threading", async () => { + const service = createService(); + mockPost.mockResolvedValueOnce({ data: { ok: true, ts: "ack_ts_123" } }); + + const ts = await service.sendAcknowledgment("#channel", "TestCo"); + expect(ts).toBe("ack_ts_123"); + }); + + it("returns empty string when no ts in response", async () => { + const service = createService(); + mockPost.mockResolvedValueOnce({ data: "ok" }); + + const ts = await service.sendAcknowledgment("#channel", "TestCo"); + expect(ts).toBe(""); + }); +}); + +// ── Digest Queue ─────────────────────────────────────────────────────────── + +describe("digest queue", () => { + it("starts empty", () => { + const service = createService(); + expect(service.getDigestQueueSize()).toBe(0); + }); + + it("queueForDigest adds to queue", () => { + const service = createService(); + service.queueForDigest(makeAssessment(), makeVendor()); + expect(service.getDigestQueueSize()).toBe(1); + }); + + it("accumulates multiple items", () => { + const service = createService(); + service.queueForDigest(makeAssessment(), makeVendor({ vendor_name: "A" })); + service.queueForDigest(makeAssessment(), makeVendor({ vendor_name: "B" })); + service.queueForDigest(makeAssessment(), makeVendor({ vendor_name: "C" })); + expect(service.getDigestQueueSize()).toBe(3); + }); + + it("flushDigest with items formats and sends digest", async () => { + const service = createService(); + service.queueForDigest(makeAssessment(), makeVendor()); + service.queueForDigest(makeAssessment(), makeVendor()); + + const result = await service.flushDigest(); + + expect(mockFormatter.formatDailyDigest).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ risk_level: "MEDIUM" })]), + expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/), + ); + expect(mockPost).toHaveBeenCalledTimes(1); + expect(result).not.toBeNull(); + expect(result!.ok).toBe(true); + }); + + it("flushDigest clears the queue", async () => { + const service = createService(); + service.queueForDigest(makeAssessment(), makeVendor()); + + await service.flushDigest(); + expect(service.getDigestQueueSize()).toBe(0); + }); + + it("flushDigest with empty queue returns null", async () => { + const service = createService(); + const result = await service.flushDigest(); + + expect(result).toBeNull(); + expect(mockPost).not.toHaveBeenCalled(); + }); +}); + +// ── Rate Limiting ────────────────────────────────────────────────────────── + +describe("rate limiting", () => { + it("serializes multiple rapid sends", async () => { + vi.useFakeTimers(); + const service = createService(); + const callOrder: number[] = []; + + mockPost.mockImplementation(async () => { + callOrder.push(callOrder.length + 1); + return { data: { ok: true, ts: "ts" } }; + }); + + const p1 = service.sendAlert(makeMessage({ text: "first" })); + const p2 = service.sendAlert(makeMessage({ text: "second" })); + const p3 = service.sendAlert(makeMessage({ text: "third" })); + + // First send happens immediately + await vi.advanceTimersByTimeAsync(0); + expect(callOrder).toEqual([1]); + + // After 1s delay, second fires + await vi.advanceTimersByTimeAsync(1000); + expect(callOrder).toEqual([1, 2]); + + // After another 1s, third fires + await vi.advanceTimersByTimeAsync(1000); + expect(callOrder).toEqual([1, 2, 3]); + + await Promise.all([p1, p2, p3]); + vi.useRealTimers(); + }); +}); diff --git a/typescript-recipes/parallel-n8n-procurement/tests/services/slack-formatter.test.ts b/typescript-recipes/parallel-n8n-procurement/tests/services/slack-formatter.test.ts new file mode 100644 index 0000000..6f251d6 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/tests/services/slack-formatter.test.ts @@ -0,0 +1,504 @@ +import { describe, it, expect } from "vitest"; +import { SlackFormatter } from "@/services/slack-formatter.js"; +import type { RiskAssessment, AdverseEvent, MonitorEventOutput } from "@/models/risk-assessment.js"; +import type { Vendor } from "@/models/vendor.js"; +import type { SlackBlock } from "@/models/slack.js"; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function makeVendor(overrides: Partial = {}): Vendor { + return { + vendor_name: "Acme Corp", + vendor_domain: "https://acme.com", + vendor_category: "technology", + monitoring_priority: "high", + active: true, + ...overrides, + }; +} + +function makeAssessment(overrides: Partial = {}): RiskAssessment { + return { + risk_level: "HIGH", + adverse_flag: true, + risk_categories: ["cybersecurity", "legal_regulatory"], + summary: "Elevated risk due to data breach and pending litigation.", + action_required: true, + recommendation: "initiate_contingency", + severity_counts: { critical: 0, high: 2, medium: 1, low: 2 }, + triggered_overrides: [], + ...overrides, + }; +} + +function makeFinding(overrides: Partial = {}): AdverseEvent { + return { + title: "Data Breach Disclosed", + date: "2026-03-01", + category: "cybersecurity", + severity: "HIGH", + source_url: "https://news.example.com/breach", + description: "Customer data exposed in security incident", + ...overrides, + }; +} + +function blocksText(blocks: SlackBlock[]): string { + return JSON.stringify(blocks); +} + +const formatter = new SlackFormatter({ + channels: { + critical: "#test-critical", + alert: "#test-alert", + digest: "#test-digest", + }, +}); + +// ── routeByRiskLevel ─────────────────────────────────────────────────────── + +describe("routeByRiskLevel", () => { + it("CRITICAL → critical channel", () => { + expect(formatter.routeByRiskLevel("CRITICAL")).toBe("#test-critical"); + }); + + it("HIGH → alert channel", () => { + expect(formatter.routeByRiskLevel("HIGH")).toBe("#test-alert"); + }); + + it("MEDIUM → digest channel", () => { + expect(formatter.routeByRiskLevel("MEDIUM")).toBe("#test-digest"); + }); + + it("LOW → digest channel", () => { + expect(formatter.routeByRiskLevel("LOW")).toBe("#test-digest"); + }); +}); + +// ── Default channels ─────────────────────────────────────────────────────── + +describe("default channels", () => { + it("uses default channel names when none provided", () => { + const defaultFormatter = new SlackFormatter(); + expect(defaultFormatter.routeByRiskLevel("CRITICAL")).toBe("#procurement-critical"); + expect(defaultFormatter.routeByRiskLevel("HIGH")).toBe("#procurement-alerts"); + expect(defaultFormatter.routeByRiskLevel("MEDIUM")).toBe("#procurement-digest"); + }); +}); + +// ── formatCriticalAlert ──────────────────────────────────────────────────── + +describe("formatCriticalAlert", () => { + it("CRITICAL alert has red circle emoji in header", () => { + const msg = formatter.formatCriticalAlert( + makeAssessment({ risk_level: "CRITICAL" }), + makeVendor(), + [makeFinding()], + ); + const headerText = (msg.blocks[0] as Record).text.text; + expect(headerText).toContain("\ud83d\udd34"); + expect(headerText).toContain("CRITICAL VENDOR RISK ALERT"); + }); + + it("HIGH alert has orange circle emoji in header", () => { + const msg = formatter.formatCriticalAlert( + makeAssessment({ risk_level: "HIGH" }), + makeVendor(), + [makeFinding()], + ); + const headerText = (msg.blocks[0] as Record).text.text; + expect(headerText).toContain("\ud83d\udfe0"); + expect(headerText).toContain("HIGH VENDOR RISK ALERT"); + }); + + it("CRITICAL routes to critical channel", () => { + const msg = formatter.formatCriticalAlert( + makeAssessment({ risk_level: "CRITICAL" }), + makeVendor(), + [], + ); + expect(msg.channel).toBe("#test-critical"); + }); + + it("HIGH routes to alert channel", () => { + const msg = formatter.formatCriticalAlert( + makeAssessment({ risk_level: "HIGH" }), + makeVendor(), + [], + ); + expect(msg.channel).toBe("#test-alert"); + }); + + it("includes vendor name in blocks", () => { + const msg = formatter.formatCriticalAlert( + makeAssessment(), + makeVendor({ vendor_name: "TestCo" }), + [], + ); + expect(blocksText(msg.blocks)).toContain("TestCo"); + }); + + it("includes findings with source URLs", () => { + const msg = formatter.formatCriticalAlert( + makeAssessment(), + makeVendor(), + [makeFinding({ source_url: "https://example.com/article" })], + ); + expect(blocksText(msg.blocks)).toContain("https://example.com/article"); + }); + + it("handles findings with missing source_url", () => { + const msg = formatter.formatCriticalAlert( + makeAssessment(), + makeVendor(), + [makeFinding({ source_url: undefined })], + ); + expect(blocksText(msg.blocks)).toContain("Data Breach Disclosed"); + expect(blocksText(msg.blocks)).not.toContain("source>"); + }); + + it("handles empty findings array", () => { + const msg = formatter.formatCriticalAlert( + makeAssessment(), + makeVendor(), + [], + ); + expect(blocksText(msg.blocks)).not.toContain("Key Findings"); + }); + + it("has non-empty text fallback", () => { + const msg = formatter.formatCriticalAlert( + makeAssessment(), + makeVendor(), + [], + ); + expect(msg.text.length).toBeGreaterThan(0); + expect(msg.text).toContain("Acme Corp"); + }); + + it("CRITICAL has '24 hours' action required", () => { + const msg = formatter.formatCriticalAlert( + makeAssessment({ risk_level: "CRITICAL" }), + makeVendor(), + [], + ); + expect(blocksText(msg.blocks)).toContain("24 hours"); + }); + + it("HIGH has '48 hours' action required", () => { + const msg = formatter.formatCriticalAlert( + makeAssessment({ risk_level: "HIGH" }), + makeVendor(), + [], + ); + expect(blocksText(msg.blocks)).toContain("48 hours"); + }); + + it("includes risk categories", () => { + const msg = formatter.formatCriticalAlert( + makeAssessment({ risk_categories: ["cybersecurity", "financial_health"] }), + makeVendor(), + [], + ); + expect(blocksText(msg.blocks)).toContain("cybersecurity"); + expect(blocksText(msg.blocks)).toContain("financial_health"); + }); + + it("has classification ADVERSE when adverse_flag is true", () => { + const msg = formatter.formatCriticalAlert( + makeAssessment({ adverse_flag: true }), + makeVendor(), + [], + ); + expect(blocksText(msg.blocks)).toContain("ADVERSE"); + }); + + it("has classification MONITORING when adverse_flag is false", () => { + const msg = formatter.formatCriticalAlert( + makeAssessment({ adverse_flag: false }), + makeVendor(), + [], + ); + expect(blocksText(msg.blocks)).toContain("MONITORING"); + }); + + it("truncates very long summaries", () => { + const longSummary = "A".repeat(3000); + const msg = formatter.formatCriticalAlert( + makeAssessment({ summary: longSummary }), + makeVendor(), + [], + ); + // Find the section block with the summary + const summaryBlock = msg.blocks.find( + (b) => + b.type === "section" && + typeof (b as any).text?.text === "string" && + (b as any).text.text.startsWith("AAAA"), + ); + expect(summaryBlock).toBeDefined(); + const text = (summaryBlock as any).text.text as string; + expect(text.length).toBeLessThanOrEqual(2003); // 2000 + "..." + expect(text.endsWith("...")).toBe(true); + }); + + it("includes divider blocks", () => { + const msg = formatter.formatCriticalAlert( + makeAssessment(), + makeVendor(), + [], + ); + const dividers = msg.blocks.filter((b) => b.type === "divider"); + expect(dividers.length).toBeGreaterThanOrEqual(2); + }); + + it("includes context blocks", () => { + const msg = formatter.formatCriticalAlert( + makeAssessment(), + makeVendor(), + [], + ); + const contexts = msg.blocks.filter((b) => b.type === "context"); + expect(contexts.length).toBeGreaterThanOrEqual(2); + }); +}); + +// ── formatDailyDigest ────────────────────────────────────────────────────── + +describe("formatDailyDigest", () => { + it("header includes date", () => { + const msg = formatter.formatDailyDigest( + [makeAssessment({ risk_level: "MEDIUM" })], + "2026-03-05", + ); + const headerText = (msg.blocks[0] as any).text.text; + expect(headerText).toContain("2026-03-05"); + }); + + it("shows total vendor count and adverse count", () => { + const assessments = [ + makeAssessment({ risk_level: "MEDIUM", adverse_flag: true }), + makeAssessment({ risk_level: "LOW", adverse_flag: false }), + makeAssessment({ risk_level: "HIGH", adverse_flag: true }), + ]; + const msg = formatter.formatDailyDigest(assessments, "2026-03-05"); + expect(blocksText(msg.blocks)).toContain("3"); + expect(blocksText(msg.blocks)).toContain("2"); // adverse count + }); + + it("handles empty assessments array", () => { + const msg = formatter.formatDailyDigest([], "2026-03-05"); + expect(msg.blocks.length).toBeGreaterThan(0); + expect(blocksText(msg.blocks)).toContain("0"); + }); + + it("channel is digest", () => { + const msg = formatter.formatDailyDigest([makeAssessment()], "2026-03-05"); + expect(msg.channel).toBe("#test-digest"); + }); + + it("shows low-risk vendor count", () => { + const assessments = [ + makeAssessment({ risk_level: "LOW", adverse_flag: false }), + makeAssessment({ risk_level: "LOW", adverse_flag: false }), + ]; + const msg = formatter.formatDailyDigest(assessments, "2026-03-05"); + expect(blocksText(msg.blocks)).toContain("2 vendors assessed with no significant findings"); + }); + + it("text fallback is non-empty", () => { + const msg = formatter.formatDailyDigest([makeAssessment()], "2026-03-05"); + expect(msg.text.length).toBeGreaterThan(0); + }); +}); + +// ── formatAdHocResult ────────────────────────────────────────────────────── + +describe("formatAdHocResult", () => { + it("includes requestedBy name", () => { + const msg = formatter.formatAdHocResult( + makeAssessment(), + makeVendor(), + "jane.doe", + ); + expect(blocksText(msg.blocks)).toContain("jane.doe"); + }); + + it("has thread_ts defined", () => { + const msg = formatter.formatAdHocResult( + makeAssessment(), + makeVendor(), + "jane.doe", + ); + expect(msg.thread_ts).toBeDefined(); + }); + + it("includes risk level and recommendation", () => { + const msg = formatter.formatAdHocResult( + makeAssessment({ risk_level: "HIGH", recommendation: "initiate_contingency" }), + makeVendor(), + "jane.doe", + ); + expect(blocksText(msg.blocks)).toContain("HIGH"); + expect(blocksText(msg.blocks)).toContain("initiate_contingency"); + }); + + it("includes severity counts", () => { + const msg = formatter.formatAdHocResult( + makeAssessment({ severity_counts: { critical: 1, high: 2, medium: 3, low: 4 } }), + makeVendor(), + "jane.doe", + ); + const text = blocksText(msg.blocks); + expect(text).toContain("1 critical"); + expect(text).toContain("2 high"); + expect(text).toContain("3 medium"); + expect(text).toContain("4 low"); + }); + + it("includes vendor name in header", () => { + const msg = formatter.formatAdHocResult( + makeAssessment(), + makeVendor({ vendor_name: "SpecialCo" }), + "user", + ); + const headerText = (msg.blocks[0] as any).text.text; + expect(headerText).toContain("SpecialCo"); + }); + + it("includes action required for HIGH+ assessments", () => { + const msg = formatter.formatAdHocResult( + makeAssessment({ action_required: true }), + makeVendor(), + "user", + ); + expect(blocksText(msg.blocks)).toContain("Action Required"); + }); +}); + +// ── formatMonitorAlert ───────────────────────────────────────────────────── + +describe("formatMonitorAlert", () => { + const event: MonitorEventOutput = { + event_summary: "Regulatory fine imposed", + severity: "HIGH", + adverse: true, + event_type: "legal_regulatory", + }; + + it("uses correct emoji for severity", () => { + const msg = formatter.formatMonitorAlert( + makeAssessment({ risk_level: "HIGH" }), + makeVendor(), + event, + ); + const headerText = (msg.blocks[0] as any).text.text; + expect(headerText).toContain("\ud83d\udfe0"); // orange + }); + + it("includes event summary", () => { + const msg = formatter.formatMonitorAlert( + makeAssessment(), + makeVendor(), + event, + ); + expect(blocksText(msg.blocks)).toContain("Regulatory fine imposed"); + }); + + it("includes event type", () => { + const msg = formatter.formatMonitorAlert( + makeAssessment(), + makeVendor(), + event, + ); + expect(blocksText(msg.blocks)).toContain("legal_regulatory"); + }); + + it("includes vendor name in header", () => { + const msg = formatter.formatMonitorAlert( + makeAssessment(), + makeVendor({ vendor_name: "MonitoredCo" }), + event, + ); + const headerText = (msg.blocks[0] as any).text.text; + expect(headerText).toContain("MonitoredCo"); + }); + + it("channel routed by risk level", () => { + const msg = formatter.formatMonitorAlert( + makeAssessment({ risk_level: "CRITICAL" }), + makeVendor(), + { ...event, severity: "CRITICAL" }, + ); + expect(msg.channel).toBe("#test-critical"); + }); + + it("has no thread_ts (not a thread reply)", () => { + const msg = formatter.formatMonitorAlert( + makeAssessment(), + makeVendor(), + event, + ); + expect(msg.thread_ts).toBeUndefined(); + }); + + it("text fallback includes vendor name and event summary", () => { + const msg = formatter.formatMonitorAlert( + makeAssessment(), + makeVendor({ vendor_name: "TestCo" }), + event, + ); + expect(msg.text).toContain("TestCo"); + expect(msg.text).toContain("Regulatory fine imposed"); + }); +}); + +// ── Block Structure ──────────────────────────────────────────────────────── + +describe("block structure", () => { + it("all blocks have a type field", () => { + const msg = formatter.formatCriticalAlert( + makeAssessment(), + makeVendor(), + [makeFinding()], + ); + for (const block of msg.blocks) { + expect(block).toHaveProperty("type"); + } + }); + + it("header blocks use plain_text", () => { + const msg = formatter.formatCriticalAlert( + makeAssessment(), + makeVendor(), + [], + ); + const header = msg.blocks[0] as any; + expect(header.type).toBe("header"); + expect(header.text.type).toBe("plain_text"); + }); + + it("section blocks use mrkdwn", () => { + const msg = formatter.formatCriticalAlert( + makeAssessment(), + makeVendor(), + [], + ); + const sections = msg.blocks.filter((b) => b.type === "section"); + for (const s of sections) { + expect((s as any).text.type).toBe("mrkdwn"); + } + }); + + it("context blocks have elements array", () => { + const msg = formatter.formatCriticalAlert( + makeAssessment(), + makeVendor(), + [], + ); + const contexts = msg.blocks.filter((b) => b.type === "context"); + for (const c of contexts) { + expect(Array.isArray((c as any).elements)).toBe(true); + } + }); +}); diff --git a/typescript-recipes/parallel-n8n-procurement/tests/services/slack-ops-reporter.test.ts b/typescript-recipes/parallel-n8n-procurement/tests/services/slack-ops-reporter.test.ts new file mode 100644 index 0000000..5eec1d5 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/tests/services/slack-ops-reporter.test.ts @@ -0,0 +1,221 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { SlackOpsReporter } from "@/services/slack-ops-reporter.js"; +import type { SlackDeliveryService } from "@/services/slack-delivery.js"; +import type { HealthCheckReport } from "@/models/health-check.js"; +import type { ResearchRunSummary } from "@/models/research-run.js"; + +let mockDelivery: { sendAlert: ReturnType }; + +beforeEach(() => { + vi.clearAllMocks(); + mockDelivery = { sendAlert: vi.fn().mockResolvedValue({ ok: true }) }; +}); + +function makeReport(overrides: Partial = {}): HealthCheckReport { + return { + timestamp: "2026-03-05T12:00:00.000Z", + total_monitors: 25, + active_count: 22, + failed_count: 2, + orphan_count: 1, + orphans_deleted: 1, + monitors_recreated: 2, + webhook_healthy: true, + errors: [], + ...overrides, + }; +} + +describe("SlackOpsReporter", () => { + it("sends report to ops channel", async () => { + const reporter = new SlackOpsReporter({ + deliveryService: mockDelivery as unknown as SlackDeliveryService, + opsChannel: "#test-ops", + }); + + await reporter.sendHealthReport(makeReport()); + + expect(mockDelivery.sendAlert).toHaveBeenCalledWith( + expect.objectContaining({ channel: "#test-ops" }), + ); + }); + + it("uses default ops channel", async () => { + const reporter = new SlackOpsReporter({ + deliveryService: mockDelivery as unknown as SlackDeliveryService, + }); + + await reporter.sendHealthReport(makeReport()); + + expect(mockDelivery.sendAlert).toHaveBeenCalledWith( + expect.objectContaining({ channel: "#vendor-risk-ops" }), + ); + }); + + it("includes wrench emoji and date in header", async () => { + const reporter = new SlackOpsReporter({ + deliveryService: mockDelivery as unknown as SlackDeliveryService, + }); + + await reporter.sendHealthReport(makeReport()); + + const msg = mockDelivery.sendAlert.mock.calls[0][0]; + const headerBlock = msg.blocks[0]; + expect(headerBlock.text.text).toContain("\ud83d\udd27"); + expect(headerBlock.text.text).toContain("2026-03-05"); + }); + + it("includes correct counts in body", async () => { + const reporter = new SlackOpsReporter({ + deliveryService: mockDelivery as unknown as SlackDeliveryService, + }); + + await reporter.sendHealthReport(makeReport()); + + const blocksText = JSON.stringify(mockDelivery.sendAlert.mock.calls[0][0].blocks); + expect(blocksText).toContain("25"); // total + expect(blocksText).toContain("22"); // active + expect(blocksText).toContain("2"); // failed + recreated + expect(blocksText).toContain("1"); // orphan + }); + + it("shows webhook healthy status", async () => { + const reporter = new SlackOpsReporter({ + deliveryService: mockDelivery as unknown as SlackDeliveryService, + }); + + await reporter.sendHealthReport(makeReport({ webhook_healthy: true })); + let text = JSON.stringify(mockDelivery.sendAlert.mock.calls[0][0].blocks); + expect(text).toContain("Reachable"); + + await reporter.sendHealthReport(makeReport({ webhook_healthy: false })); + text = JSON.stringify(mockDelivery.sendAlert.mock.calls[1][0].blocks); + expect(text).toContain("UNREACHABLE"); + }); + + it("includes errors when present", async () => { + const reporter = new SlackOpsReporter({ + deliveryService: mockDelivery as unknown as SlackDeliveryService, + }); + + await reporter.sendHealthReport( + makeReport({ errors: ["Failed to delete mon_1", "API timeout"] }), + ); + + const blocksText = JSON.stringify(mockDelivery.sendAlert.mock.calls[0][0].blocks); + expect(blocksText).toContain("Failed to delete mon_1"); + expect(blocksText).toContain("API timeout"); + expect(blocksText).toContain("Errors (2)"); + }); + + it("has non-empty text fallback", async () => { + const reporter = new SlackOpsReporter({ + deliveryService: mockDelivery as unknown as SlackDeliveryService, + }); + + await reporter.sendHealthReport(makeReport()); + + const msg = mockDelivery.sendAlert.mock.calls[0][0]; + expect(msg.text.length).toBeGreaterThan(0); + }); +}); + +// ── sendRunSummary ────────────────────────────────────────────────────────── + +function makeRunSummary(overrides: Partial = {}): ResearchRunSummary { + return { + total_due: 10, + total_researched: 8, + total_failed: 2, + risk_counts: { LOW: 4, MEDIUM: 2, HIGH: 1, CRITICAL: 1 }, + adverse_count: 1, + batches_executed: 2, + duration_ms: 45000, + ...overrides, + }; +} + +describe("sendRunSummary", () => { + it("sends run summary to ops channel", async () => { + const reporter = new SlackOpsReporter({ + deliveryService: mockDelivery as unknown as SlackDeliveryService, + opsChannel: "#test-ops", + }); + + await reporter.sendRunSummary(makeRunSummary()); + + expect(mockDelivery.sendAlert).toHaveBeenCalledWith( + expect.objectContaining({ channel: "#test-ops" }), + ); + }); + + it("includes failure count in body", async () => { + const reporter = new SlackOpsReporter({ + deliveryService: mockDelivery as unknown as SlackDeliveryService, + }); + + await reporter.sendRunSummary(makeRunSummary({ total_failed: 3 })); + + const blocksText = JSON.stringify(mockDelivery.sendAlert.mock.calls[0][0].blocks); + expect(blocksText).toContain("3"); + }); + + it("includes adverse count in body", async () => { + const reporter = new SlackOpsReporter({ + deliveryService: mockDelivery as unknown as SlackDeliveryService, + }); + + await reporter.sendRunSummary(makeRunSummary({ adverse_count: 2 })); + + const blocksText = JSON.stringify(mockDelivery.sendAlert.mock.calls[0][0].blocks); + expect(blocksText).toContain("2"); + }); + + it("includes risk breakdown in context", async () => { + const reporter = new SlackOpsReporter({ + deliveryService: mockDelivery as unknown as SlackDeliveryService, + }); + + await reporter.sendRunSummary(makeRunSummary()); + + const blocksText = JSON.stringify(mockDelivery.sendAlert.mock.calls[0][0].blocks); + expect(blocksText).toContain("CRITICAL: 1"); + expect(blocksText).toContain("HIGH: 1"); + expect(blocksText).toContain("MEDIUM: 2"); + expect(blocksText).toContain("LOW: 4"); + }); + + it("shows warning icon when failures present", async () => { + const reporter = new SlackOpsReporter({ + deliveryService: mockDelivery as unknown as SlackDeliveryService, + }); + + await reporter.sendRunSummary(makeRunSummary({ total_failed: 1 })); + + const header = mockDelivery.sendAlert.mock.calls[0][0].blocks[0]; + expect(header.text.text).toContain("\u26a0\ufe0f"); + }); + + it("shows check icon when no failures", async () => { + const reporter = new SlackOpsReporter({ + deliveryService: mockDelivery as unknown as SlackDeliveryService, + }); + + await reporter.sendRunSummary(makeRunSummary({ total_failed: 0 })); + + const header = mockDelivery.sendAlert.mock.calls[0][0].blocks[0]; + expect(header.text.text).toContain("\u2705"); + }); + + it("has non-empty text fallback", async () => { + const reporter = new SlackOpsReporter({ + deliveryService: mockDelivery as unknown as SlackDeliveryService, + }); + + await reporter.sendRunSummary(makeRunSummary()); + + const msg = mockDelivery.sendAlert.mock.calls[0][0]; + expect(msg.text.length).toBeGreaterThan(0); + expect(msg.text).toContain("Research Run Complete"); + }); +}); diff --git a/typescript-recipes/parallel-n8n-procurement/tests/services/vendor-ingestion.test.ts b/typescript-recipes/parallel-n8n-procurement/tests/services/vendor-ingestion.test.ts new file mode 100644 index 0000000..5b28b07 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/tests/services/vendor-ingestion.test.ts @@ -0,0 +1,479 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { VendorIngestionService } from "@/services/vendor-ingestion.js"; +import type { MonitorPortfolioManager } from "@/services/monitor-portfolio-manager.js"; +import type { Vendor } from "@/models/vendor.js"; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +const silentLogger = { debug: vi.fn(), warn: vi.fn(), error: vi.fn() }; + +function createService() { + return new VendorIngestionService({ logger: silentLogger }); +} + +function makeVendor(overrides: Partial = {}): Vendor { + return { + vendor_name: "Acme Corp", + vendor_domain: "https://acme.com", + vendor_category: "technology", + monitoring_priority: "high", + active: true, + ...overrides, + }; +} + +const CSV_HEADER = "vendor_name,vendor_domain,vendor_category,risk_tier_override,active,monitoring_priority"; + +function csvRow( + name: string, + domain: string, + category: string, + override = "", + active = "true", + priority = "high", +) { + return `${name},${domain},${category},${override},${active},${priority}`; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// ── ingestFromCSV ────────────────────────────────────────────────────────── + +describe("ingestFromCSV", () => { + it("parses valid CSV with all columns", async () => { + const service = createService(); + const csv = [ + CSV_HEADER, + csvRow("Acme Corp", "https://acme.com", "technology", "", "true", "high"), + ].join("\n"); + + const vendors = await service.ingestFromCSV(csv); + + expect(vendors).toHaveLength(1); + expect(vendors[0].vendor_name).toBe("Acme Corp"); + expect(vendors[0].vendor_domain).toBe("https://acme.com"); + expect(vendors[0].vendor_category).toBe("technology"); + expect(vendors[0].monitoring_priority).toBe("high"); + expect(vendors[0].active).toBe(true); + }); + + it("handles missing optional risk_tier_override", async () => { + const service = createService(); + const csv = [ + CSV_HEADER, + csvRow("Acme", "https://acme.com", "technology", "", "true", "high"), + ].join("\n"); + + const vendors = await service.ingestFromCSV(csv); + expect(vendors[0].risk_tier_override).toBeUndefined(); + }); + + it("handles risk_tier_override when present", async () => { + const service = createService(); + const csv = [ + CSV_HEADER, + csvRow("Acme", "https://acme.com", "technology", "HIGH", "true", "high"), + ].join("\n"); + + const vendors = await service.ingestFromCSV(csv); + expect(vendors[0].risk_tier_override).toBe("HIGH"); + }); + + it("handles quoted fields with commas", async () => { + const service = createService(); + const csv = `${CSV_HEADER}\n"Acme, Inc",https://acme.com,technology,,true,high`; + + const vendors = await service.ingestFromCSV(csv); + expect(vendors[0].vendor_name).toBe("Acme, Inc"); + }); + + it("handles BOM character", async () => { + const service = createService(); + const csv = `\uFEFF${CSV_HEADER}\nAcme,https://acme.com,technology,,true,high`; + + const vendors = await service.ingestFromCSV(csv); + expect(vendors).toHaveLength(1); + }); + + it("handles \\r\\n line endings", async () => { + const service = createService(); + const csv = `${CSV_HEADER}\r\nAcme,https://acme.com,technology,,true,high`; + + const vendors = await service.ingestFromCSV(csv); + expect(vendors).toHaveLength(1); + }); + + it("skips invalid rows and continues", async () => { + const service = createService(); + const csv = [ + CSV_HEADER, + csvRow("Good", "https://good.com", "technology", "", "true", "high"), + csvRow("Bad", "https://bad.com", "invalid_category", "", "true", "high"), + csvRow("Also Good", "https://also.com", "healthcare", "", "true", "low"), + ].join("\n"); + + const vendors = await service.ingestFromCSV(csv); + expect(vendors).toHaveLength(2); + expect(vendors[0].vendor_name).toBe("Good"); + expect(vendors[1].vendor_name).toBe("Also Good"); + }); + + it("parses 'false' as active=false", async () => { + const service = createService(); + const csv = [ + CSV_HEADER, + csvRow("Acme", "https://acme.com", "technology", "", "false", "high"), + ].join("\n"); + + const vendors = await service.ingestFromCSV(csv); + expect(vendors[0].active).toBe(false); + }); + + it("defaults empty active to true", async () => { + const service = createService(); + const csv = [ + CSV_HEADER, + csvRow("Acme", "https://acme.com", "technology", "", "", "high"), + ].join("\n"); + + const vendors = await service.ingestFromCSV(csv); + expect(vendors[0].active).toBe(true); + }); + + it("prepends https:// to bare domain", async () => { + const service = createService(); + const csv = [ + CSV_HEADER, + csvRow("Acme", "acme.com", "technology", "", "true", "high"), + ].join("\n"); + + const vendors = await service.ingestFromCSV(csv); + expect(vendors[0].vendor_domain).toBe("https://acme.com"); + }); + + it("returns empty for header-only CSV", async () => { + const service = createService(); + const vendors = await service.ingestFromCSV(CSV_HEADER); + expect(vendors).toEqual([]); + }); +}); + +// ── deduplicateVendors ───────────────────────────────────────────────────── + +describe("deduplicateVendors", () => { + it("keeps last occurrence", () => { + const service = createService(); + const vendors = [ + makeVendor({ vendor_name: "Old", vendor_domain: "https://acme.com" }), + makeVendor({ vendor_name: "New", vendor_domain: "https://acme.com" }), + ]; + + const deduped = service.deduplicateVendors(vendors); + + expect(deduped).toHaveLength(1); + expect(deduped[0].vendor_name).toBe("New"); + }); + + it("no duplicates returns same count", () => { + const service = createService(); + const vendors = [ + makeVendor({ vendor_domain: "https://a.com" }), + makeVendor({ vendor_domain: "https://b.com" }), + ]; + + const deduped = service.deduplicateVendors(vendors); + expect(deduped).toHaveLength(2); + }); + + it("handles multiple duplicates", () => { + const service = createService(); + const vendors = [ + makeVendor({ vendor_name: "V1", vendor_domain: "https://a.com" }), + makeVendor({ vendor_name: "V2", vendor_domain: "https://a.com" }), + makeVendor({ vendor_name: "V3", vendor_domain: "https://a.com" }), + ]; + + const deduped = service.deduplicateVendors(vendors); + expect(deduped).toHaveLength(1); + expect(deduped[0].vendor_name).toBe("V3"); + }); +}); + +// ── computeDiff ──────────────────────────────────────────────────────────── + +describe("computeDiff", () => { + it("all new vendors → all in added", () => { + const service = createService(); + const incoming = [makeVendor({ vendor_domain: "https://new.com" })]; + const previous: Vendor[] = []; + + const diff = service.computeDiff(incoming, previous); + + expect(diff.added).toHaveLength(1); + expect(diff.removed).toHaveLength(0); + expect(diff.unchanged).toHaveLength(0); + expect(diff.modified).toHaveLength(0); + }); + + it("all removed → all in removed", () => { + const service = createService(); + const incoming: Vendor[] = []; + const previous = [makeVendor({ vendor_domain: "https://old.com" })]; + + const diff = service.computeDiff(incoming, previous); + + expect(diff.added).toHaveLength(0); + expect(diff.removed).toHaveLength(1); + expect(diff.removed[0].vendor_domain).toBe("https://old.com"); + }); + + it("mix of added, removed, unchanged", () => { + const service = createService(); + const incoming = [ + makeVendor({ vendor_domain: "https://new.com" }), + makeVendor({ vendor_domain: "https://stable.com" }), + ]; + const previous = [ + makeVendor({ vendor_domain: "https://stable.com" }), + makeVendor({ vendor_domain: "https://gone.com" }), + ]; + + const diff = service.computeDiff(incoming, previous); + + expect(diff.added).toHaveLength(1); + expect(diff.added[0].vendor_domain).toBe("https://new.com"); + expect(diff.removed).toHaveLength(1); + expect(diff.removed[0].vendor_domain).toBe("https://gone.com"); + expect(diff.unchanged).toHaveLength(1); + expect(diff.unchanged[0].vendor_domain).toBe("https://stable.com"); + }); + + it("detects modified monitoring_priority", () => { + const service = createService(); + const incoming = [ + makeVendor({ vendor_domain: "https://acme.com", monitoring_priority: "high" }), + ]; + const previous = [ + makeVendor({ vendor_domain: "https://acme.com", monitoring_priority: "low" }), + ]; + + const diff = service.computeDiff(incoming, previous); + + expect(diff.modified).toHaveLength(1); + expect(diff.modified[0].changes).toContain("monitoring_priority"); + expect(diff.modified[0].vendor.monitoring_priority).toBe("high"); + expect(diff.modified[0].previous.monitoring_priority).toBe("low"); + }); + + it("detects modified vendor_category", () => { + const service = createService(); + const incoming = [ + makeVendor({ vendor_domain: "https://acme.com", vendor_category: "healthcare" }), + ]; + const previous = [ + makeVendor({ vendor_domain: "https://acme.com", vendor_category: "technology" }), + ]; + + const diff = service.computeDiff(incoming, previous); + + expect(diff.modified).toHaveLength(1); + expect(diff.modified[0].changes).toContain("vendor_category"); + }); + + it("unchanged when fields match", () => { + const service = createService(); + const vendor = makeVendor(); + const diff = service.computeDiff([vendor], [vendor]); + + expect(diff.unchanged).toHaveLength(1); + expect(diff.modified).toHaveLength(0); + }); + + it("empty incoming + empty previous → empty diff", () => { + const service = createService(); + const diff = service.computeDiff([], []); + + expect(diff.added).toHaveLength(0); + expect(diff.removed).toHaveLength(0); + expect(diff.unchanged).toHaveLength(0); + expect(diff.modified).toHaveLength(0); + }); +}); + +// ── applyDiff ────────────────────────────────────────────────────────────── + +describe("applyDiff", () => { + let mockPortfolio: { + deployMonitors: ReturnType; + removeMonitors: ReturnType; + }; + + beforeEach(() => { + let callCount = 0; + mockPortfolio = { + deployMonitors: vi.fn().mockImplementation(async (vendors: Vendor[]) => { + const map = new Map(); + for (const v of vendors) { + callCount++; + map.set(v.vendor_domain, [`mon_${callCount}`]); + } + return map; + }), + removeMonitors: vi.fn().mockResolvedValue(undefined), + }; + }); + + it("deploys monitors for added vendors", async () => { + const service = createService(); + const diff = { + added: [makeVendor({ vendor_domain: "https://new.com" })], + removed: [], + unchanged: [], + modified: [], + }; + + const result = await service.applyDiff( + diff, + mockPortfolio as unknown as MonitorPortfolioManager, + ); + + expect(mockPortfolio.deployMonitors).toHaveBeenCalledWith(diff.added); + expect(result.monitors_created.get("https://new.com")).toBeDefined(); + }); + + it("removes monitors for removed vendors", async () => { + const service = createService(); + const diff = { + added: [], + removed: [makeVendor({ vendor_domain: "https://old.com", monitor_ids: ["mon_1", "mon_2"] })], + unchanged: [], + modified: [], + }; + + const result = await service.applyDiff( + diff, + mockPortfolio as unknown as MonitorPortfolioManager, + ); + + expect(mockPortfolio.removeMonitors).toHaveBeenCalledWith(["mon_1", "mon_2"]); + expect(result.monitors_deleted).toEqual(["mon_1", "mon_2"]); + }); + + it("adjusts monitors for modified priority", async () => { + const service = createService(); + const diff = { + added: [], + removed: [], + unchanged: [], + modified: [ + { + vendor: makeVendor({ vendor_domain: "https://acme.com", monitoring_priority: "high" }), + previous: makeVendor({ + vendor_domain: "https://acme.com", + monitoring_priority: "low", + monitor_ids: ["old_mon_1"], + }), + changes: ["monitoring_priority"], + }, + ], + }; + + const result = await service.applyDiff( + diff, + mockPortfolio as unknown as MonitorPortfolioManager, + ); + + expect(mockPortfolio.removeMonitors).toHaveBeenCalledWith(["old_mon_1"]); + expect(mockPortfolio.deployMonitors).toHaveBeenCalledWith([diff.modified[0].vendor]); + expect(result.monitors_adjusted).toContain("https://acme.com"); + }); + + it("collects errors without throwing", async () => { + const service = createService(); + mockPortfolio.deployMonitors.mockRejectedValueOnce(new Error("API down")); + + const diff = { + added: [makeVendor({ vendor_domain: "https://new.com" })], + removed: [], + unchanged: [], + modified: [], + }; + + const result = await service.applyDiff( + diff, + mockPortfolio as unknown as MonitorPortfolioManager, + ); + + expect(result.errors).toHaveLength(1); + expect(result.errors[0].vendor_domain).toBe("https://new.com"); + expect(result.errors[0].error).toContain("API down"); + }); + + it("empty diff makes no calls", async () => { + const service = createService(); + const diff = { added: [], removed: [], unchanged: [], modified: [] }; + + const result = await service.applyDiff( + diff, + mockPortfolio as unknown as MonitorPortfolioManager, + ); + + expect(mockPortfolio.deployMonitors).not.toHaveBeenCalled(); + expect(mockPortfolio.removeMonitors).not.toHaveBeenCalled(); + expect(result.monitors_created.size).toBe(0); + expect(result.monitors_deleted).toHaveLength(0); + }); + + it("skips removed vendors without monitor_ids", async () => { + const service = createService(); + const diff = { + added: [], + removed: [makeVendor({ vendor_domain: "https://old.com" })], // no monitor_ids + unchanged: [], + modified: [], + }; + + const result = await service.applyDiff( + diff, + mockPortfolio as unknown as MonitorPortfolioManager, + ); + + expect(mockPortfolio.removeMonitors).not.toHaveBeenCalled(); + expect(result.monitors_deleted).toHaveLength(0); + }); +}); + +// ── updateRegistry ───────────────────────────────────────────────────────── + +describe("updateRegistry", () => { + it("merges monitor IDs and sets last_synced_at", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-05T12:00:00.000Z")); + + const { writeFile } = await import("node:fs/promises"); + vi.mock("node:fs/promises", () => ({ + writeFile: vi.fn().mockResolvedValue(undefined), + })); + + const service = createService(); + const vendors = [makeVendor({ vendor_domain: "https://acme.com" })]; + const mapping = new Map([["https://acme.com", ["mon_1", "mon_2"]]]); + + await service.updateRegistry(vendors, mapping, "/tmp/test-registry.json"); + + const mockedWriteFile = vi.mocked(writeFile); + expect(mockedWriteFile).toHaveBeenCalledWith( + "/tmp/test-registry.json", + expect.stringContaining("mon_1"), + ); + + const written = JSON.parse(mockedWriteFile.mock.calls[0][1] as string); + expect(written.vendors[0].monitor_ids).toEqual(["mon_1", "mon_2"]); + expect(written.vendors[0].last_synced_at).toBeDefined(); + expect(written.total_count).toBe(1); + + vi.useRealTimers(); + }); +}); diff --git a/typescript-recipes/parallel-n8n-procurement/tests/utils/csv-parser.test.ts b/typescript-recipes/parallel-n8n-procurement/tests/utils/csv-parser.test.ts new file mode 100644 index 0000000..f391027 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/tests/utils/csv-parser.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +import { parseCSV } from "@/utils/csv-parser.js"; + +describe("parseCSV", () => { + it("parses basic CSV", () => { + const result = parseCSV("a,b,c\n1,2,3"); + expect(result).toEqual([["a", "b", "c"], ["1", "2", "3"]]); + }); + + it("handles quoted fields with commas", () => { + const result = parseCSV('name,desc\n"Acme, Inc","A company"'); + expect(result).toEqual([["name", "desc"], ["Acme, Inc", "A company"]]); + }); + + it("handles escaped quotes inside fields", () => { + const result = parseCSV('a\n"He said ""hello"""'); + expect(result).toEqual([["a"], ['He said "hello"']]); + }); + + it("handles empty fields", () => { + const result = parseCSV("a,b,c\n1,,3"); + expect(result).toEqual([["a", "b", "c"], ["1", "", "3"]]); + }); + + it("strips BOM character", () => { + const result = parseCSV("\uFEFFa,b\n1,2"); + expect(result).toEqual([["a", "b"], ["1", "2"]]); + }); + + it("handles \\r\\n line endings", () => { + const result = parseCSV("a,b\r\n1,2\r\n3,4"); + expect(result).toEqual([["a", "b"], ["1", "2"], ["3", "4"]]); + }); + + it("handles trailing newline", () => { + const result = parseCSV("a,b\n1,2\n"); + expect(result).toEqual([["a", "b"], ["1", "2"]]); + }); + + it("handles single row", () => { + const result = parseCSV("a,b,c"); + expect(result).toEqual([["a", "b", "c"]]); + }); + + it("returns empty array for empty string", () => { + const result = parseCSV(""); + expect(result).toEqual([]); + }); +}); diff --git a/typescript-recipes/parallel-n8n-procurement/tests/workflows/workflow-combined.test.ts b/typescript-recipes/parallel-n8n-procurement/tests/workflows/workflow-combined.test.ts new file mode 100644 index 0000000..df2a357 --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/tests/workflows/workflow-combined.test.ts @@ -0,0 +1,354 @@ +import { describe, it, expect } from "vitest"; +import { generateCombinedWorkflow } from "@/workflows/generators/workflow-combined.js"; +import type { N8nWorkflow } from "@/workflows/generator-utils.js"; + +// ── Shared Validator (same as workflow-generators.test.ts) ──────────────── + +function validateWorkflow(wf: N8nWorkflow) { + expect(typeof wf.name).toBe("string"); + expect(wf.name.length).toBeGreaterThan(0); + expect(Array.isArray(wf.nodes)).toBe(true); + expect(wf.nodes.length).toBeGreaterThan(0); + expect(typeof wf.connections).toBe("object"); + expect(wf.settings).toBeDefined(); + + const nodeNames = new Set(); + for (const node of wf.nodes) { + expect(typeof node.id).toBe("string"); + expect(typeof node.name).toBe("string"); + expect(typeof node.type).toBe("string"); + expect(node.type).toMatch(/^n8n-nodes-(base|parallel)\./); + expect(Array.isArray(node.position)).toBe(true); + expect(node.position).toHaveLength(2); + expect(typeof node.position[0]).toBe("number"); + expect(typeof node.position[1]).toBe("number"); + expect(typeof node.typeVersion).toBe("number"); + expect(typeof node.parameters).toBe("object"); + expect(nodeNames.has(node.name)).toBe(false); + nodeNames.add(node.name); + } + + for (const [fromName, conn] of Object.entries(wf.connections)) { + expect(nodeNames.has(fromName)).toBe(true); + for (const outputs of conn.main) { + for (const target of outputs) { + expect(nodeNames.has(target.node)).toBe(true); + expect(target.type).toBe("main"); + expect(typeof target.index).toBe("number"); + } + } + } +} + +function hasNodeName(wf: N8nWorkflow, name: string): boolean { + return wf.nodes.some((n) => n.name === name); +} + +function getNodesByType(wf: N8nWorkflow, type: string) { + return wf.nodes.filter((n) => n.type === `n8n-nodes-base.${type}`); +} + +function connectsTo(wf: N8nWorkflow, from: string, to: string): boolean { + const conn = wf.connections[from]; + if (!conn) return false; + return conn.main.some((outputs) => outputs.some((c) => c.node === to)); +} + +// ── Tests ───────────────────────────────────────────────────────────────── + +const wf = generateCombinedWorkflow(); + +describe("Combined Workflow — structure", () => { + it("generates valid n8n workflow structure", () => { + validateWorkflow(wf); + }); + + it("has expected node count (~56)", () => { + expect(wf.nodes.length).toBe(56); + }); + + it("has no duplicate node names", () => { + const names = wf.nodes.map((n) => n.name); + expect(new Set(names).size).toBe(names.length); + }); + + it("has zero executeWorkflow nodes", () => { + expect(getNodesByType(wf, "executeWorkflow")).toHaveLength(0); + }); + + it("has zero executeWorkflowTrigger nodes", () => { + expect(getNodesByType(wf, "executeWorkflowTrigger")).toHaveLength(0); + }); +}); + +describe("Combined Workflow — triggers", () => { + it("has exactly 2 schedule triggers", () => { + expect(getNodesByType(wf, "scheduleTrigger")).toHaveLength(2); + }); + + it("has midnight sync trigger", () => { + expect(hasNodeName(wf, "Sync: Daily Midnight Trigger")).toBe(true); + }); + + it("has 6AM research trigger", () => { + expect(hasNodeName(wf, "Research: Daily 6AM Trigger")).toBe(true); + }); + + it("has 6 webhook triggers and no Parallel trigger dependency", () => { + expect(getNodesByType(wf, "webhook")).toHaveLength(6); + const parallelTriggers = wf.nodes.filter((n) => + n.type.includes("parallelMonitorTrigger") || n.type.includes("parallelTrigger"), + ); + expect(parallelTriggers.length).toBe(0); + }); + + it("has deploy-monitors webhook", () => { + const wh = wf.nodes.find( + (n) => n.type === "n8n-nodes-base.webhook" && String(n.parameters.path).includes("deploy-monitors"), + ); + expect(wh).toBeDefined(); + }); + + it("has monitor event webhook trigger", () => { + const trigger = wf.nodes.find( + (n) => n.type === "n8n-nodes-base.webhook" && String(n.parameters.path).includes("parallel-monitor-event"), + ); + expect(trigger).toBeDefined(); + expect(trigger!.parameters.httpMethod).toBe("POST"); + }); + + it("has slack-command webhook", () => { + const wh = wf.nodes.find( + (n) => n.type === "n8n-nodes-base.webhook" && String(n.parameters.path).includes("slack-command"), + ); + expect(wh).toBeDefined(); + }); + + it("has task completion webhook trigger", () => { + const trigger = wf.nodes.find( + (n) => n.type === "n8n-nodes-base.webhook" && String(n.parameters.path).includes("parallel-task-completion"), + ); + expect(trigger).toBeDefined(); + expect(trigger!.parameters.httpMethod).toBe("POST"); + }); + + it("has snapshot dashboard webhook trigger", () => { + const wh = wf.nodes.find( + (n) => n.type === "n8n-nodes-base.webhook" && String(n.parameters.path).includes("procurement-dashboard-snapshot"), + ); + expect(wh).toBeDefined(); + expect(wh!.parameters.httpMethod).toBe("GET"); + }); + + it("has portfolio mutation webhook trigger", () => { + const wh = wf.nodes.find( + (n) => n.type === "n8n-nodes-base.webhook" && String(n.parameters.path).includes("procurement-portfolio-mutation"), + ); + expect(wh).toBeDefined(); + expect(wh!.parameters.httpMethod).toBe("POST"); + expect(wh!.parameters.responseMode).toBe("lastNode"); + }); + + it("builds the dashboard view model from sheet data", () => { + const node = wf.nodes.find((n) => n.name === "Snapshot: Build Payload"); + expect(node).toBeDefined(); + + const code = String(node!.parameters.jsCode); + expect(code).toContain("lastUpdated"); + expect(code).toContain("riskDistribution"); + expect(code).toContain("researchSummary"); + expect(code).toContain("actionQueue"); + expect(code).toContain("vendors"); + }); + + it("builds portfolio mutation rows with shared-secret validation", () => { + const node = wf.nodes.find((n) => n.name === "Portfolio: Build Vendor Rows"); + expect(node).toBeDefined(); + + const code = String(node!.parameters.jsCode); + expect(code).toContain("PROCUREMENT_DASHBOARD_WRITE_TOKEN"); + expect(code).toContain("x-procurement-dashboard-token"); + expect(code).toContain("addVendor"); + expect(code).toContain("uploadVendors"); + expect(code).toContain("resetSeedVendors"); + expect(code).toContain("dashboard_managed"); + }); + + it("connects portfolio mutation writes to Vendors and Registry", () => { + expect(connectsTo(wf, "Portfolio: Mutation Webhook", "Portfolio: Read Vendors")).toBe(true); + expect(connectsTo(wf, "Portfolio: Build Vendor Rows", "Portfolio: Write Vendors")).toBe(true); + expect(connectsTo(wf, "Portfolio: Build Registry Rows", "Portfolio: Write Registry")).toBe(true); + expect(connectsTo(wf, "Portfolio: Write Registry", "Portfolio: Mutation Result")).toBe(true); + }); +}); + +describe("Combined Workflow — Sync flow (WF1)", () => { + it("has diff code node", () => { + expect(hasNodeName(wf, "Sync: Compute Diff")).toBe(true); + }); + + it("has monitor creation and deletion", () => { + expect(hasNodeName(wf, "Sync: Create Monitor")).toBe(true); + expect(hasNodeName(wf, "Sync: Delete Monitor")).toBe(true); + }); + + it("diff code references prefixed node names", () => { + const diff = wf.nodes.find((n) => n.name === "Sync: Compute Diff"); + const code = String(diff!.parameters.jsCode); + expect(code).toContain("Sync: Read Vendor List"); + expect(code).toContain("Sync: Read Previous Registry"); + expect(code).toContain("rawIncoming.filter(isActive)"); + expect(code).toContain("hasMonitorIds"); + }); +}); + +describe("Combined Workflow — Research flow (WF2)", () => { + it("uses HTTP request to Parallel task runs endpoint per vendor", () => { + const node = wf.nodes.find((n) => n.name === "Research: Run Deep Research"); + expect(node).toBeDefined(); + expect(node!.type).toBe("n8n-nodes-base.httpRequest"); + expect(node!.parameters.method).toBe("POST"); + expect(String(node!.parameters.url)).toContain("/v1/tasks/runs"); + }); + + it("has loop for vendor batching", () => { + expect(hasNodeName(wf, "Research: Loop Vendors")).toBe(true); + }); + + it("has build prompts code", () => { + const node = wf.nodes.find((n) => n.name === "Research: Build Prompts"); + expect(node).toBeDefined(); + const code = String(node!.parameters.jsCode); + expect(code).toContain("vendor risk assessment"); + }); +}); + +describe("Combined Workflow — Monitor flows (WF4)", () => { + it("has monitor deploy webhook", () => { + expect(hasNodeName(wf, "Monitor: Deploy Webhook")).toBe(true); + }); + + it("has monitor creation in deploy flow", () => { + expect(hasNodeName(wf, "Monitor: Create Monitor")).toBe(true); + }); + + it("has event enrichment", () => { + expect(hasNodeName(wf, "Monitor: Enrich & Classify Event")).toBe(true); + }); + + it("enrich code handles native trigger event_group data", () => { + const enrich = wf.nodes.find((n) => n.name === "Monitor: Enrich & Classify Event"); + const code = String(enrich!.parameters.jsCode); + expect(code).toContain("event_group"); + expect(code).toContain("source: 'monitor_event'"); + }); +}); + +describe("Combined Workflow — Ad-Hoc flow (WF5)", () => { + it("has slash command and result callback webhooks", () => { + expect(hasNodeName(wf, "AdHoc: Slack Command")).toBe(true); + expect(hasNodeName(wf, "AdHoc: Result Callback")).toBe(true); + }); + + it("has Tag Source code node with source: adhoc", () => { + const tag = wf.nodes.find((n) => n.name === "AdHoc: Tag Source"); + expect(tag).toBeDefined(); + const code = String(tag!.parameters.jsCode); + expect(code).toContain("source: 'adhoc'"); + }); + + it("has thread reply Slack node", () => { + expect(hasNodeName(wf, "AdHoc: Post Thread Reply")).toBe(true); + }); +}); + +describe("Combined Workflow — Parallel API HTTP nodes", () => { + it("uses HTTP node for Sync: Create Monitor", () => { + const node = wf.nodes.find((n) => n.name === "Sync: Create Monitor"); + expect(node).toBeDefined(); + expect(node!.type).toBe("n8n-nodes-base.httpRequest"); + expect(node!.parameters.method).toBe("POST"); + expect(String(node!.parameters.url)).toContain("/v1alpha/monitors"); + }); + + it("uses HTTP node for Sync: Delete Monitor", () => { + const node = wf.nodes.find((n) => n.name === "Sync: Delete Monitor"); + expect(node).toBeDefined(); + expect(node!.type).toBe("n8n-nodes-base.httpRequest"); + expect(node!.parameters.method).toBe("DELETE"); + }); + + it("uses HTTP node for Monitor: Create Monitor", () => { + const node = wf.nodes.find((n) => n.name === "Monitor: Create Monitor"); + expect(node).toBeDefined(); + expect(node!.type).toBe("n8n-nodes-base.httpRequest"); + expect(node!.parameters.method).toBe("POST"); + }); + + it("uses HTTP node for AdHoc: Start Research Task", () => { + const node = wf.nodes.find((n) => n.name === "AdHoc: Start Research Task"); + expect(node).toBeDefined(); + expect(node!.type).toBe("n8n-nodes-base.httpRequest"); + expect(node!.parameters.method).toBe("POST"); + expect(String(node!.parameters.url)).toContain("/v1/tasks/runs"); + }); + + it("has HTTP Request nodes for API portability", () => { + const httpNodes = getNodesByType(wf, "httpRequest"); + expect(httpNodes.length).toBeGreaterThanOrEqual(4); + }); +}); + +describe("Combined Workflow — Shared Scoring Chain", () => { + it("has Risk Scorer with scoring logic", () => { + const scorer = wf.nodes.find((n) => n.name === "Scoring: Risk Scorer"); + expect(scorer).toBeDefined(); + const code = String(scorer!.parameters.jsCode); + expect(code).toContain("CRITICAL"); + expect(code).toContain("severity"); + expect(code).toContain("override"); + }); + + it("has Route by Risk Level switch", () => { + expect(hasNodeName(wf, "Scoring: Route by Risk Level")).toBe(true); + }); + + it("has Slack alert nodes", () => { + expect(hasNodeName(wf, "Scoring: Alert Critical")).toBe(true); + expect(hasNodeName(wf, "Scoring: Alert High")).toBe(true); + }); + + it("has Audit Log", () => { + const log = wf.nodes.find((n) => n.name === "Scoring: Audit Log"); + expect(log).toBeDefined(); + expect(log!.parameters.operation).toBe("appendOrUpdate"); + }); + + it("has Route Back switch", () => { + expect(hasNodeName(wf, "Scoring: Route Back")).toBe(true); + }); +}); + +describe("Combined Workflow — Fan-in (3 sources → 1 scorer)", () => { + it("Research: Collect Results connects to Scoring: Risk Scorer", () => { + expect(connectsTo(wf, "Research: Collect Results", "Scoring: Risk Scorer")).toBe(true); + }); + + it("Monitor: Enrich & Classify Event connects to Scoring: Risk Scorer", () => { + expect(connectsTo(wf, "Monitor: Enrich & Classify Event", "Scoring: Risk Scorer")).toBe(true); + }); + + it("AdHoc: Tag Source connects to Scoring: Risk Scorer", () => { + expect(connectsTo(wf, "AdHoc: Tag Source", "Scoring: Risk Scorer")).toBe(true); + }); +}); + +describe("Combined Workflow — Route Back (scorer → per-flow continuations)", () => { + it("Route Back connects to Research: Update Research Dates", () => { + expect(connectsTo(wf, "Scoring: Route Back", "Research: Update Research Dates")).toBe(true); + }); + + it("Route Back connects to AdHoc: Post Thread Reply", () => { + expect(connectsTo(wf, "Scoring: Route Back", "AdHoc: Post Thread Reply")).toBe(true); + }); +}); diff --git a/typescript-recipes/parallel-n8n-procurement/tests/workflows/workflow-generators.test.ts b/typescript-recipes/parallel-n8n-procurement/tests/workflows/workflow-generators.test.ts new file mode 100644 index 0000000..77a329b --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/tests/workflows/workflow-generators.test.ts @@ -0,0 +1,244 @@ +import { describe, it, expect } from "vitest"; +import { generateVendorSyncWorkflow } from "@/workflows/generators/workflow1-vendor-sync.js"; +import { generateDeepResearchWorkflow } from "@/workflows/generators/workflow2-deep-research.js"; +import { generateRiskScoringWorkflow } from "@/workflows/generators/workflow3-risk-scoring.js"; +import { generateMonitorWorkflow } from "@/workflows/generators/workflow4-monitors.js"; +import { generateAdHocWorkflow } from "@/workflows/generators/workflow5-adhoc.js"; +import type { N8nWorkflow } from "@/workflows/generator-utils.js"; + +// ── Shared Validator ─────────────────────────────────────────────────────── + +function validateWorkflow(wf: N8nWorkflow) { + expect(typeof wf.name).toBe("string"); + expect(wf.name.length).toBeGreaterThan(0); + expect(Array.isArray(wf.nodes)).toBe(true); + expect(wf.nodes.length).toBeGreaterThan(0); + expect(typeof wf.connections).toBe("object"); + expect(wf.settings).toBeDefined(); + + // Validate each node + const nodeNames = new Set(); + for (const node of wf.nodes) { + expect(typeof node.id).toBe("string"); + expect(typeof node.name).toBe("string"); + expect(typeof node.type).toBe("string"); + expect(node.type).toMatch(/^n8n-nodes-base\./); + expect(Array.isArray(node.position)).toBe(true); + expect(node.position).toHaveLength(2); + expect(typeof node.position[0]).toBe("number"); + expect(typeof node.position[1]).toBe("number"); + expect(typeof node.typeVersion).toBe("number"); + expect(typeof node.parameters).toBe("object"); + + // No duplicate names + expect(nodeNames.has(node.name)).toBe(false); + nodeNames.add(node.name); + } + + // Validate connections reference existing nodes + for (const [fromName, conn] of Object.entries(wf.connections)) { + expect(nodeNames.has(fromName)).toBe(true); + for (const outputs of conn.main) { + for (const target of outputs) { + expect(nodeNames.has(target.node)).toBe(true); + expect(target.type).toBe("main"); + expect(typeof target.index).toBe("number"); + } + } + } +} + +function hasNodeType(wf: N8nWorkflow, type: string): boolean { + return wf.nodes.some((n) => n.type === `n8n-nodes-base.${type}`); +} + +function hasNodeName(wf: N8nWorkflow, name: string): boolean { + return wf.nodes.some((n) => n.name === name); +} + +// ── Workflow 1: Vendor Sync ──────────────────────────────────────────────── + +describe("Workflow 1: Vendor Sync", () => { + const wf = generateVendorSyncWorkflow(); + + it("generates valid n8n workflow structure", () => { + validateWorkflow(wf); + }); + + it("has correct name", () => { + expect(wf.name).toContain("Vendor"); + expect(wf.name).toContain("Sync"); + }); + + it("has Schedule Trigger", () => { + expect(hasNodeType(wf, "scheduleTrigger")).toBe(true); + }); + + it("has Google Sheets read node", () => { + expect(hasNodeType(wf, "googleSheets")).toBe(true); + }); + + it("has Code node for diff", () => { + expect(hasNodeName(wf, "Compute Diff")).toBe(true); + }); + + it("has HTTP Request for monitor creation", () => { + const httpNodes = wf.nodes.filter((n) => n.type === "n8n-nodes-base.httpRequest"); + expect(httpNodes.length).toBeGreaterThanOrEqual(1); + const createNode = httpNodes.find((n) => n.name === "Create Monitor"); + expect(createNode).toBeDefined(); + expect(createNode!.parameters.method).toBe("POST"); + }); + + it("has HTTP Request for monitor deletion", () => { + const deleteNode = wf.nodes.find((n) => n.name === "Delete Monitor"); + expect(deleteNode).toBeDefined(); + expect(deleteNode!.parameters.method).toBe("DELETE"); + }); +}); + +// ── Workflow 2: Deep Research ────────────────────────────────────────────── + +describe("Workflow 2: Deep Research", () => { + const wf = generateDeepResearchWorkflow(); + + it("generates valid n8n workflow structure", () => { + validateWorkflow(wf); + }); + + it("has Schedule Trigger at 6 AM", () => { + expect(hasNodeType(wf, "scheduleTrigger")).toBe(true); + }); + + it("has HTTP Request to tasks/groups", () => { + const createGroup = wf.nodes.find((n) => n.name === "Create Task Group"); + expect(createGroup).toBeDefined(); + expect(String(createGroup!.parameters.url)).toContain("tasks/groups"); + }); + + it("has Wait node", () => { + expect(hasNodeType(wf, "wait")).toBe(true); + }); + + it("has If node for poll completion", () => { + expect(hasNodeType(wf, "if")).toBe(true); + }); + + it("has poll loop connection (If false → Wait)", () => { + const ifConn = wf.connections["Is Complete?"]; + expect(ifConn).toBeDefined(); + expect(ifConn.main.length).toBeGreaterThanOrEqual(2); + // False path (index 1) should go back to Wait + const falsePath = ifConn.main[1]; + expect(falsePath.some((c) => c.node === "Wait 60s")).toBe(true); + }); + + it("has Execute Workflow for scoring", () => { + expect(hasNodeType(wf, "executeWorkflow")).toBe(true); + }); +}); + +// ── Workflow 3: Risk Scoring ─────────────────────────────────────────────── + +describe("Workflow 3: Risk Scoring", () => { + const wf = generateRiskScoringWorkflow(); + + it("generates valid n8n workflow structure", () => { + validateWorkflow(wf); + }); + + it("has Code node with scoring logic", () => { + const scorer = wf.nodes.find((n) => n.name === "Risk Scorer"); + expect(scorer).toBeDefined(); + const code = String(scorer!.parameters.jsCode); + expect(code).toContain("CRITICAL"); + expect(code).toContain("severity"); + expect(code).toContain("override"); + }); + + it("has Switch node for routing", () => { + expect(hasNodeType(wf, "switch")).toBe(true); + }); + + it("has at least 2 Slack nodes", () => { + const slackNodes = wf.nodes.filter((n) => n.type === "n8n-nodes-base.slack"); + expect(slackNodes.length).toBeGreaterThanOrEqual(2); + }); + + it("has Google Sheets append for audit log", () => { + const sheetsNode = wf.nodes.find((n) => n.name === "Audit Log"); + expect(sheetsNode).toBeDefined(); + expect(sheetsNode!.parameters.operation).toBe("appendOrUpdate"); + }); +}); + +// ── Workflow 4: Monitors ─────────────────────────────────────────────────── + +describe("Workflow 4: Monitors", () => { + const wf = generateMonitorWorkflow(); + + it("generates valid n8n workflow structure", () => { + validateWorkflow(wf); + }); + + it("has Webhook Trigger for events", () => { + expect(hasNodeType(wf, "webhook")).toBe(true); + const webhook = wf.nodes.find( + (n) => n.type === "n8n-nodes-base.webhook" && String(n.parameters.path).includes("monitor-events"), + ); + expect(webhook).toBeDefined(); + }); + + it("has HTTP Request to monitors API", () => { + const createMon = wf.nodes.find((n) => n.name === "Create Monitor"); + expect(createMon).toBeDefined(); + expect(String(createMon!.parameters.url)).toContain("monitors"); + }); + + it("has Execute Workflow for scoring events", () => { + expect(hasNodeType(wf, "executeWorkflow")).toBe(true); + }); + + it("has Execute Workflow Trigger for deploy sub-flow", () => { + expect(hasNodeType(wf, "executeWorkflowTrigger")).toBe(true); + }); +}); + +// ── Workflow 5: Ad-Hoc ───────────────────────────────────────────────────── + +describe("Workflow 5: Ad-Hoc", () => { + const wf = generateAdHocWorkflow(); + + it("generates valid n8n workflow structure", () => { + validateWorkflow(wf); + }); + + it("has 2 Webhook Triggers", () => { + const webhooks = wf.nodes.filter((n) => n.type === "n8n-nodes-base.webhook"); + expect(webhooks.length).toBe(2); + }); + + it("has slash command webhook", () => { + const cmd = wf.nodes.find( + (n) => n.type === "n8n-nodes-base.webhook" && String(n.parameters.path).includes("slack-command"), + ); + expect(cmd).toBeDefined(); + }); + + it("has result callback webhook", () => { + const cb = wf.nodes.find( + (n) => n.type === "n8n-nodes-base.webhook" && String(n.parameters.path).includes("adhoc-result"), + ); + expect(cb).toBeDefined(); + }); + + it("has Slack node for acknowledgment", () => { + expect(hasNodeType(wf, "slack")).toBe(true); + }); + + it("has HTTP Request for task creation", () => { + const taskNode = wf.nodes.find((n) => n.name === "Start Research Task"); + expect(taskNode).toBeDefined(); + expect(String(taskNode!.parameters.url)).toContain("tasks/runs"); + }); +}); diff --git a/typescript-recipes/parallel-n8n-procurement/tsconfig.json b/typescript-recipes/parallel-n8n-procurement/tsconfig.json new file mode 100644 index 0000000..761ae7b --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "nodenext", + "moduleResolution": "nodenext", + "outDir": "./dist", + "rootDir": ".", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/typescript-recipes/parallel-n8n-procurement/vitest.config.ts b/typescript-recipes/parallel-n8n-procurement/vitest.config.ts new file mode 100644 index 0000000..ae8cdad --- /dev/null +++ b/typescript-recipes/parallel-n8n-procurement/vitest.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "vitest/config"; +import path from "path"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + include: ["tests/**/*.test.ts"], + coverage: { + provider: "v8", + include: ["src/**/*.ts"], + exclude: ["src/workflows/**"], + }, + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +}); diff --git a/website/cookbook.json b/website/cookbook.json index ec77f79..1d414e6 100644 --- a/website/cookbook.json +++ b/website/cookbook.json @@ -62,6 +62,18 @@ "imageUrl": null, "tags": ["monitoring", "task", "webhooks", "cloudflare"] }, + { + "slug": "parallel-n8n-procurement", + "popular": false, + "featured": false, + "title": "n8n Vendor Risk Monitoring", + "description": "Procurement workflow that researches vendors, deploys monitors, scores risk, routes Slack alerts, and logs an audit trail.", + "repoUrl": "https://github.com/parallel-web/parallel-cookbook/tree/main/typescript-recipes/parallel-n8n-procurement", + "websiteUrl": "https://github.com/parallel-web/parallel-cookbook/tree/main/typescript-recipes/parallel-n8n-procurement", + "creators": ["parallel-web"], + "imageUrl": null, + "tags": ["n8n", "procurement", "tasks", "monitors"] + }, { "slug": "parallel-supabase-enrichment", "popular": true,