{
  "nodes": [
    {
      "id": "213a081f-853e-4f1a-a72c-cef27817a89b",
      "name": "\ud83d\udccb Overview",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -80,
        -272
      ],
      "parameters": {
        "width": 650,
        "height": 488,
        "content": "Analyze support screenshots with UploadToURL, OpenAI Vision, and Zendesk/Jira\nThe Problem: Large image attachments clog support inboxes and often reach developers without technical context, leading to slow resolution times.\nThe Solution: A support-to-dev pipeline that hosts visual proof via UploadToURL, uses AI Vision to transcribe errors, and syncs the data to Zendesk or Jira.\n\n\u2699\ufe0f How it Works\nWebhook: Receives a screenshot/video (Binary or URL) and Ticket ID.\n\nUploadToURL: Hosts the file instantly and returns a public CDN link.\n\nGPT-4o Vision: Analyzes the image to identify error messages and UI states.\n\nTicket Update: Attaches the link and AI analysis to the relevant Zendesk or Jira ticket.\n\n\ud83d\udd10 Credentials & Setup\nNode: Install n8n-nodes-uploadtourl via Community Nodes.\n\nAPIs: UploadToURL, OpenAI (Vision), and Zendesk/Jira.\n\nVariables: Set ZENDESK_SUBDOMAIN or JIRA_BASE_URL."
      },
      "typeVersion": 1
    },
    {
      "id": "6591ef98-d557-430e-9c61-e5ee20217487",
      "name": "Entry & Upload",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        704,
        -128
      ],
      "parameters": {
        "color": 7,
        "width": 984,
        "height": 727,
        "content": "## \ud83d\udeaa Entry, Validation & Upload\n**Nodes:** Webhook \u2192 Validate & Enrich \u2192 Has Remote URL? \u2192 Upload to URL (\u00d72) \u2192 Extract CDN URL\n\n- Accepts `POST` with screenshot/video + ticket metadata. Supports both `fileUrl` (remote) and binary multipart upload\n- Validates ticket ID format per platform (Zendesk: numeric, Jira: `PROJECT-123` pattern), sanitises all string inputs\n- Detects file type from extension \u2014 allowlist: `png`, `jpg`, `jpeg`, `gif`, `webp`, `mp4`, `mov`; rejects all others with `400`\n- Native **Upload to URL** node handles both paths \u2014 no custom HTTP node needed\n- `Extract CDN URL` normalises the response shape and force-upgrades to HTTPS"
      },
      "typeVersion": 1
    },
    {
      "id": "f74f9f4a-bf0f-4c8d-ace7-e0ad44cd3cdb",
      "name": "AI Vision Analysis",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1712,
        -96
      ],
      "parameters": {
        "color": 7,
        "width": 456,
        "height": 551,
        "content": "## \ud83e\udd16 GPT-4o Vision Analysis\n**Nodes:** GPT-4o Vision \u2192 Parse AI Analysis \u2192 Determine Severity\n\n- Sends the hosted CDN image URL directly to GPT-4o Vision \u2014 no base64 encoding, no file passing\n- Prompt instructs the model to return structured JSON: `errorSummary`, `visibleErrorMessage`, `affectedComponent`, `browserOrOS`, `reproducibilityHint`, `developerNotes`, `suggestedPriority`, `detectedKeywords[]`, and `confidenceScore`\n- `Parse AI Analysis` validates JSON, falls back gracefully if vision confidence is low\n- `Determine Severity` maps AI keywords (`crash`, `500`, `null`, `payment`, `data loss`) to `critical | high | medium | low` \u2014 overrides AI suggestion if hard keywords are detected"
      },
      "typeVersion": 1
    },
    {
      "id": "2dc97cc4-91d7-4af2-8f00-e0cc03aef66c",
      "name": "Platform Routing",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2192,
        -128
      ],
      "parameters": {
        "color": 7,
        "width": 840,
        "height": 695,
        "content": "## \ud83c\udfab Platform Routing \u2014 Zendesk & Jira\n**Nodes:** Route by Platform \u2192 Zendesk Add Comment \u2192 Jira Add Comment \u2192 Jira Attach File\n\n- Switch node routes on `platform` field (`zendesk` or `jira`) \u2014 add more outputs for Linear, GitHub Issues, Freshdesk, etc.\n- **Zendesk:** Posts a rich internal note with inline image embed (`![screenshot](url)`), full AI analysis table, severity badge, and a collapsible developer notes section. Also updates ticket tags with `visual-proof` and the severity label\n- **Jira:** Adds a formatted comment in Jira wiki markup with the CDN image URL, AI breakdown, and affected component. A second API call attaches the raw file to the issue for download"
      },
      "typeVersion": 1
    },
    {
      "id": "e65a544e-9cc2-4938-b420-8f28a7cd5e70",
      "name": "Escalation & Response",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3072,
        -128
      ],
      "parameters": {
        "color": 7,
        "width": 680,
        "height": 744,
        "content": "## \ud83d\udce3 Severity Escalation & Response\n**Nodes:** IF Critical/High? \u2192 Slack Alert \u2192 Build Final Response \u2192 Respond to Webhook\n\n- IF node checks severity \u2014 only `critical` and `high` tickets trigger the Slack alert\n- Slack message includes: ticket ID, customer name, product area, AI error summary, severity badge, CDN image link, and a direct link to the ticket\n- `Build Final Response` assembles the full enriched summary: ticket URL, CDN URL, AI analysis, severity, detected keywords, and agent metadata\n- Returns `200 OK` with the complete ticket enrichment record \u2014 ready to log or forward to a dashboard"
      },
      "typeVersion": 1
    },
    {
      "id": "4e572be6-268a-483e-990d-99f91d7afb1b",
      "name": "Webhook - Receive Screenshot",
      "type": "n8n-nodes-base.webhook",
      "position": [
        704,
        272
      ],
      "parameters": {
        "path": "visual-proof-ticket",
        "options": {
          "allowedOrigins": "*"
        },
        "httpMethod": "POST",
        "responseMode": "responseNode"
      },
      "typeVersion": 2
    },
    {
      "id": "be6cd236-30b0-4564-84e0-a294a9da87fe",
      "name": "Validate & Enrich Payload",
      "type": "n8n-nodes-base.code",
      "position": [
        928,
        272
      ],
      "parameters": {
        "jsCode": "const body = $input.first().json.body || $input.first().json;\n\n// \u2500\u2500 Platform validation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst allowedPlatforms = ['zendesk', 'jira'];\nconst platform = (body.platform || 'zendesk').toLowerCase();\nif (!allowedPlatforms.includes(platform)) {\n  throw new Error(`Invalid platform \"${platform}\". Must be: zendesk | jira`);\n}\n\n// \u2500\u2500 File source check \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nif (!body.fileUrl && !body.filename) {\n  throw new Error('Provide either fileUrl (remote screenshot) or filename (for binary upload).');\n}\n\n// \u2500\u2500 Ticket ID validation per platform \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst rawTicketId = String(body.ticketId || '').trim();\nif (!rawTicketId) throw new Error('ticketId is required.');\n\nlet ticketId = rawTicketId;\nif (platform === 'zendesk') {\n  if (!/^\\d+$/.test(rawTicketId.replace(/^ZD-/i, ''))) {\n    throw new Error('Zendesk ticket ID must be numeric (e.g. 10482 or ZD-10482).');\n  }\n  ticketId = rawTicketId.replace(/^ZD-/i, '');\n}\nif (platform === 'jira') {\n  if (!/^[A-Z]+-\\d+$/.test(rawTicketId.toUpperCase())) {\n    throw new Error('Jira issue key must match pattern PROJECT-123 (e.g. SUP-482).');\n  }\n  ticketId = rawTicketId.toUpperCase();\n}\n\n// \u2500\u2500 Filename & extension allowlist \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst filename = body.filename || body.fileUrl?.split('?')[0].split('/').pop() || 'screenshot.png';\nconst ext = filename.split('.').pop()?.toLowerCase() || 'png';\nconst allowedExts = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'mp4', 'mov'];\nif (!allowedExts.includes(ext)) {\n  throw new Error(`File type .${ext} not allowed. Accepted: ${allowedExts.join(', ')}`);\n}\n\n// \u2500\u2500 MIME map \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst mimeMap = {\n  png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',\n  gif: 'image/gif', webp: 'image/webp',\n  mp4: 'video/mp4', mov: 'video/quicktime'\n};\nconst mimeType = mimeMap[ext] || 'application/octet-stream';\nconst isVideo = ['mp4', 'mov'].includes(ext);\n\n// \u2500\u2500 Sanitise string fields \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst sanitise = (s) => String(s || '').trim().slice(0, 500);\n\n// \u2500\u2500 Structured filename for attachment \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst ts = new Date().toISOString().split('T')[0];\nconst structuredFilename = `${ticketId}_screenshot_${ts}.${ext}`;\n\nreturn [{\n  json: {\n    // Source\n    fileUrl: body.fileUrl || null,\n    filename,\n    structuredFilename,\n    mimeType,\n    isVideo,\n    ext,\n    // Routing\n    platform,\n    ticketId,\n    // People\n    customerName: sanitise(body.customerName),\n    customerEmail: sanitise(body.customerEmail),\n    agentName: sanitise(body.agentName),\n    // Context\n    productArea: sanitise(body.productArea) || 'Unknown',\n    userDescription: sanitise(body.description),\n    // Config\n    notifyDev: body.notifyDev !== false,\n    submittedAt: new Date().toISOString()\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "2dc42ca7-3eb9-41ca-bcad-bf0f89fadb66",
      "name": "Has Remote URL?",
      "type": "n8n-nodes-base.if",
      "position": [
        1152,
        272
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "caseSensitive": false,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cond-fileurl",
              "operator": {
                "type": "string",
                "operation": "notEmpty"
              },
              "leftValue": "={{ $json.fileUrl }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "dcfa2c80-9351-4a88-b3e5-dbd43addbf1f",
      "name": "Upload to URL - Remote",
      "type": "n8n-nodes-uploadtourl.uploadToUrl",
      "position": [
        1360,
        144
      ],
      "parameters": {
        "operation": "uploadFile"
      },
      "credentials": {
        "uploadToUrlApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "691eb389-788e-470c-8a69-2df6efb7bcf7",
      "name": "Upload to URL - Binary",
      "type": "n8n-nodes-uploadtourl.uploadToUrl",
      "position": [
        1360,
        384
      ],
      "parameters": {
        "operation": "uploadFile"
      },
      "credentials": {
        "uploadToUrlApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "f4e80de1-dbdb-4eb8-8732-12dc81e6c3a5",
      "name": "Extract CDN URL",
      "type": "n8n-nodes-base.code",
      "position": [
        1584,
        272
      ],
      "parameters": {
        "jsCode": "const uploadResp = $input.first().json;\nconst meta = $('Validate & Enrich Payload').first().json;\n\nconst cdnUrl =\n  uploadResp.url ||\n  uploadResp.link ||\n  uploadResp.data?.url ||\n  uploadResp.file?.url ||\n  uploadResp.shortUrl;\n\nif (!cdnUrl) {\n  throw new Error('Upload to URL returned no public URL. Raw: ' + JSON.stringify(uploadResp).slice(0, 400));\n}\n\nreturn [{\n  json: {\n    ...meta,\n    cdnUrl: cdnUrl.replace(/^http:\\/\\//, 'https://'),\n    uploadId: uploadResp.id || uploadResp.data?.id || null,\n    fileSizeBytes: uploadResp.size || uploadResp.data?.size || null\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "360a2173-f68d-441c-b7ed-cb7a0a031a32",
      "name": "GPT-4o Vision - Analyse Screenshot",
      "type": "@n8n/n8n-nodes-langchain.openAi",
      "notes": "Passes the CDN URL to GPT-4o Vision. Returns structured error analysis: visible error message, affected component, browser/OS, developer notes, suggested priority, and keywords.",
      "position": [
        1760,
        272
      ],
      "parameters": {
        "modelId": {
          "__rl": true,
          "mode": "list",
          "value": "ft:gpt-3.5-turbo-0125:bar-juice::91x6k9Fc",
          "cachedResultName": "FT:GPT-3.5-TURBO-0125:BAR-JUICE::91X6K9FC"
        },
        "options": {
          "maxTokens": 1000,
          "temperature": 0.3
        },
        "messages": {
          "values": [
            {
              "role": "system",
              "content": "You are a senior software QA engineer and technical support specialist. Analyse the provided screenshot or screen recording URL and produce a structured developer-ready bug report. Return ONLY valid JSON \u2014 no markdown, no preamble."
            },
            {
              "content": "=Analyse this customer support screenshot.\n\nImage/Video URL: {{ $json.cdnUrl }}\nProduct Area: {{ $json.productArea }}\nCustomer Description: {{ $json.userDescription || 'No description provided' }}\nPlatform: {{ $json.platform }}\nTicket ID: {{ $json.ticketId }}\n\nReturn ONLY this JSON:\n{\n  \"errorSummary\": \"One-sentence plain English summary of what the customer is experiencing\",\n  \"visibleErrorMessage\": \"Exact error text visible in the screenshot, or null if none\",\n  \"errorCode\": \"HTTP status code or app error code if visible, or null\",\n  \"affectedComponent\": \"Specific UI component, page, or feature affected\",\n  \"affectedUrl\": \"URL visible in browser address bar if present, or null\",\n  \"browserOrOS\": \"Browser name/version or OS if identifiable from screenshot, or null\",\n  \"reproducibilityHint\": \"What state the UI appears to be in that might help reproduce the bug\",\n  \"developerNotes\": \"Technical observations: console errors visible, network state, loading indicators, broken elements\",\n  \"suggestedPriority\": \"critical|high|medium|low\",\n  \"detectedKeywords\": [\"array\", \"of\", \"technical\", \"keywords\", \"found\"],\n  \"suggestedLabels\": [\"frontend\", \"checkout\", \"etc\"],\n  \"isScreenRecording\": false,\n  \"confidenceScore\": 0.92\n}"
            }
          ]
        }
      },
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.5
    },
    {
      "id": "c962207d-40fd-4def-a363-a7bf6e9873fa",
      "name": "Parse AI Analysis & Compute Severity",
      "type": "n8n-nodes-base.code",
      "position": [
        2032,
        272
      ],
      "parameters": {
        "jsCode": "const aiRaw = $input.first().json;\nconst meta = $('Extract CDN URL').first().json;\n\n// \u2500\u2500 Parse AI JSON \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nlet ai;\ntry {\n  const raw =\n    aiRaw.message?.content ||\n    aiRaw.choices?.[0]?.message?.content ||\n    aiRaw.content ||\n    aiRaw.text;\n  ai = typeof raw === 'string' ? JSON.parse(raw) : raw;\n} catch (e) {\n  throw new Error('Failed to parse GPT-4o Vision JSON: ' + e.message);\n}\n\n// \u2500\u2500 Severity override logic \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Hard keywords always escalate to critical regardless of AI suggestion\nconst criticalKeywords = ['crash', 'data loss', 'payment failed', 'stripe', 'checkout broken', '500', '503', 'null pointer', 'undefined', 'white screen', 'blank page'];\nconst highKeywords = ['404', 'timeout', 'login failed', 'auth', 'permission denied', 'infinite loop', 'spinner'];\n\nconst allText = [\n  ai.errorSummary || '',\n  ai.visibleErrorMessage || '',\n  ai.developerNotes || '',\n  ...(ai.detectedKeywords || [])\n].join(' ').toLowerCase();\n\nlet severity = ai.suggestedPriority || 'medium';\nif (criticalKeywords.some(kw => allText.includes(kw))) severity = 'critical';\nelse if (highKeywords.some(kw => allText.includes(kw)) && severity !== 'critical') severity = 'high';\n\n// \u2500\u2500 Emoji badge for ticket comments \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst severityBadge = { critical: '\ud83d\udd34 CRITICAL', high: '\ud83d\udfe0 HIGH', medium: '\ud83d\udfe1 MEDIUM', low: '\ud83d\udfe2 LOW' };\n\nreturn [{\n  json: {\n    ...meta,\n    // AI analysis\n    errorSummary: ai.errorSummary || 'Unable to determine error from screenshot.',\n    visibleErrorMessage: ai.visibleErrorMessage || null,\n    errorCode: ai.errorCode || null,\n    affectedComponent: ai.affectedComponent || meta.productArea,\n    affectedUrl: ai.affectedUrl || null,\n    browserOrOS: ai.browserOrOS || null,\n    reproducibilityHint: ai.reproducibilityHint || null,\n    developerNotes: ai.developerNotes || null,\n    detectedKeywords: ai.detectedKeywords || [],\n    suggestedLabels: ai.suggestedLabels || [],\n    isScreenRecording: ai.isScreenRecording || false,\n    confidenceScore: ai.confidenceScore || null,\n    // Computed severity\n    severity,\n    severityBadge: severityBadge[severity],\n    // Ticket comment body (pre-built for both platforms)\n    richComment: `## \ud83d\udcf8 Visual Proof Attached\\n\\n**${severityBadge[severity]}** | Ticket: ${meta.ticketId} | Product Area: ${meta.productArea}\\n\\n### \ud83d\udd17 Screenshot\\n![Customer Screenshot](${meta.cdnUrl})\\n[Direct Link](${meta.cdnUrl})\\n\\n### \ud83e\udd16 AI Error Analysis\\n| Field | Value |\\n|---|---|\\n| **Error Summary** | ${ai.errorSummary || 'N/A'} |\\n| **Visible Error** | \\`${ai.visibleErrorMessage || 'None detected'}\\` |\\n| **Error Code** | ${ai.errorCode || 'N/A'} |\\n| **Affected Component** | ${ai.affectedComponent || 'N/A'} |\\n| **Affected URL** | ${ai.affectedUrl || 'N/A'} |\\n| **Browser / OS** | ${ai.browserOrOS || 'Not identified'} |\\n\\n### \ud83d\udee0 Developer Notes\\n${ai.developerNotes || 'No additional technical observations.'}\\n\\n### \ud83d\udd01 Reproducibility\\n${ai.reproducibilityHint || 'Unknown \u2014 see screenshot for UI state.'}\\n\\n**Labels:** ${(ai.suggestedLabels || []).join(', ')} | **Keywords:** ${(ai.detectedKeywords || []).join(', ')}\\n**Filed by:** ${meta.agentName || 'Support System'} | **Customer:** ${meta.customerName} (${meta.customerEmail})`,\n    jiraComment: `h2. \ud83d\udcf8 Visual Proof Attached\\n\\n*${severityBadge[severity]}* | Ticket: ${meta.ticketId}\\n\\n*Screenshot:* [View Image|${meta.cdnUrl}]\\n!${meta.cdnUrl}|thumbnail!\\n\\nh3. AI Error Analysis\\n||Field||Value||\\n|Error Summary|${ai.errorSummary || 'N/A'}|\\n|Visible Error|{{${ai.visibleErrorMessage || 'None'}}}|\\n|Error Code|${ai.errorCode || 'N/A'}|\\n|Affected Component|${ai.affectedComponent || 'N/A'}|\\n|Browser/OS|${ai.browserOrOS || 'N/A'}|\\n\\nh3. Developer Notes\\n${ai.developerNotes || 'No additional observations.'}\\n\\nh3. Reproducibility\\n${ai.reproducibilityHint || 'Unknown.'}\\n\\n*Filed by:* ${meta.agentName || 'Support System'} | *Customer:* ${meta.customerName}`\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "f351e33b-f202-4a95-9914-a822c39f31cb",
      "name": "Route by Platform",
      "type": "n8n-nodes-base.switch",
      "position": [
        2240,
        272
      ],
      "parameters": {
        "rules": {
          "values": [
            {
              "outputKey": "Zendesk",
              "conditions": {
                "options": {
                  "caseSensitive": false,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.platform }}",
                    "rightValue": "zendesk"
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "Jira",
              "conditions": {
                "options": {
                  "caseSensitive": false,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.platform }}",
                    "rightValue": "jira"
                  }
                ]
              },
              "renameOutput": true
            }
          ]
        },
        "options": {
          "fallbackOutput": "extra"
        }
      },
      "typeVersion": 3
    },
    {
      "id": "6d214aa5-6e58-40f2-9b88-32a7ff367aa2",
      "name": "Zendesk - Add Internal Note",
      "type": "n8n-nodes-base.zendesk",
      "notes": "Posts a rich internal note with inline image embed, AI analysis table, developer notes, and reproducibility hints. Tags ticket with severity and visual-proof labels.",
      "position": [
        2464,
        128
      ],
      "parameters": {
        "id": "={{ $json.ticketId }}",
        "operation": "update",
        "updateFields": {
          "tags": "visual-proof,ai-analysed,{{ $json.severity }}"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "5cf40aa1-8d93-4afe-971a-0f4e2e253c2b",
      "name": "Jira - Update Issue Labels",
      "type": "n8n-nodes-base.jira",
      "notes": "Updates Jira issue with AI-generated summary and labels before adding the comment.",
      "position": [
        2464,
        304
      ],
      "parameters": {
        "issueKey": "={{ $json.ticketId }}",
        "operation": "update",
        "updateFields": {
          "labels": "={{ [...$json.suggestedLabels, 'visual-proof', $json.severity].join(',') }}",
          "summary": "={{ $json.errorSummary }}"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "33056cb7-c70b-40c3-803e-e559e46bb6ff",
      "name": "Jira - Add Comment",
      "type": "n8n-nodes-base.jira",
      "notes": "Adds a Jira wiki-markup formatted comment with image thumbnail, AI analysis table, developer notes, and reproducibility hints.",
      "position": [
        2688,
        304
      ],
      "parameters": {
        "resource": "issueComment",
        "operation": "create"
      },
      "typeVersion": 1
    },
    {
      "id": "5e6b35e0-36a3-4c1e-8575-7b052141d34f",
      "name": "Merge Platform Response",
      "type": "n8n-nodes-base.code",
      "position": [
        2912,
        240
      ],
      "parameters": {
        "jsCode": "// Normalise response from both Zendesk and Jira branches\nconst platformResp = $input.first().json;\nconst data = $('Parse AI Analysis & Compute Severity').first().json;\n\nconst isZendesk = data.platform === 'zendesk';\n\n// Build ticket URL\nconst ticketUrl = isZendesk\n  ? `https://${$vars.ZENDESK_SUBDOMAIN || 'your-domain'}.zendesk.com/agent/tickets/${data.ticketId}`\n  : `${$vars.JIRA_BASE_URL || 'https://your-domain.atlassian.net'}/browse/${data.ticketId}`;\n\nreturn [{\n  json: {\n    ...data,\n    ticketUrl,\n    platformCommentId:\n      platformResp.comment?.id ||\n      platformResp.audit?.events?.[0]?.id ||\n      platformResp.id ||\n      null\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "aa43cb6b-3c03-4db2-ae4d-fa32a14ca178",
      "name": "IF Critical or High?",
      "type": "n8n-nodes-base.if",
      "position": [
        3120,
        240
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "caseSensitive": false,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cond-severity",
              "operator": {
                "type": "string",
                "operation": "notEquals"
              },
              "leftValue": "={{ $json.severity }}",
              "rightValue": "medium"
            },
            {
              "id": "cond-notify",
              "operator": {
                "type": "boolean",
                "operation": "true"
              },
              "leftValue": "={{ $json.notifyDev }}",
              "rightValue": true
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "ab65d1aa-8fc7-45c5-941d-cd056a3aff2d",
      "name": "Slack - Escalate to Dev Channel",
      "type": "n8n-nodes-base.slack",
      "notes": "Only fires for critical and high severity tickets when notifyDev is true. Posts to the configured dev channel with full error context, CDN image link, and direct ticket URL.",
      "position": [
        3344,
        128
      ],
      "parameters": {
        "text": "={{ $json.severityBadge }} *New Visual Proof Ticket Escalated*\n\n*Ticket:* <{{ $json.ticketUrl }}|{{ $json.ticketId }}> | *Product Area:* {{ $json.productArea }}\n*Customer:* {{ $json.customerName }} ({{ $json.customerEmail }})\n*Agent:* {{ $json.agentName }}\n\n*Error Summary:* {{ $json.errorSummary }}\n*Visible Error:* `{{ $json.visibleErrorMessage || 'None detected' }}`\n*Affected Component:* {{ $json.affectedComponent }}\n*Browser/OS:* {{ $json.browserOrOS || 'Unknown' }}\n\n:frame_with_picture: <{{ $json.cdnUrl }}|View Screenshot>\n:jira: <{{ $json.ticketUrl }}|Open Ticket>\n\n_Keywords: {{ $json.detectedKeywords.join(', ') }} | AI confidence: {{ Math.round(($json.confidenceScore || 0) * 100) }}%_",
        "otherOptions": {},
        "authentication": "oAuth2"
      },
      "credentials": {
        "slackOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "9569c065-e78b-4dd7-bb55-62a4cc8d860c",
      "name": "Build Final Response",
      "type": "n8n-nodes-base.code",
      "position": [
        3344,
        352
      ],
      "parameters": {
        "jsCode": "const slackResp = $input.first();\nconst data = $('Merge Platform Response').first().json;\n\nreturn [{\n  json: {\n    success: true,\n    message: `Visual proof attached to ${data.platform} ticket ${data.ticketId} with severity ${data.severity}.`,\n    // Ticket\n    platform: data.platform,\n    ticketId: data.ticketId,\n    ticketUrl: data.ticketUrl,\n    platformCommentId: data.platformCommentId,\n    // Asset\n    cdnUrl: data.cdnUrl,\n    structuredFilename: data.structuredFilename,\n    fileSizeBytes: data.fileSizeBytes,\n    isScreenRecording: data.isScreenRecording,\n    // AI Analysis\n    severity: data.severity,\n    severityBadge: data.severityBadge,\n    errorSummary: data.errorSummary,\n    visibleErrorMessage: data.visibleErrorMessage,\n    errorCode: data.errorCode,\n    affectedComponent: data.affectedComponent,\n    affectedUrl: data.affectedUrl,\n    browserOrOS: data.browserOrOS,\n    developerNotes: data.developerNotes,\n    detectedKeywords: data.detectedKeywords,\n    suggestedLabels: data.suggestedLabels,\n    confidenceScore: data.confidenceScore,\n    // Meta\n    agentName: data.agentName,\n    customerName: data.customerName,\n    customerEmail: data.customerEmail,\n    devNotified: data.notifyDev && ['critical', 'high'].includes(data.severity),\n    submittedAt: data.submittedAt,\n    processedAt: new Date().toISOString()\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "cb02125b-2d56-4916-ad75-b85ed5b67a80",
      "name": "Respond to Webhook",
      "type": "n8n-nodes-base.respondToWebhook",
      "position": [
        3568,
        352
      ],
      "parameters": {
        "options": {
          "responseCode": 200,
          "responseHeaders": {
            "entries": [
              {
                "name": "Content-Type",
                "value": "application/json"
              }
            ]
          }
        },
        "respondWith": "json",
        "responseBody": "={{ $json }}"
      },
      "typeVersion": 1.1
    }
  ],
  "connections": {
    "Extract CDN URL": {
      "main": [
        [
          {
            "node": "GPT-4o Vision - Analyse Screenshot",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Has Remote URL?": {
      "main": [
        [
          {
            "node": "Upload to URL - Remote",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Upload to URL - Binary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route by Platform": {
      "main": [
        [
          {
            "node": "Zendesk - Add Internal Note",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Jira - Update Issue Labels",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Jira - Add Comment": {
      "main": [
        [
          {
            "node": "Merge Platform Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Final Response": {
      "main": [
        [
          {
            "node": "Respond to Webhook",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF Critical or High?": {
      "main": [
        [
          {
            "node": "Slack - Escalate to Dev Channel",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build Final Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upload to URL - Binary": {
      "main": [
        [
          {
            "node": "Extract CDN URL",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upload to URL - Remote": {
      "main": [
        [
          {
            "node": "Extract CDN URL",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge Platform Response": {
      "main": [
        [
          {
            "node": "IF Critical or High?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate & Enrich Payload": {
      "main": [
        [
          {
            "node": "Has Remote URL?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Jira - Update Issue Labels": {
      "main": [
        [
          {
            "node": "Jira - Add Comment",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Zendesk - Add Internal Note": {
      "main": [
        [
          {
            "node": "Merge Platform Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Webhook - Receive Screenshot": {
      "main": [
        [
          {
            "node": "Validate & Enrich Payload",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Slack - Escalate to Dev Channel": {
      "main": [
        [
          {
            "node": "Build Final Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "GPT-4o Vision - Analyse Screenshot": {
      "main": [
        [
          {
            "node": "Parse AI Analysis & Compute Severity",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse AI Analysis & Compute Severity": {
      "main": [
        [
          {
            "node": "Route by Platform",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}