AutomationFlowsAI & RAG › Monitor Live Event Sentiment and Send Alerts with Fanbase Mcp, Gemini, and Slack

Monitor Live Event Sentiment and Send Alerts with Fanbase Mcp, Gemini, and Slack

Bysebastian @zebz on n8n.io

This workflow monitors a time-boxed event by pulling live activity across your social media from Fanbase MCP every 15 minutes, analyzing each interval with Google Gemini, storing results in n8n Data Tables, and posting rolling reports plus urgent alerts and a final post-event…

Event trigger★★★★★ complexityAI-powered33 nodesForm TriggerGoogle GeminiSlackData TableMcp Client
AI & RAG Trigger: Event Nodes: 33 Complexity: ★★★★★ AI nodes: yes Added:

This workflow corresponds to n8n.io template #17848 — we link there as the canonical source.

This workflow follows the Form Trigger → Googlegemini recipe pattern — see all workflows that pair these two integrations.

The workflow JSON

Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →

Download .json
{
  "id": "sUdQQ4X39QktCAxE",
  "name": "Event Monitor \u2014 Live Rolling Report(template)",
  "tags": [
    {
      "id": "99XXWEkL8LBSb2hs",
      "name": "Template",
      "createdAt": "2026-08-05T09:07:12.397Z",
      "updatedAt": "2026-08-05T09:07:12.397Z"
    },
    {
      "id": "FzwpMZOUmXg61Fvr",
      "name": "Monitor 1/2",
      "createdAt": "2026-08-04T13:16:36.236Z",
      "updatedAt": "2026-08-04T13:16:36.236Z"
    },
    {
      "id": "LY5qTFTCHFLiOFlU",
      "name": "Fanbase MCP",
      "createdAt": "2026-07-31T13:56:11.467Z",
      "updatedAt": "2026-07-31T13:56:11.467Z"
    }
  ],
  "nodes": [
    {
      "id": "e8ebf0ce-e876-4e8b-a551-ab554af431c7",
      "name": "Form: Configure Event",
      "type": "n8n-nodes-base.formTrigger",
      "position": [
        784,
        336
      ],
      "parameters": {
        "options": {},
        "formTitle": "Configure Event Monitor",
        "formFields": {
          "values": [
            {
              "fieldLabel": "Event Name",
              "placeholder": "Spring Drop Launch",
              "requiredField": true
            },
            {
              "fieldType": "date",
              "fieldLabel": "Monitoring Start",
              "requiredField": true
            },
            {
              "fieldLabel": "Start Time",
              "placeholder": "18:00",
              "requiredField": true
            },
            {
              "fieldType": "date",
              "fieldLabel": "Monitoring End",
              "requiredField": true
            },
            {
              "fieldLabel": "End Time",
              "placeholder": "23:00",
              "requiredField": true
            },
            {
              "fieldType": "textarea",
              "fieldLabel": "Keywords",
              "placeholder": "spring drop, restock, hoodie, launch",
              "requiredField": true
            }
          ]
        },
        "formDescription": "Starts a temporary high-frequency monitor. All times are UTC, 24-hour clock."
      },
      "typeVersion": 2.2
    },
    {
      "id": "acbbd08b-59b3-41bb-8d65-4420371cfb23",
      "name": "Validate Config",
      "type": "n8n-nodes-base.code",
      "position": [
        1008,
        336
      ],
      "parameters": {
        "jsCode": "\nconst f = $input.first().json;\n\nfunction pick(names) {\n  for (const n of names) {\n    if (f[n] !== undefined && f[n] !== null && String(f[n]).trim() !== '') return String(f[n]).trim();\n  }\n  return '';\n}\n\n// The form uses a native date picker (date-only: n8n has no datetime field type) plus a\n// separate HH:MM time field. Recombine them into one UTC instant.\nfunction combine(label, dateNames, timeNames) {\n  const d = pick(dateNames);\n  if (!d) return '';\n  if (d.length > 10) return d;            // already a full ISO datetime -> pass through\n  const raw = pick(timeNames) || '00:00';\n  const m = /^(\\d{1,2}):(\\d{2})$/.exec(raw);\n  if (!m) throw new Error(label + ' time must be HH:MM in 24-hour format (e.g. 18:00), got \"' + raw + '\"');\n  const hh = Number(m[1]), mm = Number(m[2]);\n  if (hh > 23 || mm > 59) throw new Error(label + ' time must be a valid 24-hour time, got \"' + raw + '\"');\n  const pad = n => (n < 10 ? '0' : '') + n;\n  return d + 'T' + pad(hh) + ':' + pad(mm) + ':00Z';\n}\n\nconst eventName = pick(['Event Name', 'event_name']);\nconst startRaw  = combine('Monitoring Start', ['Monitoring Start', 'start_time'], ['Start Time', 'start_time_of_day']);\nconst endRaw    = combine('Monitoring End',   ['Monitoring End', 'end_time'],     ['End Time', 'end_time_of_day']);\nconst kwRaw     = pick(['Keywords', 'keywords']);\n\nif (!eventName) throw new Error('Event Name is required');\n\nconst start = new Date(startRaw);\nconst end   = new Date(endRaw);\nif (isNaN(start.getTime())) throw new Error('Monitoring Start is not a valid date/time: \"' + startRaw + '\"');\nif (isNaN(end.getTime()))   throw new Error('Monitoring End is not a valid date/time: \"' + endRaw + '\"');\nif (end <= start) throw new Error('Monitoring End (' + end.toISOString() + ') must be after Monitoring Start (' + start.toISOString() + '). If both dates are the same day, set the times accordingly.');\n\nconst keywords = kwRaw.split(',').map(s => s.trim()).filter(Boolean);\nif (!keywords.length) throw new Error('At least one keyword is required');\n\nreturn [{ json: {\n  event_name: eventName,\n  keywords: keywords.join(','),\n  start_time: start.toISOString(),\n  end_time: end.toISOString(),\n  status: 'active',\n  interval_index: 0,\n  last_run_at: start.toISOString(),\n  prev_metrics_json: '',\n  created_at: new Date().toISOString(),\n} }];\n"
      },
      "typeVersion": 2
    },
    {
      "id": "e900617a-18aa-461e-be15-fa19c30339e1",
      "name": "Every 15 Minutes",
      "type": "n8n-nodes-base.scheduleTrigger",
      "disabled": true,
      "position": [
        704,
        688
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "minutes",
              "minutesInterval": 15
            }
          ]
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "84b10fef-3b03-450a-b20b-d00768fdb323",
      "name": "Window Gate",
      "type": "n8n-nodes-base.code",
      "position": [
        1216,
        688
      ],
      "parameters": {
        "jsCode": "\n// Decides, PER ACTIVE EVENT, whether this 15-min tick should collect an interval, finalise,\n// or do nothing. Receives every active config row from event_monitor_config and emits ONE item\n// per event that has work to do; \"Loop Over Events\" then feeds them downstream one at a time.\nconst items = $input.all().filter(i => i.json && i.json.event_name);\nif (!items.length) return [];              // no active event -> stop the branch\n\nconst now = new Date();\nconst out = [];\nconst seenNames = new Set();\n\nfor (const it of items) {\n  const cfg = it.json;\n\n  // Two active rows sharing a name would both match the event_name+status filter used by\n  // \"Advance Event Config\" / \"Complete Event\" and corrupt each other's interval_index.\n  const nameKey = String(cfg.event_name).trim().toLowerCase();\n  if (seenNames.has(nameKey)) continue;\n  seenNames.add(nameKey);\n\n  const start = new Date(cfg.start_time);\n  const end = new Date(cfg.end_time);\n  if (isNaN(start.getTime()) || isNaN(end.getTime())) {\n    throw new Error('Active config \"' + cfg.event_name + '\" has invalid start/end: ' +\n      cfg.start_time + ' / ' + cfg.end_time);\n  }\n\n  if (now < start) continue;               // window has not opened yet\n\n  const lastRun = cfg.last_run_at ? new Date(cfg.last_run_at) : start;\n  const from = isNaN(lastRun.getTime()) ? start : lastRun;\n\n  const keywords_list = String(cfg.keywords || '').split(',').map(s => s.trim()).filter(Boolean);\n\n  if (now >= end) {\n    // Window closed -> emit a finalise item so the post-event report runs once.\n    out.push({ json: Object.assign({}, cfg, {\n      phase: 'finalize',\n      keywords_list,\n      window_start: from.toISOString(),\n      window_end: end.toISOString(),\n    }) });\n    continue;\n  }\n\n  out.push({ json: Object.assign({}, cfg, {\n    phase: 'interval',\n    keywords_list,\n    interval_index: Number(cfg.interval_index || 0) + 1,\n    window_start: from.toISOString(),\n    window_end: now.toISOString(),\n  }) });\n}\n\nreturn out;\n"
      },
      "typeVersion": 2
    },
    {
      "id": "e88d04a8-6748-4413-9c98-b088db3c5279",
      "name": "In Monitoring Window?",
      "type": "n8n-nodes-base.if",
      "position": [
        2000,
        688
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "route-phase-c1",
              "operator": {
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "={{ $json.phase }}",
              "rightValue": "interval"
            }
          ]
        },
        "looseTypeValidation": true
      },
      "typeVersion": 2.2
    },
    {
      "id": "73cb977e-59f2-4aee-b55f-9cf1de377b34",
      "name": "Collapse To One Item",
      "type": "n8n-nodes-base.code",
      "position": [
        2464,
        592
      ],
      "parameters": {
        "jsCode": "\n// Collapses the seen-records lookup (N rows, or 1 empty item) back to exactly ONE item\n// so the MCP node downstream runs once, not once per row.\n// Config comes from the LOOP, not from Window Gate: Window Gate runs once and emits one item\n// per active event, so $('Window Gate').first() would pin every iteration to event #1.\nconst cfg = $('Loop Over Events').first().json;\nconst seen_ids = [];\nfor (const it of $input.all()) {\n  const r = it.json && it.json.record_id;\n  if (r) seen_ids.push(String(r));\n}\nreturn [{ json: Object.assign({}, cfg, { seen_ids }) }];\n"
      },
      "typeVersion": 2,
      "alwaysOutputData": true
    },
    {
      "id": "9c388867-f99a-4712-8923-23d560bc1720",
      "name": "Filter To Interval Window",
      "type": "n8n-nodes-base.code",
      "position": [
        2912,
        592
      ],
      "parameters": {
        "jsCode": "\n// Hard date filter for the interval.\n// The MCP call also passes after/before, but the tool's own filtering cannot be relied on\n// (its parameter surface is server-defined and the node's mapper may not send them), so the\n// authoritative window filter lives HERE.\n// Upper bound is EXCLUSIVE: the next interval's `after` equals this `before`, so an event\n// landing exactly on the boundary belongs to the next interval, never both.\nconst ctx = $('Collapse To One Item').first().json;\nconst raw = $input.first().json;\n\nlet payload = (raw && raw.content && raw.content[0]) ? raw.content[0].text : raw;\nif (typeof payload === 'string') { try { payload = JSON.parse(payload); } catch (e) { payload = null; } }\nconst all = (payload && Array.isArray(payload.events)) ? payload.events : [];\nconst apiTotal = (payload && typeof payload.total === 'number') ? payload.total : all.length;\n// The API reports `total` but returns at most one page (~25) and exposes no `hasMore`, so\n// truncation has to be inferred from the count itself.\nconst apiTruncated = apiTotal > all.length;\n// Needed to build the FanBase inbox fallback link without hardcoding the org.\nconst organization_id = (payload && payload.organizationId) ? String(payload.organizationId) : '';\n\nconst from = new Date(ctx.window_start).getTime();\nconst to = new Date(ctx.window_end).getTime();\nconst validWindow = Number.isFinite(from) && Number.isFinite(to);\n\nfunction stampOf(ev) {\n  const s = (ev && ev.metadata && ev.metadata.timestamp) || (ev && ev.createdAt) || '';\n  const t = new Date(s).getTime();\n  return Number.isFinite(t) ? t : null;\n}\n\nconst events = [];\nlet dropped_before = 0, dropped_after = 0, undated = 0;\n\nfor (const ev of all) {\n  const t = stampOf(ev);\n  if (t === null) { undated++; events.push(ev); continue; }   // keep undated: id dedup still protects us\n  if (!validWindow) { events.push(ev); continue; }\n  if (t < from) { dropped_before++; continue; }\n  if (t >= to)  { dropped_after++; continue; }\n  events.push(ev);\n}\n\nreturn [{ json: Object.assign({}, ctx, {\n  events,\n  fetched_events: all.length,\n  in_window_events: events.length,\n  dropped_before,\n  dropped_after,\n  undated,\n  api_total: apiTotal,\n  api_truncated: apiTruncated,\n  organization_id,\n}) }];\n"
      },
      "typeVersion": 2
    },
    {
      "id": "92edc48a-7961-4384-893f-a1557c5af698",
      "name": "Build Interval Batch",
      "type": "n8n-nodes-base.code",
      "position": [
        3120,
        592
      ],
      "parameters": {
        "jsCode": "\n// Removes already-seen records, applies the keyword relevance filter, and emits exactly ONE\n// item of interval metrics (even at zero volume). Events arrive already parsed and\n// window-filtered by \"Filter To Interval Window\".\nconst ctx = $input.first().json;\nconst events = Array.isArray(ctx.events) ? ctx.events : [];\nconst apiTotal = Number(ctx.api_total || events.length);\nconst apiTruncated = !!ctx.api_truncated;\nconst orgId = String(ctx.organization_id || '');\n\nconst keywords = Array.isArray(ctx.keywords_list) && ctx.keywords_list.length\n  ? ctx.keywords_list\n  : String(ctx.keywords || '').split(',').map(s => s.trim()).filter(Boolean);\n\n// Word-boundary matcher: avoids the substring false positives that plague naive includes()\n// (e.g. \"order\" inside \"border\", \"how\" inside \"however\").\nfunction boundary(kw) {\n  const esc = String(kw).replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n  try { return new RegExp('(?<![\\\\p{L}\\\\p{N}])' + esc + '(?![\\\\p{L}\\\\p{N}])', 'iu'); }\n  catch (e) { return new RegExp(esc, 'i'); }\n}\n\nconst kwRes = keywords.map(k => ({ kw: k, re: boundary(k) }));\n\nconst INTENT = ['buy','buying','purchase','purchasing','order','preorder','pre-order','price','pricing',\n  'cost','checkout','payment','how much','where can i buy','where can i get','in stock','restock',\n  'sold out','discount','promo code','sign up','subscribe','upgrade'];\nconst COMPLAINT = ['refund','broken','not working','error','bug','crash','crashed','delayed','delay',\n  'late','missing','damaged','scam','ripoff','terrible','awful','horrible','worst','cancel',\n  'chargeback','unacceptable','disappointed','never arrived','poor quality','overcharged'];\nconst intentRes = INTENT.map(boundary);\nconst complaintRes = COMPLAINT.map(boundary);\n\n// --- reply links -------------------------------------------------------------------------\n// Mirrors FanBase's own getSocialActivityUrl mapping. A link is only emitted when the id can\n// actually be recovered; anything unrecognised falls back to the FanBase inbox, never a guess.\n// IG DM ids are base64 of \"ig_dm_item:1:IGMessageID:<account>:<thread>:<message>\", carrying\n// Meta's ZA-for-Z padding noise.\nfunction igThreadId(messageId) {\n  if (!messageId) return null;\n  const raw = String(messageId);\n  const cands = raw.indexOf('ZA') === -1 ? [raw] : [raw, raw.replace(/ZA/g, 'Z')];\n  for (const c of cands) {\n    for (let p = 0; p < 4; p++) {\n      let dec = '';\n      try { dec = Buffer.from(c + '='.repeat(p), 'base64').toString('utf8'); } catch (e) { continue; }\n      const m = dec.match(/^ig_dm_item:\\d+:IGMessageID:\\d+:(\\d+):\\d+/);\n      if (m) return m[1];\n    }\n  }\n  return null;\n}\n\nfunction numericTail(s) {\n  const m = String(s || '').match(/(\\d{5,})\\s*$/);\n  return m ? m[1] : null;\n}\n\nfunction replyUrl(ev) {\n  const inbox = orgId ? 'https://copilot.fanbase.gg/o/' + orgId + '/inbox' : '';\n  const platform = String((ev && ev.platform) || '').toLowerCase();\n  const meta = (ev && ev.metadata) || {};\n  if (platform === 'instagram') {\n    const tid = igThreadId(meta.messageId);\n    if (tid) return 'https://www.instagram.com/direct/t/' + tid + '/';\n  } else if (platform === 'twitter' || platform === 'x') {\n    const id = numericTail(meta.messageId) || numericTail(ev && ev.sourceId);\n    if (id) return 'https://x.com/i/status/' + id;\n  }\n  return inbox;\n}\n\n// Slack renders &, < and > as markup \u2014 escape them in quoted fan text.\nfunction slackSafe(s) {\n  return String(s || '').replace(/\\s+/g, ' ').trim()\n    .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');\n}\n\nconst seen = new Set((ctx.seen_ids || []).map(String));\nconst inBatch = new Set();\nconst nowIso = new Date().toISOString();\nconst records = [];\n\nfor (const ev of events) {\n  let id = '';\n  if (ev && ev.id) id = String(ev.id);\n  else if (ev && ev.metadata && ev.metadata.messageId) id = String(ev.metadata.messageId);\n  if (!id || seen.has(id) || inBatch.has(id)) continue;\n  inBatch.add(id);\n\n  const text = (ev.metadata && ev.metadata.text) ? String(ev.metadata.text) : '';\n  const matched = kwRes.filter(k => k.re.test(text)).map(k => k.kw);\n  records.push({\n    record_id: id,\n    type: ev.type || 'unknown',\n    platform: ev.platform || 'unknown',\n    fan_id: (ev.fan && ev.fan.id) || '',\n    fan_name: (ev.fan && ev.fan.name) || '',\n    text,\n    relevant: matched.length > 0,\n    matched_keywords: matched,\n    timestamp: (ev.metadata && ev.metadata.timestamp) || ev.createdAt || '',\n    reply_url: replyUrl(ev),\n    is_complaint: complaintRes.some(re => re.test(text)),\n    is_intent: intentRes.some(re => re.test(text)),\n  });\n}\n\nconst scope = records.filter(r => r.relevant);   // event-relevant only drives the metrics\n\nconst byType = {}, byPlatform = {}, fans = {};\nlet intent = 0, complaints = 0;\nfor (const r of scope) {\n  byType[r.type] = (byType[r.type] || 0) + 1;\n  byPlatform[r.platform] = (byPlatform[r.platform] || 0) + 1;\n  if (r.fan_id) {\n    if (!fans[r.fan_id]) fans[r.fan_id] = { fan_id: r.fan_id, fan_name: r.fan_name, count: 0 };\n    fans[r.fan_id].count++;\n  }\n  if (r.is_intent) intent++;\n  if (r.is_complaint) complaints++;\n}\nconst notable = Object.keys(fans).map(k => fans[k]).sort((a, b) => b.count - a.count).slice(0, 5);\n\nlet prev = null;\nif (ctx.prev_metrics_json) { try { prev = JSON.parse(ctx.prev_metrics_json); } catch (e) { prev = null; } }\n\nconst SAMPLE_CAP = 60;\nconst sample_messages = scope.slice(0, SAMPLE_CAP).map(r => ({\n  t: r.text.slice(0, 400), type: r.type, platform: r.platform, fan: r.fan_name,\n}));\n\n// The shortlist that actually goes in the Slack report: what needs a human reply, first.\nconst ACTIONABLE_CAP = 10;    // shown in the live Slack report\nconst STORED_CAP = 50;        // persisted per interval for `report-detailed`\nconst TEXT_CAP = 180;\nconst rank = r => (r.is_complaint ? 0 : (r.is_intent ? 1 : 2));\nconst stamp = r => { const t = new Date(r.timestamp).getTime(); return Number.isFinite(t) ? t : 0; };\nconst toDisplay = (r) => {\n  const clean = slackSafe(r.text);\n  return {\n    record_id: r.record_id,\n    kind: r.is_complaint ? 'complaint' : (r.is_intent ? 'intent' : 'other'),\n    fan_name: r.fan_name || 'unknown',\n    platform: r.platform,\n    timestamp: r.timestamp,\n    time_label: /^\\d{4}-\\d{2}-\\d{2}T(\\d{2}:\\d{2})/.test(r.timestamp)\n      ? r.timestamp.match(/^\\d{4}-\\d{2}-\\d{2}T(\\d{2}:\\d{2})/)[1] : '',\n    text: clean.length > TEXT_CAP ? clean.slice(0, TEXT_CAP - 1) + '\u2026' : clean,\n    reply_url: r.reply_url,\n  };\n};\n// complaint -> purchase intent -> everything else, newest first inside each bucket\nconst ranked = scope.slice().sort((a, b) => (rank(a) - rank(b)) || (stamp(b) - stamp(a)));\nconst actionable_messages = ranked.slice(0, ACTIONABLE_CAP).map(toDisplay);\n// persisted so `/speak report-detailed` can hand a manager the messages + reply links later\nconst stored_messages = ranked.slice(0, STORED_CAP).map(toDisplay);\n\nreturn [{ json: {\n  event_name: ctx.event_name,\n  keywords,\n  interval_index: Number(ctx.interval_index || 0),\n  window_start: ctx.window_start,\n  window_end: ctx.window_end,\n  interval_label: ctx.window_start + ' -> ' + ctx.window_end,\n  fetched_events: Number(ctx.fetched_events || events.length),\n  in_window_events: events.length,\n  dropped_before: Number(ctx.dropped_before || 0),\n  dropped_after: Number(ctx.dropped_after || 0),\n  undated: Number(ctx.undated || 0),\n  api_total: apiTotal,\n  api_truncated: apiTruncated,\n  organization_id: orgId,\n  // every NEW record gets marked seen, on-topic or not, so it is never re-fetched\n  new_records: records.map(r => ({ record_id: r.record_id, event_name: ctx.event_name, seen_at: nowIso })),\n  message_volume: scope.length,\n  offtopic_count: records.length - scope.length,\n  purchase_intent_count: intent,\n  complaint_count: complaints,\n  by_type: byType,\n  by_platform: byPlatform,\n  notable_creators: notable,\n  sample_messages,\n  sample_truncated: scope.length > SAMPLE_CAP,\n  actionable_messages,\n  actionable_total: scope.length,\n  stored_messages,\n  stored_truncated: scope.length > STORED_CAP,\n  prev,\n} }];\n"
      },
      "typeVersion": 2
    },
    {
      "id": "0bac54d7-606b-4e87-ab2e-446b5f3a44e9",
      "name": "Analyse Interval",
      "type": "@n8n/n8n-nodes-langchain.googleGemini",
      "onError": "continueRegularOutput",
      "position": [
        3312,
        432
      ],
      "parameters": {
        "modelId": {
          "__rl": true,
          "mode": "list",
          "value": "models/gemini-2.5-flash",
          "cachedResultName": "models/gemini-2.5-flash"
        },
        "options": {},
        "messages": {
          "values": [
            {
              "content": "=You are a live-event monitoring analyst. Analyse ONLY the messages provided below.\n\nEVENT: {{ $json.event_name }}\nKEYWORDS: {{ ($json.keywords || []).join(', ') }}\nINTERVAL: {{ $json.interval_label }}\nMESSAGE COUNT: {{ $json.message_volume }}\n\nMESSAGES (JSON array; `t` = text, plus type/platform/fan):\n{{ JSON.stringify($json.sample_messages) }}\n\nReturn ONLY a JSON object, no prose and no code fence, in exactly this shape:\n{\n  \"sentiment\": { \"positive\": 0, \"neutral\": 0, \"negative\": 0 },\n  \"trending_topics\": [],\n  \"emerging_issues\": [],\n  \"recommended_actions\": [],\n  \"safety_fraud_legal\": false,\n  \"safety_fraud_legal_detail\": \"\"\n}\n\nRules:\n- The three sentiment counts MUST sum to exactly MESSAGE COUNT ({{ $json.message_volume }}).\n- If MESSAGE COUNT is 0, return all zeros, empty arrays, and false.\n- trending_topics: up to 5 short noun phrases that recur across messages. Not generic words.\n- emerging_issues: up to 5 concrete OPERATIONAL problems (outage, delivery, payment, access, defect, stock). General negativity is NOT an issue.\n- recommended_actions: up to 4 short imperative actions for the team running this event.\n- safety_fraud_legal: true ONLY for a credible safety, fraud, scam, legal or regulatory concern. Put the reason in safety_fraud_legal_detail."
            }
          ]
        },
        "jsonOutput": true,
        "builtInTools": {}
      },
      "credentials": {
        "googlePalmApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.1
    },
    {
      "id": "e3b3f776-d396-4669-9005-9d16410cfd7d",
      "name": "Compose Interval Metrics",
      "type": "n8n-nodes-base.code",
      "position": [
        3600,
        592
      ],
      "parameters": {
        "jsCode": "\n// Merges the LLM analysis with the deterministic counts, compares against the previous\n// interval, decides the urgent alert, and renders the Slack copy.\nconst batch = $('Build Interval Batch').first().json;\nconst raw = $input.first().json;\n\n// The Gemini node's exact output shape varies (object at root, .content, or a JSON string),\n// so walk the response for the first object that carries a sentiment block.\nfunction findAnalysis(o, depth) {\n  if (!o || depth > 6) return null;\n  if (typeof o === 'string') { try { return findAnalysis(JSON.parse(o), depth + 1); } catch (e) { return null; } }\n  if (typeof o !== 'object') return null;\n  if (o.sentiment && typeof o.sentiment === 'object') return o;\n  if (Array.isArray(o)) { for (const v of o) { const f = findAnalysis(v, depth + 1); if (f) return f; } return null; }\n  for (const k of Object.keys(o)) { const f = findAnalysis(o[k], depth + 1); if (f) return f; }\n  return null;\n}\nconst a = findAnalysis(raw, 0) || {};\nconst llm_parsed = !!(a && a.sentiment);\n\nconst vol = Number(batch.message_volume || 0);\nconst num = v => { const x = Number(v); return Number.isFinite(x) && x >= 0 ? Math.round(x) : 0; };\n\nlet pos = num(a.sentiment && a.sentiment.positive);\nlet neu = num(a.sentiment && a.sentiment.neutral);\nlet neg = num(a.sentiment && a.sentiment.negative);\n\n// Force the sentiment split to reconcile with the real volume.\nif (vol === 0) { pos = 0; neu = 0; neg = 0; }\nelse {\n  const sum = pos + neu + neg;\n  if (sum === 0) { neu = vol; }\n  else if (sum !== vol) {\n    const f = vol / sum;\n    pos = Math.round(pos * f);\n    neg = Math.round(neg * f);\n    neu = Math.max(0, vol - pos - neg);\n  }\n}\n\nconst strArr = (v, cap) => Array.isArray(v) ? v.map(x => String(x).trim()).filter(Boolean).slice(0, cap) : [];\nconst topics = strArr(a.trending_topics, 5);\nconst issues = strArr(a.emerging_issues, 5);\nconst actions = strArr(a.recommended_actions, 4);\nconst sfl = a.safety_fraud_legal === true || a.safety_fraud_legal === 'true';\nconst sflDetail = String(a.safety_fraud_legal_detail || '').trim();\n\nconst prev = batch.prev || null;\nconst prevVol = prev ? Number(prev.message_volume || 0) : 0;\nconst prevNeg = prev && prev.sentiment ? Number(prev.sentiment.negative || 0) : 0;\nconst prevNegShare = prevVol > 0 ? prevNeg / prevVol : 0;\nconst prevComplaints = prev ? Number(prev.complaints || 0) : 0;\nconst prevIssues = prev ? strArr(prev.emerging_issues, 99).map(s => s.toLowerCase()) : [];\n\nconst negShare = vol > 0 ? neg / vol : 0;\nconst volDelta = vol - prevVol;\nconst negShareDelta = Math.round((negShare - prevNegShare) * 1000) / 1000;\nconst newIssues = issues.filter(i => prevIssues.indexOf(i.toLowerCase()) === -1);\nconst complaints = Number(batch.complaint_count || 0);\n\nconst reasons = [];\nif (sfl) reasons.push('Safety / fraud / legal concern flagged' + (sflDetail ? ': ' + sflDetail : ''));\nif (negShareDelta >= 0.15 && neg >= 3) reasons.push('Negative sentiment share up ' + Math.round(negShareDelta * 100) + 'pts (' + neg + '/' + vol + ')');\nif (vol >= 5 && negShare >= 0.5) reasons.push('Negative sentiment is ' + Math.round(negShare * 100) + '% of interval volume');\nif (newIssues.length >= 2) reasons.push(newIssues.length + ' new operational issues: ' + newIssues.join('; '));\nif (complaints >= 3 && complaints >= prevComplaints * 2 && complaints > prevComplaints) reasons.push('Complaints jumped ' + prevComplaints + ' -> ' + complaints);\nif (prevVol > 0 && vol >= prevVol * 3 && vol >= 10) reasons.push('Volume spike ' + prevVol + ' -> ' + vol);\n\nconst urgent = reasons.length > 0;\n\n// Canonical payload \u2014 exactly the requested schema.\nconst payload = {\n  event_name: batch.event_name,\n  interval: batch.interval_label,\n  message_volume: vol,\n  sentiment: { positive: pos, neutral: neu, negative: neg },\n  trending_topics: topics,\n  emerging_issues: issues,\n  high_intent_messages: Number(batch.purchase_intent_count || 0),\n  recommended_actions: actions,\n  urgent_alert: urgent,\n};\n\nconst sign = n => (n > 0 ? '+' : '') + n;\nconst pct = v => (vol > 0 ? Math.round(v / vol * 100) + '%' : '0%');\nconst kv = o => Object.keys(o || {}).map(k => k + ' ' + o[k]).join(' \u00b7 ') || '\u2014';\n\n// The messages themselves \u2014 aggregate counts alone don't tell you what to reply to.\nconst KIND_ICON = { complaint: '\ud83d\udd34', intent: '\ud83d\udcb0', other: '\u26aa' };\nconst msgs = Array.isArray(batch.actionable_messages) ? batch.actionable_messages : [];\nconst actionableTotal = Number(batch.actionable_total || msgs.length);\nfunction msgLine(m) {\n  const head = ' ' + (KIND_ICON[m.kind] || '\u26aa') + ' ' + (m.fan_name || 'unknown') + ' \u00b7 ' + m.platform +\n    (m.time_label ? ' \u00b7 ' + m.time_label : '') + ' \u2014 \"' + m.text + '\"';\n  return m.reply_url ? head + '  <' + m.reply_url + '|Reply \u2192>' : head;\n}\nconst replyBlock = [];\nif (msgs.length) {\n  replyBlock.push('');\n  replyBlock.push('*Needs a reply (' + msgs.length + ' of ' + actionableTotal + '):*');\n  for (const m of msgs) replyBlock.push(msgLine(m));\n  if (actionableTotal > msgs.length) replyBlock.push('_+' + (actionableTotal - msgs.length) + ' more this interval_');\n}\n\nconst lines = [\n  '*' + batch.event_name + '* \u2014 rolling report \u00b7 interval ' + batch.interval_index,\n  '`' + batch.window_start + '` \u2192 `' + batch.window_end + '`',\n  '',\n  '*Volume:* ' + vol + ' relevant (' + sign(volDelta) + ' vs prev)' + (batch.offtopic_count ? ' \u00b7 ' + batch.offtopic_count + ' off-topic filtered' : ''),\n  '*Sentiment:* \ud83d\udfe2 ' + pos + ' (' + pct(pos) + ') \u00b7 \u26aa ' + neu + ' \u00b7 \ud83d\udd34 ' + neg + ' (' + pct(neg) + ') \u00b7 \u0394neg ' + sign(Math.round(negShareDelta * 100)) + 'pts',\n  '*Purchase intent:* ' + payload.high_intent_messages + ' \u00b7 *Complaints:* ' + complaints + ' (prev ' + prevComplaints + ')',\n  ...replyBlock,\n  '',\n  '*Trending:* ' + (topics.length ? topics.join(', ') : '\u2014'),\n  '*Emerging issues:* ' + (issues.length ? issues.join('; ') : '\u2014') + (newIssues.length ? '  _(new: ' + newIssues.join('; ') + ')_' : ''),\n  '*Platforms:* ' + kv(batch.by_platform) + '  \u00b7  *Types:* ' + kv(batch.by_type),\n  '*Notable fans:* ' + ((batch.notable_creators || []).map(f => f.fan_name + ' (' + f.count + ')').join(', ') || '\u2014'),\n  '*Recommended:* ' + (actions.length ? '\\n' + actions.map(x => '  \u2022 ' + x).join('\\n') : '\u2014'),\n];\nif (urgent) lines.splice(2, 0, '\ud83d\udea8 *URGENT ALERT RAISED*');\nif (batch.api_truncated) lines.push('_\u26a0\ufe0f MCP reported ' + batch.api_total + ' records but returned only ' + batch.fetched_events + ' (single page) \u2014 volume is undercounted._');\nconst droppedWindow = Number(batch.dropped_before || 0) + Number(batch.dropped_after || 0);\nif (droppedWindow) lines.push('_\u2139\ufe0f ' + droppedWindow + ' of ' + batch.fetched_events + ' returned records fell outside this interval and were filtered out._');\nif (batch.undated) lines.push('_\u2139\ufe0f ' + batch.undated + ' record(s) had no usable timestamp and were kept._');\nif (batch.sample_truncated) lines.push('_\u26a0\ufe0f Only the first 60 messages were sent for analysis._');\nif (!llm_parsed && vol > 0) lines.push('_\u26a0\ufe0f Could not parse the model response \u2014 sentiment defaulted to neutral._');\n\nconst alertLines = urgent ? [\n  '\ud83d\udea8 *URGENT \u2014 ' + batch.event_name + '*',\n  'Interval ' + batch.interval_index + '  `' + batch.window_start + '` \u2192 `' + batch.window_end + '`',\n  '',\n  ...reasons.map(r => '\u2022 ' + r),\n  '',\n  'Volume ' + vol + ' \u00b7 \ud83d\udd34 ' + neg + ' (' + pct(neg) + ') \u00b7 complaints ' + complaints,\n  ...replyBlock,\n  actions.length ? '*Recommended:* ' + actions.join('; ') : '',\n] : [];\n\nreturn [{ json: Object.assign({}, batch, payload, {\n  complaints,\n  urgent_reasons: reasons,\n  new_issues: newIssues,\n  volume_delta: volDelta,\n  negative_share_delta: negShareDelta,\n  llm_parsed,\n  payload_json: JSON.stringify(payload),\n  metrics_snapshot_json: JSON.stringify(Object.assign({}, payload, { complaints })),\n  report_text: lines.join('\\n'),\n  alert_text: alertLines.join('\\n'),\n}) }];\n"
      },
      "typeVersion": 2
    },
    {
      "id": "2e020c54-ae44-4e5c-8e3b-f86fed99e717",
      "name": "Send Rolling Report",
      "type": "n8n-nodes-base.slack",
      "position": [
        4256,
        480
      ],
      "parameters": {
        "text": "={{ $('Compose Interval Metrics').first().json.report_text }}",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "report"
        },
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "typeVersion": 2.4
    },
    {
      "id": "6ff3376c-4d76-4b03-8dbe-317cde9fe449",
      "name": "Urgent Alert?",
      "type": "n8n-nodes-base.if",
      "position": [
        4464,
        592
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "route-urgent-c1",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ $('Compose Interval Metrics').first().json.urgent_alert }}",
              "rightValue": ""
            }
          ]
        },
        "looseTypeValidation": true
      },
      "typeVersion": 2.2
    },
    {
      "id": "33c8757a-7d1d-4810-a429-800789ab8949",
      "name": "Send Urgent Alert",
      "type": "n8n-nodes-base.slack",
      "position": [
        4656,
        496
      ],
      "parameters": {
        "text": "={{ $('Compose Interval Metrics').first().json.alert_text }}",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "report"
        },
        "otherOptions": {}
      },
      "typeVersion": 2.4
    },
    {
      "id": "3c553c4e-6f7d-46c2-9308-d6043b382325",
      "name": "Split New Records",
      "type": "n8n-nodes-base.splitOut",
      "position": [
        3792,
        784
      ],
      "parameters": {
        "options": {},
        "fieldToSplitOut": "new_records"
      },
      "typeVersion": 1
    },
    {
      "id": "e42d1477-5803-4326-a91c-42281bda0ea7",
      "name": "Build Post-Event Report",
      "type": "n8n-nodes-base.code",
      "position": [
        2464,
        848
      ],
      "parameters": {
        "jsCode": "\n// Aggregates every stored interval into the post-event report.\n// Config comes from the LOOP, not from Window Gate: Window Gate emits one item per active event,\n// so $('Window Gate').first() would pin every iteration to event #1.\nconst cfg = $('Loop Over Events').first().json;\nconst rows = $input.all().map(i => i.json)\n  .filter(r => r && r.event_name && r.interval_index !== undefined && r.event_name === cfg.event_name);\nrows.sort((a, b) => Number(a.interval_index) - Number(b.interval_index));\n\nfunction jarr(v) {\n  if (Array.isArray(v)) return v;\n  if (typeof v === 'string' && v.trim()) { try { const p = JSON.parse(v); return Array.isArray(p) ? p : []; } catch (e) { return []; } }\n  return [];\n}\n\nlet vol = 0, pos = 0, neu = 0, neg = 0, intent = 0, comp = 0, alerts = 0;\nconst topicC = {}, issueC = {};\nlet peak = null;\n\nfor (const r of rows) {\n  const v = Number(r.message_volume || 0);\n  vol += v; pos += Number(r.sentiment_positive || 0); neu += Number(r.sentiment_neutral || 0);\n  neg += Number(r.sentiment_negative || 0);\n  intent += Number(r.high_intent_messages || 0); comp += Number(r.complaints || 0);\n  if (r.urgent_alert === true || r.urgent_alert === 'true') alerts++;\n  for (const t of jarr(r.trending_topics)) topicC[t] = (topicC[t] || 0) + 1;\n  for (const t of jarr(r.emerging_issues)) issueC[t] = (issueC[t] || 0) + 1;\n  if (!peak || v > Number(peak.message_volume || 0)) peak = r;\n}\n\nconst rank = o => Object.keys(o).map(k => [k, o[k]]).sort((a, b) => b[1] - a[1]).slice(0, 8);\nconst pct = v => (vol > 0 ? Math.round(v / vol * 100) + '%' : '0%');\n\nconst summary = {\n  event_name: cfg.event_name,\n  intervals: rows.length,\n  window: cfg.start_time + ' -> ' + cfg.end_time,\n  total_volume: vol,\n  sentiment: { positive: pos, neutral: neu, negative: neg },\n  total_high_intent: intent,\n  total_complaints: comp,\n  urgent_intervals: alerts,\n  top_topics: rank(topicC),\n  top_issues: rank(issueC),\n  peak_interval: peak ? { interval: peak.interval, volume: Number(peak.message_volume || 0) } : null,\n};\n\nconst lines = [\n  '\ud83c\udfc1 *' + cfg.event_name + '* \u2014 POST-EVENT REPORT',\n  '`' + cfg.start_time + '` \u2192 `' + cfg.end_time + '`',\n  '',\n  '*Intervals captured:* ' + rows.length,\n  '*Total relevant volume:* ' + vol,\n  '*Sentiment:* \ud83d\udfe2 ' + pos + ' (' + pct(pos) + ') \u00b7 \u26aa ' + neu + ' (' + pct(neu) + ') \u00b7 \ud83d\udd34 ' + neg + ' (' + pct(neg) + ')',\n  '*Purchase-intent messages:* ' + intent,\n  '*Complaints:* ' + comp,\n  '*Intervals that raised an urgent alert:* ' + alerts,\n  '',\n  '*Top topics:* ' + (rank(topicC).map(t => t[0] + ' (' + t[1] + ')').join(', ') || '\u2014'),\n  '*Top issues:* ' + (rank(issueC).map(t => t[0] + ' (' + t[1] + ')').join(', ') || '\u2014'),\n  '*Peak interval:* ' + (peak ? peak.interval + ' (' + peak.message_volume + ' msgs)' : '\u2014'),\n  '',\n  '_Monitoring stopped automatically. All ' + rows.length + ' interval rows retained in `event_monitor_intervals`._',\n];\nif (!rows.length) lines.push('_No intervals were recorded for this event._');\n\nreturn [{ json: {\n  event_name: cfg.event_name,\n  summary_json: JSON.stringify(summary),\n  report_text: lines.join('\\n'),\n} }];\n"
      },
      "typeVersion": 2,
      "alwaysOutputData": true
    },
    {
      "id": "5c3997c9-0b21-4310-9e8e-27d2bb4472cd",
      "name": "Send Post-Event Report",
      "type": "n8n-nodes-base.slack",
      "position": [
        2688,
        1056
      ],
      "parameters": {
        "text": "={{ $json.report_text }}",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "report"
        },
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "typeVersion": 2.4
    },
    {
      "id": "66ee8ab9-0b09-46d0-a4ac-809f58304b94",
      "name": "Loop Over Events",
      "type": "n8n-nodes-base.splitInBatches",
      "position": [
        1680,
        672
      ],
      "parameters": {
        "options": {}
      },
      "typeVersion": 3
    },
    {
      "id": "77af7da0-079e-435d-aa72-514b568e2a46",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        640,
        0
      ],
      "parameters": {
        "width": 1040,
        "height": 304,
        "content": "## Start here - Initialize your monitoring events\n\nThis section is to initialize or add events you would like to monitor, events are saved to a datatable\n(which you will need to create)\n\nIn total this workflow uses 3 data tables \n\nevents_config\nevents_seen\nevents_intervals \n\neach data table will have the above names tagged in the node name so you know where to set them up"
      },
      "typeVersion": 1
    },
    {
      "id": "cf1f58f2-4f05-4ceb-884f-07c7a9c932db",
      "name": "Save Event Config(events_config)",
      "type": "n8n-nodes-base.dataTable",
      "position": [
        1216,
        336
      ],
      "parameters": {
        "columns": {
          "value": {
            "status": "={{ $json.status }}",
            "end_time": "={{ $json.end_time }}",
            "keywords": "={{ $json.keywords }}",
            "created_at": "={{ $json.created_at }}",
            "event_name": "={{ $json.event_name }}",
            "start_time": "={{ $json.start_time }}",
            "last_run_at": "={{ $json.last_run_at }}",
            "interval_index": "={{ $json.interval_index }}",
            "prev_metrics_json": "={{ $json.prev_metrics_json }}"
          },
          "schema": [
            {
              "id": "event_name",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "event_name",
              "defaultMatch": false
            },
            {
              "id": "keywords",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "keywords",
              "defaultMatch": false
            },
            {
              "id": "start_time",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "start_time",
              "defaultMatch": false
            },
            {
              "id": "end_time",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "end_time",
              "defaultMatch": false
            },
            {
              "id": "status",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "status",
              "defaultMatch": false
            },
            {
              "id": "interval_index",
              "type": "number",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "interval_index",
              "defaultMatch": false
            },
            {
              "id": "last_run_at",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "last_run_at",
              "defaultMatch": false
            },
            {
              "id": "prev_metrics_json",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "prev_metrics_json",
              "defaultMatch": false
            },
            {
              "id": "created_at",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "created_at",
              "defaultMatch": false
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {},
        "dataTableId": {
          "__rl": true,
          "mode": "list",
          "value": "rkOPRv39TKZHdgQT",
          "cachedResultName": "event_monitor_config"
        }
      },
      "typeVersion": 1.1
    },
    {
      "id": "f950cafe-0ed7-4a4d-86d1-bcfa30c14e54",
      "name": "Get Active Event(events_config)",
      "type": "n8n-nodes-base.dataTable",
      "position": [
        992,
        688
      ],
      "parameters": {
        "filters": {
          "conditions": [
            {
              "keyName": "status",
              "keyValue": "active"
            }
          ]
        },
        "matchType": "allConditions",
        "operation": "get",
        "returnAll": true,
        "dataTableId": {
          "__rl": true,
          "mode": "list",
          "value": "rkOPRv39TKZHdgQT",
          "cachedResultName": "event_monitor_config"
        }
      },
      "typeVersion": 1.1
    },
    {
      "id": "870bbe36-e0dc-48e2-9da6-ecee52c0cdbc",
      "name": "Advance Event Config(events_config)",
      "type": "n8n-nodes-base.dataTable",
      "position": [
        4000,
        592
      ],
      "parameters": {
        "columns": {
          "value": {
            "last_run_at": "={{ $('Compose Interval Metrics').first().json.window_end }}",
            "interval_index": "={{ $('Compose Interval Metrics').first().json.interval_index }}",
            "prev_metrics_json": "={{ $('Compose Interval Metrics').first().json.metrics_snapshot_json }}"
          },
          "schema": [
            {
              "id": "event_name",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "event_name",
              "defaultMatch": false
            },
            {
              "id": "keywords",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "keywords",
              "defaultMatch": false
            },
            {
              "id": "start_time",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "start_time",
              "defaultMatch": false
            },
            {
              "id": "end_time",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "end_time",
              "defaultMatch": false
            },
            {
              "id": "status",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "status",
              "defaultMatch": false
            },
            {
              "id": "interval_index",
              "type": "number",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "interval_index",
              "defaultMatch": false
            },
            {
              "id": "last_run_at",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "last_run_at",
              "defaultMatch": false
            },
            {
              "id": "prev_metrics_json",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "prev_metrics_json",
              "defaultMatch": false
            },
            {
              "id": "created_at",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "created_at",
              "defaultMatch": false
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "filters": {
          "conditions": [
            {
              "keyName": "event_name",
              "keyValue": "={{ $('Compose Interval Metrics').first().json.event_name }}"
            },
            {
              "keyName": "status",
              "keyValue": "active"
            }
          ]
        },
        "options": {},
        "matchType": "allConditions",
        "operation": "update",
        "dataTableId": {
          "__rl": true,
          "mode": "list",
          "value": "rkOPRv39TKZHdgQT",
          "cachedResultName": "event_monitor_config"
        }
      },
      "typeVersion": 1.1,
      "alwaysOutputData": true
    },
    {
      "id": "a67d0b11-4ced-4166-82e2-a1eb707905b5",
      "name": "Complete Event(events_config)",
      "type": "n8n-nodes-base.dataTable",
      "position": [
        2912,
        848
      ],
      "parameters": {
        "columns": {
          "value": {
            "status": "completed",
            "interval_index": 0
          },
          "schema": [
            {
              "id": "event_name",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "event_name",
              "defaultMatch": false
            },
            {
              "id": "keywords",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "keywords",
              "defaultMatch": false
            },
            {
              "id": "start_time",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "start_time",
              "defaultMatch": false
            },
            {
              "id": "end_time",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "end_time",
              "defaultMatch": false
            },
            {
              "id": "status",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "status",
              "defaultMatch": false
            },
            {
              "id": "interval_index",
              "type": "number",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "interval_index",
              "defaultMatch": false
            },
            {
              "id": "last_run_at",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "last_run_at",
              "defaultMatch": false
            },
            {
              "id": "prev_metrics_json",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "prev_metrics_json",
              "defaultMatch": false
            },
            {
              "id": "created_at",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "created_at",
              "defaultMatch": false
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "filters": {
          "conditions": [
            {
              "keyName": "event_name",
              "keyValue": "={{ $('Loop Over Events').first().json.event_name }}"
            },
            {
              "keyName": "status",
              "keyValue": "active"
            }
          ]
        },
        "options": {},
        "matchType": "allConditions",
        "operation": "update",
        "dataTableId": {
          "__rl": true,
          "mode": "list",
          "value": "rkOPRv39TKZHdgQT",
          "cachedResultName": "event_monitor_config"
        }
      },
      "typeVersion": 1.1,
      "alwaysOutputData": true
    },
    {
      "id": "367d1145-331a-475c-bf4a-e40c3c692ad2",
      "name": "Get Seen Records(events_seen)",
      "type": "n8n-nodes-base.dataTable",
      "position": [
        2240,
        592
      ],
      "parameters": {
        "filters": {
          "conditions": [
            {
              "keyName": "event_name",
              "keyValue": "={{ $json.event_name }}"
            }
          ]
        },
        "matchType": "allConditions",
        "operation": "get",
        "returnAll": true,
        "dataTableId": {
          "__rl": true,
          "mode": "list",
          "value": "cAxuUMM38frafgqm",
          "cachedResultUrl": "/projects/vGZu5e0wu2t0XVCM/datatables/cAxuUMM38frafgqm",
          "cachedResultName": "event_monitor_seen"
        }
      },
      "typeVersion": 1.1,
      "alwaysOutputData": true
    },
    {
      "id": "7fa43d8b-f5bd-4cfe-ae93-b753c4885615",
      "name": "Mark Records Seen(events_seen)",
      "type": "n8n-nodes-base.dataTable",
      "position": [
        4000,
        784
      ],
      "parameters": {
        "columns": {
          "value": {
            "seen_at": "={{ $json.seen_at }}",
            "record_id": "={{ $json.record_id }}",
            "event_name": "={{ $json.event_name }}"
          },
          "schema": [
            {
              "id": "record_id",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "record_id",
              "defaultMatch": false
            },
            {
              "id": "event_name",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "event_name",
              "defaultMatch": false
            },
            {
              "id": "seen_at",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "seen_at",
              "defaultMatch": false
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {},
        "dataTableId": {
          "__rl": true,
          "mode": "list",
          "value": "cAxuUMM38frafgqm",
          "cachedResultName": "event_monitor_seen"
        }
      },
      "typeVersion": 1.1
    },
    {
      "id": "2e0fd00a-f0e5-4457-aad3-bfc21671a3e5",
      "name": "Load All Intervals(events_intervals )",
      "type": "n8n-nodes-base.dataTable",
      "position": [
        2240,
        848
      ],
      "parameters": {
        "filters": {
          "conditions": [
            {
              "keyName": "event_name",
              "keyValue": "={{ $json.event_name }}"
            }
          ]
        },
        "matchType": "allConditions",
        "operation": "get",
        "returnAll": true,
        "dataTableId": {
          "__rl": true,
          "mode": "list",
          "value": "wNyhsdeBSjzjKnJn",
          "cachedResultName": "event_monitor_intervals"
        }
      },
      "typeVersion": 1.1,
      "alwaysOutputData": true
    },
    {
      "id": "08a28a45-c54d-4882-baf8-b0f7e04b9c35",
      "name": "Save Interval Metrics(events_intervals )",
      "type": "n8n-nodes-base.dataTable",
      "position": [
        3792,
        592
      ],
      "parameters": {
        "columns": {
          "value": {
            "by_type": "={{ JSON.stringify($json.by_type) }}",
            "interval": "={{ $json.interval }}",
            "complaints": "={{ $json.complaints }}",
            "created_at": "={{ new Date().toISOString() }}",
            "event_name": "={{ $json.event_name }}",
            "window_end": "={{ $json.window_end }}",
            "by_platform": "={{ JSON.stringify($json.by_platform) }}",
            "payload_json": "={{ $json.payload_json }}",
            "urgent_alert": "={{ $json.urgent_alert }}",
            "volume_delta": "={{ $json.volume_delta }}",
            "window_start": "={{ $json.window_start }}",
            "messages_json": "={{ JSON.stringify($json.stored_messages) }}",
            "interval_index": "={{ $json.interval_index }}",
            "message_volume": "={{ $json.message_volume }}",
            "urgent_reasons": "={{ JSON.stringify($json.urgent_reasons) }}",
            "emerging_issues": "={{ JSON.stringify($json.emerging_issues) }}",
            "trending_topics": "={{ JSON.stringify($json.trending_topics) }}",
            "notable_creators": "={{ JSON.stringify($json.notable_creators) }}",
            "sentiment_neutral": "={{ $json.sentiment.neutral }}",
            "sentiment_negative": "={{ $json.sentiment.negative }}",
            "sentiment_positive": "={{ $json.sentiment.positive }}",
            "recommended_actions": "={{ JSON.stringify($json.recommended_actions) }}",
            "high_intent_messages": "={{ $json.high_intent_messages }}",
            "negative_share_delta": "={{ $json.negative_share_delta }}"
          },
          "schema": [
            {
              "id": "event_name",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "event_name",
              "defaultMatch": false
            },
            {
              "id": "interval_index",
              "type": "number",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "interval_index",
              "defaultMatch": false
            },
            {
              "id": "interval",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "interval",
              "defaultMatch": false
            },
            {
              "id": "window_start",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "window_start",
              "defaultMatch": false
            },
            {
              "id": "window_end",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "window_end",
              "defaultMatch": false
            },
            {
              "id": "message_volume",
              "type": "number",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "message_volume",
              "defaultMatch": false
            },
            {
              "id": "sentiment_positive",
              "type": "number",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "sentiment_positive",
              "defaultMatch": false
            },
            {
              "id": "sentiment_neutral",
              "type": "number",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "sentiment_neutral",
              "defaultMatch": false
            },
            {
              "id": "sentiment_negative",
              "type": "number",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "sentiment_negative",
              "defaultMatch": false
            },
            {
              "id": "trending_topics",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "trending_topics",
              "defaultMatch": false
            },
            {
              "id": "emerging_issues",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "emerging_issues",
              "defaultMatch": false
            },
            {
              "id": "high_intent_messages",
              "type": "number",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "high_intent_messages",
 

Credentials you'll need

Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.

Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

About this workflow

This workflow monitors a time-boxed event by pulling live activity across your social media from Fanbase MCP every 15 minutes, analyzing each interval with Google Gemini, storing results in n8n Data Tables, and posting rolling reports plus urgent alerts and a final post-event…

Source: https://n8n.io/workflows/17848/ — original creator credit. Request a take-down →

More AI & RAG workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

AI & RAG

This workflow empowers marketing teams, agencies and solopreneurs to instantly generate on-brand, platform-optimized social media ads — without designers or complex setup. Running performance marketin

Form Trigger, HTTP Request, Slack +1
AI & RAG

Smart Inquiry Responder: AI Drafts & Categorization. Uses formTrigger, googleGemini, googleSheets, slack. Event-driven trigger; 15 nodes.

Form Trigger, Google Gemini, Google Sheets +1
AI & RAG

Generar Resumen Trabajo. Uses formTrigger, googleGemini, readWriteFile, dataTable. Event-driven trigger; 12 nodes.

Form Trigger, Google Gemini, Read Write File +1
AI & RAG

Analyze incoming support requests, classify intent and priority with AI, create Jira tickets, assign the correct team, send Slack notifications, and track SLA deadlines. Supports Gmail, WhatsApp, form

Gmail Trigger, HTTP Request, Form Trigger +4
AI & RAG

Most expense tracker apps (like Money Lover, Spendee, or Wallet) have a common friction point: Data Entry. You have to unlock your phone, find the app, wait for it to load, navigate menus, and manuall

Google Sheets, Google Gemini, Telegram +2