{
  "name": "OpsRelay — Intake & Process",
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "opsrelay/intake",
        "responseMode": "responseNode",
        "options": {
          "rawBody": false
        }
      },
      "id": "b1a0c3d2-0001-4000-8000-000000000001",
      "name": "Inbound Webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        -560,
        300
      ],
      "webhookId": "opsrelay-intake"
    },
    {
      "parameters": {
        "jsCode": "// Channel adapters. Each provider posts its own shape; the service only ever\n// sees the OpsRelay envelope. Adding a provider means adding a branch here and\n// changing nothing downstream.\n//\n// The split between `structured` and `content` is load-bearing: anything placed\n// in `structured` can become a VERIFIED fact, so only put fields the upstream\n// platform actually vouches for there. Never move transcript-derived values up.\n\nconst out = [];\n\nfor (const item of $input.all()) {\n  const body = item.json.body ?? item.json;\n  const provider = (body.provider || $('Inbound Webhook').first().json.headers?.['x-opsrelay-provider'] || 'generic').toLowerCase();\n\n  let envelope;\n\n  if (provider === 'telephony') {\n    envelope = {\n      source: 'call',\n      external_id: body.call_sid || body.id,\n      occurred_at: body.ended_at || body.timestamp,\n      structured: {\n        from_number: body.from,\n        to_number: body.to,\n        direction: body.direction,\n        duration_seconds: body.duration,\n        recording_url: body.recording_url\n      },\n      content: { transcript: body.transcript },\n      metadata: { provider }\n    };\n  } else if (provider === 'email') {\n    envelope = {\n      source: 'email',\n      external_id: body.message_id,\n      occurred_at: body.date,\n      structured: {\n        from_email: body.from_email,\n        from_name: body.from_name,\n        to_email: body.to_email,\n        message_id: body.message_id,\n        thread_id: body.thread_id\n      },\n      content: { subject: body.subject, body: body.text || body.body },\n      metadata: { provider, forwarded_by: body.forwarded_by }\n    };\n  } else if (provider === 'note') {\n    envelope = {\n      source: 'note',\n      external_id: body.note_id || null,\n      occurred_at: body.created_at || new Date().toISOString(),\n      structured: {},\n      content: { note: body.text },\n      metadata: { provider, author: body.author }\n    };\n  } else {\n    // Already-normalised envelope (what fixtures and the test harness send).\n    envelope = body;\n  }\n\n  if (!envelope.source || !envelope.occurred_at) {\n    throw new Error(`cannot normalise payload from provider \"${provider}\": missing source or occurred_at`);\n  }\n\n  out.push({ json: { envelope, run_id: $execution.id } });\n}\n\nreturn out;"
      },
      "id": "b1a0c3d2-0001-4000-8000-000000000002",
      "name": "Normalise To Envelope",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -340,
        300
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $env.OPSRELAY_SERVICE_URL }}/v1/intake/events",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "run_id",
              "value": "={{ $json.run_id }}"
            }
          ]
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "<set this in n8n after import>"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.envelope) }}",
        "options": {
          "timeout": 20000,
          "retry": {
            "retry": {
              "maxTries": 3,
              "waitBetweenTries": 2000
            }
          }
        }
      },
      "id": "b1a0c3d2-0001-4000-8000-000000000003",
      "name": "Claim Idempotency & Persist",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        -120,
        300
      ],
      "notes": "Retries are safe here — that is the entire point of the idempotency key. A retried call returns status=duplicate rather than creating a second event."
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "version": 2
          },
          "conditions": [
            {
              "id": "dup-check",
              "leftValue": "={{ $json.status }}",
              "rightValue": "duplicate",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "b1a0c3d2-0001-4000-8000-000000000004",
      "name": "Already Seen?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        100,
        300
      ]
    },
    {
      "parameters": {},
      "id": "b1a0c3d2-0001-4000-8000-000000000005",
      "name": "Drop Duplicate",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        320,
        180
      ],
      "notes": "No task, no Slack message, no Notion page. The duplicate is already recorded in workflow_logs by the service."
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $env.OPSRELAY_SERVICE_URL }}/v1/pipeline/process",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "<set this in n8n after import>"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ raw_event_id: $json.raw_event_id, run_id: $('Normalise To Envelope').item.json.run_id }) }}",
        "options": {
          "timeout": 180000,
          "retry": {
            "retry": {
              "maxTries": 2,
              "waitBetweenTries": 5000
            }
          }
        }
      },
      "id": "b1a0c3d2-0001-4000-8000-000000000006",
      "name": "Extract, Match, Sync, Gate",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        320,
        400
      ],
      "notes": "Long timeout: this is three model hops plus Notion writes. Re-running a processed event is safe — it re-extracts from the stored payload."
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ ok: true, result: $json }) }}",
        "options": {
          "responseCode": 200
        }
      },
      "id": "b1a0c3d2-0001-4000-8000-000000000007",
      "name": "Respond 200",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        560,
        300
      ]
    }
  ],
  "connections": {
    "Inbound Webhook": {
      "main": [
        [
          {
            "node": "Normalise To Envelope",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalise To Envelope": {
      "main": [
        [
          {
            "node": "Claim Idempotency & Persist",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Claim Idempotency & Persist": {
      "main": [
        [
          {
            "node": "Already Seen?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Already Seen?": {
      "main": [
        [
          {
            "node": "Drop Duplicate",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Extract, Match, Sync, Gate",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Drop Duplicate": {
      "main": [
        [
          {
            "node": "Respond 200",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract, Match, Sync, Gate": {
      "main": [
        [
          {
            "node": "Respond 200",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "meta": {
    "templateCredsSetupCompleted": false
  }
}