{
  "name": "Regulatory change monitor",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [{ "field": "hours", "hoursInterval": 1 }]
        }
      },
      "id": "6a6a6a6a-0001-0000-0000-000000000001",
      "name": "Cron Hourly",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [240, 400]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT feed_id, feed_url, feed_format, last_etag, last_polled_at FROM regulatory_feeds WHERE active = true;"
      },
      "id": "6a6a6a6a-0001-0000-0000-000000000002",
      "name": "List Feeds",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.4,
      "position": [460, 400],
      "credentials": {
        "postgres": {
          "id": "PLACEHOLDER_REG_DB_CRED_ID",
          "name": "Postgres — regulatory feeds + items"
        }
      }
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ $json.feed_url }}",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            { "name": "Accept", "value": "application/rss+xml, application/atom+xml, application/json" },
            { "name": "If-None-Match", "value": "={{ $json.last_etag || '' }}" },
            { "name": "User-Agent", "value": "ooligo-regulatory-monitor/1.0 (legal-ops@firm)" }
          ]
        },
        "options": {
          "response": { "response": { "responseFormat": "text", "neverError": true } },
          "timeout": 30000
        }
      },
      "id": "6a6a6a6a-0001-0000-0000-000000000003",
      "name": "Fetch Feed",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [680, 400],
      "notesInFlow": true,
      "notes": "GET each feed. Use If-None-Match for conditional fetch. Some feeds reject empty If-None-Match; the conditional fallthrough to a 200 is fine."
    },
    {
      "parameters": {
        "jsCode": "// Parse the feed body, dedupe against the regulatory_items table.\n// Returns one item per new entry.\nconst Parser = require('rss-parser');\nconst parser = new Parser({ timeout: 10000 });\n\nconst feedMeta = $('List Feeds').item.json;\nconst body = $input.first().json.body || '';\n\nlet feed;\ntry {\n  feed = await parser.parseString(body);\n} catch (e) {\n  return [{ json: { feed_id: feedMeta.feed_id, status: 'parse_error', error: e.message } }];\n}\n\n// For each item, return a flat record. Dedupe happens against the DB in the next node.\nconst items = (feed.items || []).map(item => ({\n  json: {\n    feed_id: feedMeta.feed_id,\n    item_guid: item.guid || item.link || item.title,\n    title: item.title || '',\n    link: item.link || '',\n    summary: (item.contentSnippet || item.content || '').slice(0, 4000),\n    published_at: item.isoDate || item.pubDate || new Date().toISOString(),\n  }\n}));\n\nreturn items;"
      },
      "id": "6a6a6a6a-0001-0000-0000-000000000004",
      "name": "Parse + Map Items",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [900, 400]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.anthropic.com/v1/messages",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            { "name": "Content-Type", "value": "application/json" },
            { "name": "x-api-key", "value": "={{ $credentials.anthropicApi.apiKey }}" },
            { "name": "anthropic-version", "value": "2023-06-01" }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={\n  \"model\": \"claude-sonnet-4-6\",\n  \"max_tokens\": 800,\n  \"system\": \"You classify regulatory feed items against the firm's exposure profile (loaded as a separate system message). Return ONLY valid JSON with: relevant_to_profile (boolean), jurisdictions_implicated (array of jurisdiction codes from the profile), regulatory_areas (array of areas from the profile), urgency (low|medium|high based on rules in the profile), summary (1-3 sentence neutral summary, NOT legal advice), recommended_owner (counsel role from the profile). Do NOT return prose; only JSON.\",\n  \"messages\": [\n    { \"role\": \"user\", \"content\": \"Item title: {{ $json.title }}\\nItem link: {{ $json.link }}\\nItem summary: {{ $json.summary }}\\nFeed source: {{ $json.feed_id }}\\nPublished: {{ $json.published_at }}\" }\n  ]\n}",
        "options": {
          "response": { "response": { "responseFormat": "json" } },
          "timeout": 30000
        }
      },
      "id": "6a6a6a6a-0001-0000-0000-000000000005",
      "name": "Classify Item",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [1120, 400],
      "credentials": {
        "anthropicApi": { "id": "PLACEHOLDER_ANTHROPIC_CRED_ID", "name": "Anthropic API key" }
      }
    },
    {
      "parameters": {
        "jsCode": "// Parse classification, decide alert vs digest, write to DB.\nconst llm = $input.first().json;\nconst item = $('Parse + Map Items').item.json;\nconst text = llm.content?.[0]?.text || '';\n\nlet cls;\ntry {\n  cls = JSON.parse(text.match(/\\{[\\s\\S]*\\}/)[0]);\n} catch (e) {\n  cls = { relevant_to_profile: false, urgency: 'low', summary: 'Classification failed: ' + e.message, jurisdictions_implicated: [], regulatory_areas: [], recommended_owner: 'unknown' };\n}\n\nconst alertNow = !!cls.relevant_to_profile && cls.urgency === 'high';\n\nreturn [{\n  json: {\n    feed_id: item.feed_id,\n    item_guid: item.item_guid,\n    title: item.title,\n    link: item.link,\n    summary_raw: item.summary,\n    summary_classified: cls.summary,\n    published_at: item.published_at,\n    relevant_to_profile: !!cls.relevant_to_profile,\n    jurisdictions: cls.jurisdictions_implicated || [],\n    regulatory_areas: cls.regulatory_areas || [],\n    urgency: cls.urgency || 'low',\n    recommended_owner: cls.recommended_owner || 'unknown',\n    alert_now: alertNow,\n    classified_at: new Date().toISOString(),\n  }\n}];"
      },
      "id": "6a6a6a6a-0001-0000-0000-000000000006",
      "name": "Decide Alert + Persist",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [1340, 400]
    },
    {
      "parameters": {
        "operation": "insert",
        "schema": "public",
        "table": "regulatory_items",
        "columns": "feed_id, item_guid, title, link, summary_raw, summary_classified, published_at, relevant_to_profile, jurisdictions, regulatory_areas, urgency, recommended_owner, classified_at",
        "additionalFields": { "options": { "queryReplacement": "" } }
      },
      "id": "6a6a6a6a-0001-0000-0000-000000000007",
      "name": "Persist Item",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.4,
      "position": [1560, 320],
      "credentials": {
        "postgres": { "id": "PLACEHOLDER_REG_DB_CRED_ID", "name": "Postgres — regulatory feeds + items" }
      }
    },
    {
      "parameters": {
        "conditions": {
          "options": { "caseSensitive": true, "typeValidation": "strict" },
          "conditions": [
            { "leftValue": "={{ $json.alert_now }}", "rightValue": true, "operator": { "type": "boolean", "operation": "true" } }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "6a6a6a6a-0001-0000-0000-000000000008",
      "name": "Alert Now?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [1560, 480]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://slack.com/api/chat.postMessage",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "slackApi",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={\n  \"channel\": \"#legal-alerts\",\n  \"text\": \":rotating_light: HIGH-URGENCY regulatory item — {{ $json.title }}\",\n  \"blocks\": [\n    { \"type\": \"section\", \"text\": { \"type\": \"mrkdwn\", \"text\": \":rotating_light: *HIGH-URGENCY regulatory item*\\n*{{ $json.title }}*\\n{{ $json.summary_classified }}\\n*Jurisdictions:* {{ $json.jurisdictions.join(', ') }}\\n*Areas:* {{ $json.regulatory_areas.join(', ') }}\\n*Owner:* {{ $json.recommended_owner }}\\n<{{ $json.link }}|Open source>\" } }\n  ]\n}",
        "options": {}
      },
      "id": "6a6a6a6a-0001-0000-0000-000000000009",
      "name": "Slack Immediate Alert",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [1780, 480],
      "credentials": {
        "slackApi": { "id": "PLACEHOLDER_SLACK_CRED_ID", "name": "Slack bot token (chat:write)" }
      }
    }
  ],
  "connections": {
    "Cron Hourly": { "main": [[{ "node": "List Feeds", "type": "main", "index": 0 }]] },
    "List Feeds": { "main": [[{ "node": "Fetch Feed", "type": "main", "index": 0 }]] },
    "Fetch Feed": { "main": [[{ "node": "Parse + Map Items", "type": "main", "index": 0 }]] },
    "Parse + Map Items": { "main": [[{ "node": "Classify Item", "type": "main", "index": 0 }]] },
    "Classify Item": { "main": [[{ "node": "Decide Alert + Persist", "type": "main", "index": 0 }]] },
    "Decide Alert + Persist": {
      "main": [
        [
          { "node": "Persist Item", "type": "main", "index": 0 },
          { "node": "Alert Now?", "type": "main", "index": 0 }
        ]
      ]
    },
    "Alert Now?": {
      "main": [
        [{ "node": "Slack Immediate Alert", "type": "main", "index": 0 }],
        []
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "timezone": "America/New_York",
    "saveExecutionProgress": true,
    "saveManualExecutions": true
  },
  "active": false,
  "versionId": "1"
}
