{
  "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",
              "defaultMatch": false
            },
            {
              "id": "complaints",
              "type": "number",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "complaints",
              "defaultMatch": false
            },
            {
              "id": "recommended_actions",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "recommended_actions",
              "defaultMatch": false
            },
            {
              "id": "urgent_alert",
              "type": "boolean",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "urgent_alert",
              "defaultMatch": false
            },
            {
              "id": "urgent_reasons",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "urgent_reasons",
              "defaultMatch": false
            },
            {
              "id": "notable_creators",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "notable_creators",
              "defaultMatch": false
            },
            {
              "id": "volume_delta",
              "type": "number",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "volume_delta",
              "defaultMatch": false
            },
            {
              "id": "negative_share_delta",
              "type": "number",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "negative_share_delta",
              "defaultMatch": false
            },
            {
              "id": "by_type",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "by_type",
              "defaultMatch": false
            },
            {
              "id": "by_platform",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "by_platform",
              "defaultMatch": false
            },
            {
              "id": "payload_json",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "payload_json",
              "defaultMatch": false
            },
            {
              "id": "created_at",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "created_at",
              "defaultMatch": false
            },
            {
              "id": "messages_json",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "messages_json",
              "defaultMatch": false
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {},
        "dataTableId": {
          "__rl": true,
          "mode": "list",
          "value": "wNyhsdeBSjzjKnJn",
          "cachedResultName": "event_monitor_intervals"
        }
      },
      "typeVersion": 1.1
    },
    {
      "id": "c99108f2-de8e-4184-b4cb-a6c29bde08f2",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        0,
        0
      ],
      "parameters": {
        "color": 5,
        "width": 608,
        "height": 1248,
        "content": "# Event Monitor \u2014 Live Rolling Report\n\nMonitor a live event across social platforms, analyse activity in rolling intervals, and send automated reports and urgent alerts to Slack.\n\n## How It Works\n\n1. Configure the event through the n8n form.\n2. Every 15 minutes, the workflow checks whether the event is within its monitoring window.\n3. New event activity is collected through the **Fanbase MCP**.\n4. Google Gemini analyses engagement, sentiment, notable creators, and emerging trends.\n5. Metrics are stored in n8n Data Tables.\n6. Rolling updates, urgent alerts, and a final post-event report are sent to Slack.\n\n## Requirements\n\n- **Fanbase MCP access is required**\n  - MCP endpoint: `https://api.copilot.fanbase.gg/mcp`\n  - Add your Fanbase MCP OAuth credential to the **Fetch Event Activity** node.\n- Google Gemini API credential.\n- Slack API credential and a destination channel.\n- n8n with Data Tables enabled.\n- Three Data Tables:\n  - `event_monitor_config`\n  - `event_monitor_seen`\n  - `event_monitor_intervals`\n\n## Setup\n\n1. Import the workflow into n8n.\n2. Create the three required Data Tables and match their columns to the Data Table nodes.\n3. Configure the **Fanbase MCP credential** in **Fetch Event Activity**.\n4. Add your Google Gemini credential to **Analyse Interval**.\n5. Add your Slack credential and select the reporting channel in each Slack node.\n6. Run the configuration form to define the event, monitoring window, keywords, and alert settings.\n7. Test the workflow manually, then enable the **Every 15 Minutes** schedule.\n\n> **Important:** This workflow cannot collect event activity without an active Fanbase MCP connection. Configure and test the Fanbase MCP node before enabling the schedule."
      },
      "typeVersion": 1
    },
    {
      "id": "6dbda4ee-e1f5-43fc-8041-7e23a5c30054",
      "name": "FanBase MCP - Fetch Event Activity",
      "type": "@n8n/n8n-nodes-langchain.mcpClient",
      "position": [
        2688,
        592
      ],
      "parameters": {
        "tool": {
          "__rl": true,
          "mode": "list",
          "value": "query_activity",
          "cachedResultName": "query_activity"
        },
        "options": {},
        "inputMode": "json",
        "jsonInput": "={\n  \"after\": \"{{ $json.window_start }}\",\n  \"before\": \"{{ $json.window_end }}\",\n  \"limit\": 100,\n  \"sortDirection\": \"desc\"\n}",
        "endpointUrl": "https://api.copilot.fanbase.gg/mcp",
        "authentication": "mcpOAuth2Api"
      },
      "credentials": {
        "mcpOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.1
    },
    {
      "id": "c61451fd-c043-40ee-a38b-f547275d00df",
      "name": "How It Works - Scheduled Monitoring",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        640,
        1072
      ],
      "parameters": {
        "width": 1456,
        "height": 208,
        "content": "## 2. Scheduled Monitoring & Window Gate\n\nThe **Every 15 Minutes** trigger loads active event configurations from `event_monitor_config`.\n\nEach event passes through the window gate and loop. Only events inside their configured monitoring window continue to activity collection. Events that have ended move to the post-event reporting branch."
      },
      "typeVersion": 1
    },
    {
      "id": "d146949d-7aec-4951-a722-201e158216d3",
      "name": "How It Works - Fanbase Activity",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2240,
        144
      ],
      "parameters": {
        "width": 1008,
        "height": 336,
        "content": "## 3. Collect New Activity \u2014 Fanbase MCP Required\n\nPreviously processed records are loaded from `event_monitor_seen` to prevent duplicates.\n\nThe **FanBase MCP - Fetch Event Activity** node then calls the Fanbase `query_activity` tool to collect current event and social activity. Results are filtered to the current interval and prepared as one analysis batch.\n\n> **Required:** Connect a Fanbase MCP OAuth credential and use `https://api.copilot.fanbase.gg/mcp`. Without Fanbase MCP, this workflow cannot collect activity."
      },
      "typeVersion": 1
    },
    {
      "id": "63f82f93-4fbd-47b3-be24-6e1cdaf4e066",
      "name": "How It Works - Analysis and State",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3296,
        144
      ],
      "parameters": {
        "width": 912,
        "height": 272,
        "content": "## 4. AI Analysis & Monitoring State\n\nGoogle Gemini analyses each interval for engagement(can set any LLM), sentiment, notable creators, emerging themes, and urgent developments.\n\nThe structured metrics are stored in `event_monitor_intervals`. New source records are split and written to `event_monitor_seen`, while `event_monitor_config` is advanced so the next run starts from the correct interval."
      },
      "typeVersion": 1
    },
    {
      "id": "603480f7-040b-476a-8b49-59e46064d85e",
      "name": "How It Works - Slack Reporting",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        4256,
        144
      ],
      "parameters": {
        "width": 672,
        "height": 224,
        "content": "## 5. Slack Reports & Urgent Alerts\n\nEach completed interval sends a rolling summary to the selected Slack channel.\n\nThe urgent-alert check evaluates the AI result. When an important development is detected, the workflow immediately sends a separate high-priority Slack alert.\n\nConfigure your Slack credential and destination channel in both Slack branches before activating the schedule."
      },
      "typeVersion": 1
    },
    {
      "id": "bb00616e-983d-454d-9ab8-53dd5d73a7f5",
      "name": "How It Works - Post-Event Report",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2224,
        1216
      ],
      "parameters": {
        "width": 1024,
        "height": 176,
        "content": "## 6. Final Post-Event Report\n\nWhen an event leaves its monitoring window, all saved interval metrics are loaded from `event_monitor_intervals`.\n\nThe workflow combines them into a complete post-event report, sends the report to Slack, and marks the event as complete in `event_monitor_config` so it is not monitored again."
      },
      "typeVersion": 1
    }
  ],
  "active": false,
  "settings": {
    "binaryMode": "separate",
    "availableInMCP": false,
    "executionOrder": "v1"
  },
  "versionId": "1d637580-95ed-41da-980b-e66b65c223cf",
  "nodeGroups": [],
  "connections": {
    "Window Gate": {
      "main": [
        [
          {
            "node": "Loop Over Events",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Urgent Alert?": {
      "main": [
        [
          {
            "node": "Send Urgent Alert",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Loop Over Events",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate Config": {
      "main": [
        [
          {
            "node": "Save Event Config(events_config)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Analyse Interval": {
      "main": [
        [
          {
            "node": "Compose Interval Metrics",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Every 15 Minutes": {
      "main": [
        [
          {
            "node": "Get Active Event(events_config)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Loop Over Events": {
      "main": [
        [],
        [
          {
            "node": "In Monitoring Window?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send Urgent Alert": {
      "main": [
        [
          {
            "node": "Loop Over Events",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split New Records": {
      "main": [
        [
          {
            "node": "Mark Records Seen(events_seen)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send Rolling Report": {
      "main": [
        [
          {
            "node": "Urgent Alert?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Interval Batch": {
      "main": [
        [
          {
            "node": "Analyse Interval",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Collapse To One Item": {
      "main": [
        [
          {
            "node": "FanBase MCP - Fetch Event Activity",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Form: Configure Event": {
      "main": [
        [
          {
            "node": "Validate Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "In Monitoring Window?": {
      "main": [
        [
          {
            "node": "Get Seen Records(events_seen)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Load All Intervals(events_intervals )",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send Post-Event Report": {
      "main": [
        [
          {
            "node": "Complete Event(events_config)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Post-Event Report": {
      "main": [
        [
          {
            "node": "Send Post-Event Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Compose Interval Metrics": {
      "main": [
        [
          {
            "node": "Save Interval Metrics(events_intervals )",
            "type": "main",
            "index": 0
          },
          {
            "node": "Split New Records",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Filter To Interval Window": {
      "main": [
        [
          {
            "node": "Build Interval Batch",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Complete Event(events_config)": {
      "main": [
        [
          {
            "node": "Loop Over Events",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Seen Records(events_seen)": {
      "main": [
        [
          {
            "node": "Collapse To One Item",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Mark Records Seen(events_seen)": {
      "main": [
        [
          {
            "node": "Loop Over Events",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Active Event(events_config)": {
      "main": [
        [
          {
            "node": "Window Gate",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "FanBase MCP - Fetch Event Activity": {
      "main": [
        [
          {
            "node": "Filter To Interval Window",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Advance Event Config(events_config)": {
      "main": [
        [
          {
            "node": "Send Rolling Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Load All Intervals(events_intervals )": {
      "main": [
        [
          {
            "node": "Build Post-Event Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Save Interval Metrics(events_intervals )": {
      "main": [
        [
          {
            "node": "Advance Event Config(events_config)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}