{
  "name": "Lumeor | AI customer feedback tagging (Zendesk + Typeform to Supabase)",
  "nodes": [
    {
      "parameters": {
        "content": "## AI customer feedback analysis\nEvery morning the workflow collects yesterday's new Zendesk tickets and Typeform responses, strips quoted replies and signatures, masks emails, phone numbers and order numbers, then has Claude split each comment into separate points and tag each point against **your** taxonomy (theme, sentiment, severity, competitor mention). Code rejects unknown theme IDs and checks that each quote really appears in the comment. Points are upserted into Supabase, and a Slack summary lists counts by theme and any proposed new themes for a person to review.\n\n**Setup**\n1. Run the SQL in the yellow note in the Supabase SQL editor, then add 8 to 15 themes to feedback_taxonomy (theme_id, name, definition, examples).\n2. Credentials: Supabase API (service role key, server-side only), Zendesk API token, Typeform personal token, Anthropic, Slack.\n3. Replace REPLACE_WITH_TYPEFORM_FORM_ID, REPLACE_WITH_SUPABASE_PROJECT_REF, REPLACE_WITH_SUMMARY_CHANNEL and REPLACE_WITH_ALERTS_CHANNEL.\n4. Build your dashboard (Looker Studio or Supabase views) on feedback_points.\n\nMasking runs before any text leaves n8n. Review the patterns in \"Clean and mask comments\" for your data (for example account numbers).\n\nModel: claude-opus-5-5 with effort low. Tagging short comments is high volume and simple, so claude-haiku-5-5 is a cheaper option worth testing against a hand-tagged sample.\n\nTemplate by Lumeor Studio, https://www.lumeor.studio/guides/ai-customer-feedback-analysis/",
        "height": 860,
        "width": 600,
        "color": 7
      },
      "id": "28454535-40d7-455a-b7f2-49f0d8fe1072",
      "name": "About this template",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -80,
        -500
      ]
    },
    {
      "parameters": {
        "content": "### Supabase tables (run once)\n```sql\ncreate table feedback_taxonomy (\n  theme_id text primary key,\n  name text not null,\n  definition text,\n  examples text,\n  active boolean not null default true,\n  version int not null default 1\n);\ncreate table feedback_points (\n  id bigserial primary key,\n  source text not null,\n  source_id text not null,\n  point_index int not null,\n  created_at timestamptz,\n  customer_ref text,\n  theme_id text,\n  subtopic text,\n  sentiment text,\n  severity text,\n  mentions_competitor boolean,\n  quote text,\n  quote_verified boolean,\n  proposed_theme text,\n  taxonomy_version int,\n  tagged_at timestamptz not null default now(),\n  unique (source, source_id, point_index)\n);\n```",
        "height": 860,
        "width": 480,
        "color": 6
      },
      "id": "59cde7d2-209f-43c1-9c21-49314d62313d",
      "name": "Supabase setup",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        580,
        -500
      ]
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 5 * * *"
            }
          ]
        }
      },
      "id": "7dac5b7e-86ab-46cf-a72c-e1f66512e4dd",
      "name": "Daily at 5am",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        300
      ]
    },
    {
      "parameters": {
        "resource": "row",
        "operation": "getAll",
        "tableId": "feedback_taxonomy",
        "returnAll": true,
        "filterType": "string",
        "filterString": "active=eq.true"
      },
      "id": "d0a61bf3-78e9-40e6-bd17-ee3753697200",
      "name": "Get taxonomy",
      "type": "n8n-nodes-base.supabase",
      "typeVersion": 1,
      "position": [
        220,
        300
      ],
      "alwaysOutputData": true
    },
    {
      "parameters": {
        "resource": "ticket",
        "operation": "getAll",
        "returnAll": true,
        "options": {
          "query": "=created>{{ $now.minus({ days: 1 }).toFormat('yyyy-MM-dd') }}",
          "sortBy": "created_at",
          "sortOrder": "asc"
        }
      },
      "id": "d763968a-5a92-4485-97bc-154fea4b7506",
      "name": "Get new Zendesk tickets",
      "type": "n8n-nodes-base.zendesk",
      "typeVersion": 1,
      "position": [
        440,
        300
      ],
      "executeOnce": true,
      "alwaysOutputData": true
    },
    {
      "parameters": {
        "method": "GET",
        "url": "https://api.typeform.com/forms/REPLACE_WITH_TYPEFORM_FORM_ID/responses",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "typeformApi",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "since",
              "value": "={{ $now.minus({ days: 1 }).startOf('day').toISO() }}"
            },
            {
              "name": "completed",
              "value": "true"
            },
            {
              "name": "page_size",
              "value": "1000"
            }
          ]
        },
        "options": {}
      },
      "id": "8637326b-0a3a-40e9-a42e-26e99dac2cb5",
      "name": "Get new Typeform responses",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        660,
        300
      ],
      "executeOnce": true
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Combines both sources, strips quoted replies and signatures, and masks personal data\n// before any text is sent to a model.\nconst MAX_COMMENTS = 200;\nconst clean = (t) => {\n  let s = String(t || '').replace(/\\r/g, '');\n  s = s.split(/\\n(?:On .{0,120}wrote:|-{2,}\\s*Original Message|From: .+\\nSent: )/)[0]; // quoted thread\n  s = s.split(/\\n(?:--\\s*\\n|Sent from my |Best regards|Kind regards|Thanks,\\n)/i)[0]; // signature\n  s = s.split('\\n').filter((l) => !l.trim().startsWith('>')).join('\\n');\n  return s\n    .replace(/[\\w.+-]+@[\\w-]+\\.[\\w.-]+/g, '[email]')\n    .replace(/\\+?\\d[\\d\\s().-]{8,}\\d/g, '[phone]')\n    .replace(/#\\s?\\d{4,}|\\b[A-Z]{2,4}-?\\d{5,}\\b/g, '[order]')\n    .replace(/\\s+/g, ' ')\n    .trim();\n};\nconst out = [];\nfor (const { json: t } of $('Get new Zendesk tickets').all()) {\n  if (!t.id) continue;\n  out.push({ source: 'zendesk', source_id: String(t.id), created_at: t.created_at, customer_ref: t.requester_id ? `zendesk:${t.requester_id}` : '', text: clean(`${t.subject || ''}\\n${t.description || ''}`) });\n}\nfor (const r of $input.first().json.items || []) {\n  const text = (r.answers || [])\n    .filter((a) => a.type === 'text' || a.type === 'long_text')\n    .map((a) => a.text)\n    .join('\\n');\n  const hidden = r.hidden || {};\n  out.push({ source: 'typeform', source_id: String(r.response_id || r.token), created_at: r.submitted_at, customer_ref: hidden.customer_id ? `customer:${hidden.customer_id}` : '', text: clean(text) });\n}\nreturn out\n  .filter((c) => c.text.length >= 15)\n  .slice(0, MAX_COMMENTS)\n  .map((c) => ({ json: { ...c, text: c.text.slice(0, 4000) } }));"
      },
      "id": "84f7058c-4c8f-4c09-a6a6-f147e286d066",
      "name": "Clean and mask comments",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        880,
        300
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.anthropic.com/v1/messages",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "anthropicApi",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "anthropic-version",
              "value": "2023-06-01"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ model: 'claude-opus-5-5', max_tokens: 2000, system: \"You analyze one piece of customer feedback. Split it into separate points (one topic each), then tag each point.\\n- quote: the exact words from the comment that express the point, copied verbatim.\\n- theme_id: one theme_id from the taxonomy provided. If nothing fits, use \\\"other\\\" and put a 2 to 4 word label in proposed_theme; otherwise proposed_theme is an empty string.\\n- sentiment: positive, neutral or negative. severity: low, medium or high (high = blocks the customer, safety, billing errors, threats to leave).\\n- mentions_competitor: true only if a competitor product or company is named.\\nMasked values like [email] or [order] are placeholders; do not guess what they hide.\", output_config: { \"format\": { \"type\": \"json_schema\", \"schema\": { \"type\": \"object\", \"properties\": { \"points\": { \"type\": \"array\", \"items\": { \"type\": \"object\", \"properties\": { \"quote\": { \"type\": \"string\" }, \"theme_id\": { \"type\": \"string\" }, \"subtopic\": { \"type\": \"string\", \"description\": \"Short subtopic label\" }, \"sentiment\": { \"type\": \"string\", \"enum\": [ \"positive\", \"neutral\", \"negative\" ] }, \"severity\": { \"type\": \"string\", \"enum\": [ \"low\", \"medium\", \"high\" ] }, \"mentions_competitor\": { \"type\": \"boolean\" }, \"proposed_theme\": { \"type\": \"string\" } }, \"required\": [ \"quote\", \"theme_id\", \"subtopic\", \"sentiment\", \"severity\", \"mentions_competitor\", \"proposed_theme\" ], \"additionalProperties\": false } } }, \"required\": [ \"points\" ], \"additionalProperties\": false } }, \"effort\": \"low\" }, messages: [ { role: 'user', content: 'Taxonomy (JSON):\\n' + JSON.stringify($('Get taxonomy').all().filter(t => t.json.theme_id).map(t => ({ theme_id: t.json.theme_id, name: t.json.name, definition: t.json.definition, examples: t.json.examples }))) + '\\n\\nComment from ' + $json.source + ':\\n' + $json.text } ] }) }}",
        "options": {
          "timeout": 120000
        }
      },
      "id": "d6f567c0-e36c-4559-a7c8-f426624d7c89",
      "name": "Split and tag with Claude",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1100,
        300
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000,
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// One row per point. Unknown theme IDs become \"other\"; quotes must appear in the masked comment.\nconst tax = $('Get taxonomy').all().map((t) => t.json).filter((t) => t.theme_id);\nconst ids = new Set(tax.map((t) => String(t.theme_id)));\nconst version = Math.max(1, ...tax.map((t) => Number(t.version) || 1));\nconst norm = (s) => String(s).toLowerCase().replace(/[^a-z0-9[\\] ]+/g, ' ').replace(/\\s+/g, ' ').trim();\nconst out = [];\nconst items = $input.all();\nfor (let i = 0; i < items.length; i++) {\n  const res = items[i].json;\n  const c = $('Clean and mask comments').itemMatching(i).json;\n  const fail = (reason) => out.push({ json: { error: reason, details: { source: c.source, source_id: c.source_id } } });\n  if (res.stop_reason === 'refusal' || res.stop_reason === 'max_tokens') { fail(`Claude stopped with stop_reason=${res.stop_reason}`); continue; }\n  // Opus 5.5 may return a thinking block first, so find the text block explicitly.\n  const block = (res.content || []).find((b) => b.type === 'text');\n  if (!block) { fail('Claude response had no text block'); continue; }\n  let d;\n  try { d = JSON.parse(block.text); } catch (e) { fail('Claude returned invalid JSON'); continue; }\n  (d.points || []).slice(0, 10).forEach((p, idx) => {\n    const known = ids.has(String(p.theme_id));\n    const verified = norm(c.text).includes(norm(p.quote)) && norm(p.quote).length > 0;\n    out.push({\n      json: {\n        source: c.source,\n        source_id: c.source_id,\n        point_index: idx,\n        created_at: c.created_at,\n        customer_ref: c.customer_ref,\n        theme_id: known ? String(p.theme_id) : 'other',\n        subtopic: p.subtopic,\n        sentiment: p.sentiment,\n        severity: p.severity,\n        mentions_competitor: Boolean(p.mentions_competitor),\n        quote: verified ? p.quote : '',\n        quote_verified: verified,\n        proposed_theme: known ? '' : p.proposed_theme || String(p.theme_id),\n        taxonomy_version: version,\n      },\n    });\n  });\n}\nreturn out;"
      },
      "id": "21f1d43e-4893-487c-8279-a39c9828bcf2",
      "name": "Validate tags",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1320,
        300
      ],
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://REPLACE_WITH_SUPABASE_PROJECT_REF.supabase.co/rest/v1/feedback_points",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "supabaseApi",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "on_conflict",
              "value": "source,source_id,point_index"
            }
          ]
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Prefer",
              "value": "resolution=merge-duplicates,return=minimal"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($input.all().map(i => i.json)) }}",
        "options": {}
      },
      "id": "a7412618-338e-42cb-808d-e268bc7b116d",
      "name": "Upsert points to Supabase",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1540,
        280
      ],
      "executeOnce": true,
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "REPLACE_WITH_SUMMARY_CHANNEL",
          "mode": "name"
        },
        "text": "={{ (() => { const pts = $('Validate tags').all().map(i => i.json); const counts = {}; pts.forEach(p => { counts[p.theme_id] = (counts[p.theme_id] || 0) + 1; }); const neg = pts.filter(p => p.sentiment === 'negative').length; const high = pts.filter(p => p.severity === 'high').length; const proposed = [...new Set(pts.filter(p => p.proposed_theme).map(p => p.proposed_theme))]; return ':bar_chart: *Customer feedback, last 24 hours*\\n' + pts.length + ' points from ' + new Set(pts.map(p => p.source + p.source_id)).size + ' comments. Negative: ' + neg + '. High severity: ' + high + '.\\n' + Object.entries(counts).sort((a, b) => b[1] - a[1]).map(([k, v]) => '- ' + k + ': ' + v).join('\\n') + (proposed.length ? '\\n\\n*Proposed new themes to review:* ' + proposed.join(', ') : ''); })() }}",
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "id": "a5082975-0460-4ba9-a47e-fede8fb44824",
      "name": "Post daily tagging summary",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        1760,
        260
      ],
      "executeOnce": true
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "REPLACE_WITH_ALERTS_CHANNEL",
          "mode": "name"
        },
        "text": "=:warning: *Feedback tagging needs attention*\n*Reason:* {{ typeof $json.error === 'string' ? $json.error : (($json.error && $json.error.message) || 'Unknown error') }}\n*Details:* {{ JSON.stringify($json.details || $json.error || {}).slice(0, 600) }}\n*Execution:* {{ $execution.id }}",
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "id": "7252d6d3-d65d-4aa6-bc36-68497eebebf1",
      "name": "Alert: feedback tagging needs attention",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        1320,
        540
      ]
    }
  ],
  "connections": {
    "Daily at 5am": {
      "main": [
        [
          {
            "node": "Get taxonomy",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get taxonomy": {
      "main": [
        [
          {
            "node": "Get new Zendesk tickets",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get new Zendesk tickets": {
      "main": [
        [
          {
            "node": "Get new Typeform responses",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get new Typeform responses": {
      "main": [
        [
          {
            "node": "Clean and mask comments",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Clean and mask comments": {
      "main": [
        [
          {
            "node": "Split and tag with Claude",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split and tag with Claude": {
      "main": [
        [
          {
            "node": "Validate tags",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Alert: feedback tagging needs attention",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate tags": {
      "main": [
        [
          {
            "node": "Upsert points to Supabase",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Alert: feedback tagging needs attention",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upsert points to Supabase": {
      "main": [
        [
          {
            "node": "Post daily tagging summary",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Alert: feedback tagging needs attention",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {},
  "meta": {
    "templateCredsSetupCompleted": false
  },
  "tags": []
}
