AutomationFlowsAI & RAG › Monitor Hotel Competitor Rates and Answer Whatsapp Q&a Using Openai Gpt-4.1

Monitor Hotel Competitor Rates and Answer Whatsapp Q&a Using Openai Gpt-4.1

ByLee Lin @l2l on n8n.io

Top Branch Workflow A The Market Intelligence: Patrols the Market: Runs hourly to scrape competitor rates for future days. Gathers Intel: If prices spike, it instantly checks event announcements to see if a major event is driving demand. Crunches Numbers: Calculates the exact…

Event trigger★★★★★ complexityAI-powered48 nodesHTTP RequestData TableOutput Parser StructuredData Table ToolWhatsApp TriggerWhatsAppAgentOpenAI Chat
AI & RAG Trigger: Event Nodes: 48 Complexity: ★★★★★ AI nodes: yes Added:

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

This workflow follows the Agent → Datatable 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": "CsBvM49DLYRa5tMJ",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "Automate hotel pricing strategy with Q&A on WhatsApp using OpenAI Agent",
  "tags": [],
  "nodes": [
    {
      "id": "148c7c8b-2c56-4a85-8c9a-43e0938eea6c",
      "name": "Config",
      "type": "n8n-nodes-base.set",
      "position": [
        240,
        -112
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "d7c1e6b2-ada9-4449-b2b1-1da10950bc69",
              "name": "currency",
              "type": "string",
              "value": "CAD"
            },
            {
              "id": "fb2eb3b9-f977-4abb-b9fa-bb70368dd53e",
              "name": "adults",
              "type": "number",
              "value": 2
            },
            {
              "id": "ac39d14a-78fe-4c53-aba6-fc48674c82c5",
              "name": "roomQuantity",
              "type": "number",
              "value": 1
            },
            {
              "id": "69b1699d-5318-453f-808b-832faada0468",
              "name": "thresholdPct",
              "type": "number",
              "value": 0.1
            },
            {
              "id": "70a48f58-48f5-4c8d-9a77-9e590eba83c7",
              "name": "daysAhead",
              "type": "number",
              "value": 30
            },
            {
              "id": "79982c4f-6200-4b93-818e-22888187ab61",
              "name": "competitors",
              "type": "array",
              "value": "=[\n  { \"name\": \"VANCOUVER MARRIOTT PINNACLE\", \"hotelId\": \"MCYVRDTM\" }\n]\n"
            },
            {
              "id": "2f21f1cb-1f00-434f-932a-e5510f65d83a",
              "name": "ourHotel",
              "type": "object",
              "value": "={ \"name\": \"WESTIN BAYSHORE VANCOUVER\", \"hotelId\": \"WIYVRBAY\" }\n"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "af6fd0df-1be7-4fca-a576-e1f3cda511e9",
      "name": "Generate 30 night windows",
      "type": "n8n-nodes-base.code",
      "position": [
        528,
        -112
      ],
      "parameters": {
        "jsCode": "// Generate N one-night windows starting \"today\" in America/Vancouver\n// Output = hotel x date items (competitors + ourHotel), each item carries hotelRole + token + cfg fields\n\n// --- FIX START ---\n// Access the FIRST item of the previous nodes securely\nconst cfg = $('Config').first().json; \nconst token = $('Amadeus OAuth').first().json.access_token;\n// --- FIX END ---\n\nconst daysAhead = Number(cfg.daysAhead ?? 30);\nconst tz = cfg.timezone ?? \"America/Vancouver\";\n\n// Format a Date into YYYY-MM-DD in the target timezone\nfunction fmtInTZ(date, timeZone) {\n  const parts = new Intl.DateTimeFormat(\"en-CA\", {\n    timeZone,\n    year: \"numeric\",\n    month: \"2-digit\",\n    day: \"2-digit\",\n  }).formatToParts(date);\n\n  const y = parts.find(p => p.type === \"year\")?.value;\n  const m = parts.find(p => p.type === \"month\")?.value;\n  const d = parts.find(p => p.type === \"day\")?.value;\n  return `${y}-${m}-${d}`;\n}\n\n// Add N days to a YYYY-MM-DD string (treat as UTC midnight to avoid local TZ drift)\nfunction addDays(yyyyMmDd, days) {\n  const [y, m, d] = yyyyMmDd.split(\"-\").map(Number);\n  const dt = new Date(Date.UTC(y, m - 1, d));\n  dt.setUTCDate(dt.getUTCDate() + days);\n  return dt.toISOString().slice(0, 10);\n}\n\n// Build hotel list (competitors + ourHotel), each with hotelRole\nconst hotels = [];\n\n// Competitors (array)\nconst competitors = Array.isArray(cfg.competitors) ? cfg.competitors : [];\nfor (const c of competitors) {\n  if (!c?.hotelId) continue;\n  hotels.push({\n    hotelRole: \"competitor\",\n    hotelId: c.hotelId,\n    hotelName: c.name ?? c.hotelId,\n  });\n}\n\n// Our hotel (object)\nif (cfg.ourHotel?.hotelId) {\n  hotels.push({\n    hotelRole: \"ourHotel\",\n    hotelId: cfg.ourHotel.hotelId,\n    hotelName: cfg.ourHotel.name ?? cfg.ourHotel.hotelId,\n  });\n}\n\n// \"Today\" as YYYY-MM-DD in Vancouver time\nconst todayYMD = fmtInTZ(new Date(), tz);\n\nconst out = [];\nfor (let i = 0; i < daysAhead; i++) {\n  const checkInDate = addDays(todayYMD, i);\n  const checkOutDate = addDays(todayYMD, i + 1);\n\n  for (const h of hotels) {\n    out.push({\n      json: {\n        // hotel identity + role (CRITICAL for alert + AI)\n        hotelRole: h.hotelRole,\n        hotelId: h.hotelId,\n        hotelName: h.hotelName,\n\n        // date window\n        checkInDate,\n        checkOutDate,\n\n        // cfg fields used downstream\n        currency: cfg.currency,\n        adults: cfg.adults,\n        roomQuantity: cfg.roomQuantity,\n        thresholdPct: cfg.thresholdPct,\n\n        // token for Amadeus call\n        access_token: token,\n      },\n    });\n  }\n}\n\nreturn out;"
      },
      "typeVersion": 2
    },
    {
      "id": "ae083efe-c987-41a2-8004-bfea3caabf5d",
      "name": "Amadeus OAuth",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        384,
        -112
      ],
      "parameters": {
        "url": "https://test.api.amadeus.com/v1/security/oauth2/token",
        "method": "POST",
        "options": {},
        "sendBody": true,
        "contentType": "form-urlencoded",
        "bodyParameters": {
          "parameters": [
            {
              "name": "grant_type",
              "value": "client_credentials"
            },
            {
              "name": "client_id"
            },
            {
              "name": "client_secret"
            }
          ]
        }
      },
      "typeVersion": 4.3
    },
    {
      "id": "75c62008-dc34-4193-9559-abc4b327f863",
      "name": "Amadeus Hotel Offers",
      "type": "n8n-nodes-base.httpRequest",
      "onError": "continueRegularOutput",
      "position": [
        704,
        -48
      ],
      "parameters": {
        "url": "https://test.api.amadeus.com/v3/shopping/hotel-offers",
        "options": {
          "batching": {
            "batch": {
              "batchSize": 1,
              "batchInterval": 1200
            }
          },
          "response": {
            "response": {
              "responseFormat": "json"
            }
          }
        },
        "sendQuery": true,
        "sendHeaders": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "hotelIds",
              "value": "={{ $json.hotelId }}"
            },
            {
              "name": "checkInDate",
              "value": "={{$json.checkInDate}}"
            },
            {
              "name": "checkOutDate",
              "value": "={{$json.checkOutDate}}"
            },
            {
              "name": "adults",
              "value": "={{$json.adults}}"
            },
            {
              "name": "roomQuantity",
              "value": "={{$json.roomQuantity}}"
            },
            {
              "name": "currency",
              "value": "={{$json.currency}}"
            },
            {
              "name": "paymentPolicy",
              "value": "NONE"
            }
          ]
        },
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "=Bearer {{$json.access_token}}"
            }
          ]
        }
      },
      "typeVersion": 4.3
    },
    {
      "id": "13fd9cc9-d9e5-4613-9014-a621268901dd",
      "name": "Prev Snapshot",
      "type": "n8n-nodes-base.code",
      "position": [
        1824,
        -48
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Prev Snapshot\n// Purpose: Attach \"previous snapshot\" fields from Hotel_Rates to the current item.\n// Input expectations (from Get Prev Rates):\n// - If a match exists, $json contains the row fields: id, bestTotal, currency, updatedAt, rateKey, etc.\n// - If no match exists, node may output an \"empty\" item (no id). We handle that safely.\n//\n// Output fields added:\n// - prevExists (boolean)\n// - prevId (number|null)\n// - prevBestTotal (number|null)\n// - prevCurrency (string|null)\n// - prevUpdatedAt (string|null)\n// - prevRateKey (string|null)\n\nconst row = $json;\n\n// Determine whether a previous row exists\nconst prevExists = row.id !== undefined && row.id !== null;\n\n// Normalize bestTotal to a number if present\nconst prevBestTotal =\n  prevExists && row.bestTotal !== undefined && row.bestTotal !== null\n    ? Number(row.bestTotal)\n    : null;\n\nreturn {\n  ...row,\n\n  prevExists,\n  prevId: prevExists ? row.id : null,\n  prevBestTotal,\n  prevCurrency: prevExists ? (row.currency ?? null) : null,\n  prevUpdatedAt: prevExists ? (row.updatedAt ?? null) : null,\n  prevRateKey: prevExists ? (row.rateKey ?? null) : null,\n};\n"
      },
      "typeVersion": 2
    },
    {
      "id": "f8f52b3a-d4e0-4a71-96df-cddb41cc41b6",
      "name": "New Snapshot",
      "type": "n8n-nodes-base.code",
      "position": [
        1488,
        -96
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// New Snapshot: compute stable keys + timeSlot + timestamps for each hotel x date item\n// - snapshotKey: hotelId|checkInDate (stable per date)\n// - rateKey: same as snapshotKey (for Hotel_Rates latest snapshot table)\n// - historyKey: hotelId|checkInDate|timeSlot (for Hotel_Rates_History append-only table)\n\nconst item = $json;\n\n// Use Vancouver time for timeSlot bucketing\nconst tz = \"America/Vancouver\";\nconst now = new Date();\n\n// Extract Vancouver local time via Intl (works in n8n without luxon)\nconst parts = new Intl.DateTimeFormat(\"en-CA\", {\n  timeZone: tz,\n  hour: \"2-digit\",\n  hour12: false,\n  year: \"numeric\",\n  month: \"2-digit\",\n  day: \"2-digit\",\n  minute: \"2-digit\",\n  second: \"2-digit\",\n}).formatToParts(now);\n\nconst getPart = (type) => parts.find(p => p.type === type)?.value;\n\nconst localHour = Number(getPart(\"hour\")); // 0-23 in Vancouver\nconst localDate = `${getPart(\"year\")}-${getPart(\"month\")}-${getPart(\"day\")}`;\nconst localTime = `${getPart(\"hour\")}:${getPart(\"minute\")}:${getPart(\"second\")}`;\nconst observedAtLocal = `${localDate}T${localTime} (${tz})`;\n\n// Time slot based on Vancouver hour\nlet timeSlot = \"09\";\nif (localHour >= 21) timeSlot = \"21\";\nelse if (localHour >= 15) timeSlot = \"15\";\n\n// bestTotal = minRate (normalized field)\nconst bestTotal =\n  item.minRate !== null && item.minRate !== undefined\n    ? Number(item.minRate)\n    : null;\n\n// Keys\nconst snapshotKey = `${item.hotelId}|${item.checkInDate}`;\nconst rateKey = snapshotKey; // latest table key (no timeSlot)\nconst historyKey = `${snapshotKey}|${timeSlot}`; // history table key (with timeSlot)\n\nreturn {\n  ...item,\n\n  timeSlot,\n  bestTotal,\n\n  snapshotKey,\n  rateKey,\n  historyKey,\n\n  observedAtUtc: now.toISOString(),\n  observedAtLocal,\n};\n"
      },
      "typeVersion": 2
    },
    {
      "id": "5f6d09f7-4d6f-4459-95f1-0307d827859e",
      "name": "Compute Change",
      "type": "n8n-nodes-base.code",
      "position": [
        -224,
        384
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Compute Change Flags\n// Computes absChange, pctChange, isSignificant, isCompetitor\n// Handles missing prev values safely.\n\nconst item = $json;\n\nconst thresholdPct = Number(item.thresholdPct ?? 0.10);\n\n// Current (new) bestTotal\nconst bestTotal =\n  item.bestTotal !== undefined && item.bestTotal !== null\n    ? Number(item.bestTotal)\n    : null;\n\n// Previous bestTotal\nconst prevBestTotal =\n  item.prevBestTotal !== undefined && item.prevBestTotal !== null\n    ? Number(item.prevBestTotal)\n    : null;\n\nconst prevExists = Boolean(item.prevExists);\n\n// Role flags\nconst isCompetitor = item.hotelRole === \"competitor\";\nconst isOurHotel = item.hotelRole === \"ourHotel\";\n\n// Changes\nlet absChange = null;\nlet pctChange = null;\nlet pctChangePct = null;\n\nif (bestTotal !== null && prevExists && prevBestTotal !== null && prevBestTotal !== 0) {\n  absChange = bestTotal - prevBestTotal;\n  pctChange = absChange / prevBestTotal;      // e.g., 0.10 = 10%\n  pctChangePct = pctChange * 100;             // e.g., 10 = 10%\n}\n\n// Significant change rule (competitors only; uses absolute change)\nconst isSignificant =\n  isCompetitor &&\n  pctChange !== null &&\n  Math.abs(pctChange) >= thresholdPct;\n\nreturn {\n  ...item,\n  thresholdPct,\n  absChange,\n  pctChange,\n  pctChangePct,\n  isSignificant,\n  isCompetitor,\n  isOurHotel,\n};\n"
      },
      "typeVersion": 2
    },
    {
      "id": "778472bb-1e24-45ed-8282-025132d50f4e",
      "name": "Significant Competitor Change",
      "type": "n8n-nodes-base.if",
      "position": [
        -16,
        384
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "02b560c8-8054-4630-b97e-647de32f66e2",
              "operator": {
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "={{ $json.hotelRole }}",
              "rightValue": "competitor"
            },
            {
              "id": "91a3040d-b0a8-4fe2-b00e-fb083d34fa44",
              "operator": {
                "type": "boolean",
                "operation": "equals"
              },
              "leftValue": "={{ $json.isSignificant }}",
              "rightValue": true
            },
            {
              "id": "0116b668-9bb6-40d5-8f23-3bc7328582f9",
              "operator": {
                "type": "boolean",
                "operation": "equals"
              },
              "leftValue": "={{ $json.prevExists }}",
              "rightValue": true
            },
            {
              "id": "81f76849-6d3c-45d8-9a27-fb15b50e91b3",
              "operator": {
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "={{ $json.outcome }}",
              "rightValue": "OK"
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "a35561a3-f6e5-465e-9a75-85f68a9ada22",
      "name": "Get Our Hotel Rates",
      "type": "n8n-nodes-base.dataTable",
      "position": [
        192,
        464
      ],
      "parameters": {
        "filters": {
          "conditions": [
            {
              "keyName": "rateKey",
              "keyValue": "={{$node[\"Config\"].json.ourHotel.hotelId}}|{{$json.checkInDate}}"
            }
          ]
        },
        "operation": "get",
        "dataTableId": {
          "__rl": true,
          "mode": "list",
          "value": "qjXbEPpv1X8ECf6N",
          "cachedResultUrl": "/projects/HgU76KeJzQXHrABf/datatables/qjXbEPpv1X8ECf6N",
          "cachedResultName": "#2 Hotel_Rates"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "c618b644-7349-4c28-917a-8ac06ae91fee",
      "name": "Prefix Our Hotel Fields",
      "type": "n8n-nodes-base.set",
      "position": [
        368,
        464
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "b0369e36-f9bd-41bf-adf2-934815a09018",
              "name": "ourHotelID",
              "type": "string",
              "value": "={{ $json.hotelId }}"
            },
            {
              "id": "1fbcd5b1-9c85-40d8-8893-a84259bc11f7",
              "name": "ourHotelName",
              "type": "string",
              "value": "={{ $json.hotelName }}"
            },
            {
              "id": "61487e87-1db2-4016-b2a5-e2ca0a448703",
              "name": "ourHotelRateKey",
              "type": "string",
              "value": "={{ $json.rateKey }}"
            },
            {
              "id": "cad6d9ca-2cfb-484c-a5c7-dafb9b45b480",
              "name": "ourHotelCheckInDate",
              "type": "string",
              "value": "={{ $json.checkInDate }}"
            },
            {
              "id": "8b489b14-2823-4376-b541-2636a870bf81",
              "name": "ourHotelTimeSlot",
              "type": "string",
              "value": "={{ $json.timeSlot }}"
            },
            {
              "id": "c15fe73c-decf-4867-8eee-81c4ec5cea0d",
              "name": "ourHotelBestTotal",
              "type": "number",
              "value": "={{ $json.bestTotal }}"
            },
            {
              "id": "aeff0a4f-4c5a-4d55-8279-7a6149c404a3",
              "name": "ourHotelCurrency",
              "type": "string",
              "value": "={{ $json.currency }}"
            },
            {
              "id": "c202c5b2-b622-47da-8225-4b95e6681e4b",
              "name": "ourHotelUpdatedAt",
              "type": "string",
              "value": "={{ $json.updatedAt }}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "c26bbbf2-4096-4399-b9d2-206b917f5fe5",
      "name": "Build VCC URLs",
      "type": "n8n-nodes-base.code",
      "position": [
        816,
        480
      ],
      "parameters": {
        "jsCode": "// Build UNIQUE Vancouver Convention Centre monthly URLs needed for all significant items\n// Output: 1 item per unique month URL\n// Each output includes alertItems[] so downstream parsing/AI still has full context\n\nconst items = $input.all().map(i => i.json);\n\n// Helper: \"YYYY-MM\"\nfunction ym(dateObj) {\n  const y = dateObj.getFullYear();\n  const m = String(dateObj.getMonth() + 1).padStart(2, \"0\");\n  return `${y}-${m}`;\n}\n\n// Helper: VCC URL\nfunction vccUrlFromYM(yyyyMm) {\n  const [y, m] = yyyyMm.split(\"-\").map(Number);\n  return `https://www.vancouverconventioncentre.com/events/${y}/${m}`;\n}\n\n// Collect months needed (current + next) for every significant item\nconst monthSet = new Set();\n\nfor (const it of items) {\n  const d = new Date(`${it.checkInDate}T00:00:00`);\n  monthSet.add(ym(d));\n\n  const d2 = new Date(d);\n  d2.setMonth(d2.getMonth() + 1);\n  monthSet.add(ym(d2));\n}\n\n// Build output: one request per unique month\nconst months = Array.from(monthSet).sort();\nreturn months.map(m => ({\n  json: {\n    vccMonth: m,\n    vccUrl: vccUrlFromYM(m),\n    alertItems: items, // keep all significant changes for downstream AI/message composition\n  },\n}));\n"
      },
      "typeVersion": 2
    },
    {
      "id": "c33c9db9-5763-4ef7-a4fb-98e634618ac8",
      "name": "Fetch VCC Month HTML",
      "type": "n8n-nodes-base.httpRequest",
      "onError": "continueRegularOutput",
      "position": [
        992,
        480
      ],
      "parameters": {
        "url": "={{$json.vccUrl}}",
        "options": {
          "response": {
            "response": {
              "responseFormat": "text",
              "outputPropertyName": "events"
            }
          }
        }
      },
      "retryOnFail": false,
      "typeVersion": 4.3
    },
    {
      "id": "5e2b3b9c-3065-4708-9e6c-d126cdc737d9",
      "name": "Events Extract",
      "type": "n8n-nodes-base.code",
      "position": [
        1344,
        480
      ],
      "parameters": {
        "jsCode": "/**\n * n8n Code node (JavaScript) \u2014 Run Once for All Items\n * Fixes: startISO null / sortKey 99999999 by inferring year from title/url + Dec/Jan heuristic.\n */\n\nconst BASE_URL = 'https://www.vancouverconventioncentre.com';\n\nconst monthMap = {\n  Jan: 1, Feb: 2, Mar: 3, Apr: 4, May: 5, Jun: 6,\n  Jul: 7, Aug: 8, Sep: 9, Oct: 10, Nov: 11, Dec: 12,\n};\n\nconst clean = (v) =>\n  (v ?? '')\n    .toString()\n    .replace(/\\s+/g, ' ')\n    .trim();\n\nconst toAbsUrl = (href) => {\n  const h = clean(href);\n  if (!h) return '';\n  if (h.startsWith('http://') || h.startsWith('https://')) return h;\n  if (h.startsWith('/')) return `${BASE_URL}${h}`;\n  return `${BASE_URL}/${h}`;\n};\n\nconst pad2 = (n) => String(n).padStart(2, '0');\n\nconst toISO = (year, monNum, dayNum) => {\n  if (!year || !monNum || !dayNum) return null;\n  return `${year}-${pad2(monNum)}-${pad2(dayNum)}`;\n};\n\nconst detectYearFromText = (text) => {\n  const s = clean(text);\n  const m = s.match(/\\b(20\\d{2})\\b/);\n  return m ? parseInt(m[1], 10) : undefined;\n};\n\nconst detectYearFromUrl = (url) => {\n  const s = clean(url);\n  // matches ...-2026 or .../2026/... (slug patterns)\n  const m = s.match(/(?:-|\\/)(20\\d{2})(?:\\b|\\/|$)/);\n  return m ? parseInt(m[1], 10) : undefined;\n};\n\nconst formatRange = (e) => {\n  const sMon = clean(e.startMonth);\n  const sDay = clean(e.startDay);\n  const eMon = clean(e.endMonth);\n  const eDay = clean(e.endDay);\n\n  if (!eMon && !eDay) return `${sMon} ${sDay}`.trim();\n\n  if (sMon === eMon && sDay === eDay) return `${sMon} ${sDay}`.trim();\n\n  if (sMon && eMon && sMon === eMon && sDay && eDay) return `${sMon} ${sDay}\u2013${eDay}`;\n\n  if (sMon && sDay && eMon && eDay) return `${sMon} ${sDay}\u2013${eMon} ${eDay}`;\n\n  return [sMon, sDay, '-', eMon, eDay].filter(Boolean).join(' ').replace(/\\s+/g, ' ').trim();\n};\n\n// --------------------\n// 1) Collect raw events from all input items\n// --------------------\nconst inputs = $input.all();\nconst rawEvents = [];\n\nfor (const item of inputs) {\n  const j = item.json || {};\n\n  // Arrays from HTML Extract node\n  const titles = Array.isArray(j.title) ? j.title : [];\n  const hrefs = Array.isArray(j.href) ? j.href : [];\n  const startDays = Array.isArray(j.startDay) ? j.startDay : [];\n  const startMonths = Array.isArray(j.startMonth) ? j.startMonth : [];\n  const endDays = Array.isArray(j.endDay) ? j.endDay : [];\n  const endMonths = Array.isArray(j.endMonth) ? j.endMonth : [];\n\n  const n = Math.max(\n    titles.length,\n    hrefs.length,\n    startDays.length,\n    startMonths.length,\n    endDays.length,\n    endMonths.length\n  );\n\n  for (let i = 0; i < n; i++) {\n    const title = clean(titles[i]);\n    const url = toAbsUrl(hrefs[i]);\n\n    const startDay = clean(startDays[i]);\n    const startMonth = clean(startMonths[i]);\n    const endDay = clean(endDays[i]);\n    const endMonth = clean(endMonths[i]);\n\n    if (!title && !url) continue;\n\n    const startMonthNum = monthMap[startMonth] || undefined;\n\n    const yearFromTitle = detectYearFromText(title);\n    const yearFromUrl = detectYearFromUrl(url);\n    const detectedYear = yearFromTitle || yearFromUrl;\n\n    rawEvents.push({\n      title,\n      url,\n      startDay,\n      startMonth,\n      endDay,\n      endMonth,\n      startMonthNum,\n      detectedYear,\n    });\n  }\n}\n\n// --------------------\n// 2) Determine an \"anchor\" year for the batch\n//    - Prefer the smallest detected year among Jan events (perfect for Dec+Jan scrape)\n//    - Else: smallest detected year overall\n//    - Else: current year\n// --------------------\nconst nowYear = new Date().getFullYear();\n\nconst janYears = rawEvents\n  .filter(e => e.startMonthNum === 1 && e.detectedYear)\n  .map(e => e.detectedYear);\n\nconst allYears = rawEvents\n  .filter(e => e.detectedYear)\n  .map(e => e.detectedYear);\n\nconst anchorYear =\n  (janYears.length ? Math.min(...janYears) : undefined) ??\n  (allYears.length ? Math.min(...allYears) : undefined) ??\n  nowYear;\n\n// --------------------\n// 3) Assign final year per event\n//    - if detectedYear exists, use it\n//    - else if month is Nov/Dec, assume anchorYear - 1 (Dec preceding Jan anchor)\n//    - else assume anchorYear\n// --------------------\nconst withYear = rawEvents.map(e => {\n  let year = e.detectedYear;\n\n  if (!year) {\n    if (e.startMonthNum === 11 || e.startMonthNum === 12) year = anchorYear - 1;\n    else year = anchorYear;\n  }\n\n  const startDayNum = parseInt(e.startDay, 10);\n  const startISO =\n    Number.isFinite(startDayNum) && e.startMonthNum && year\n      ? toISO(year, e.startMonthNum, startDayNum)\n      : null;\n\n  const sortKey = startISO ? parseInt(startISO.replace(/-/g, ''), 10) : 99999999;\n\n  return {\n    ...e,\n    year,\n    startISO,\n    sortKey,\n  };\n});\n\n// --------------------\n// 4) De-dupe by URL (fallback key if missing)\n// --------------------\nconst seen = new Set();\nconst events = [];\n\nfor (const e of withYear) {\n  const key = e.url || `${e.title}|${e.startDay}|${e.startMonth}`;\n  if (seen.has(key)) continue;\n  seen.add(key);\n  events.push(e);\n}\n\n// --------------------\n// 5) Sort chronologically\n// --------------------\nevents.sort((a, b) => {\n  if (a.sortKey !== b.sortKey) return a.sortKey - b.sortKey;\n  return (a.title || '').localeCompare(b.title || '');\n});\n\n// --------------------\n// 6) Build summary (chronological)\n// --------------------\nconst eventsSummary = events\n  .map(e => `- ${formatRange(e)}: ${clean(e.title)} (${e.url})`)\n  .join('\\n');\n\nreturn [\n  {\n    json: {\n      eventsCount: events.length,\n      anchorYear,\n      events: events.map(e => ({\n        title: e.title,\n        url: e.url,\n        startDay: e.startDay,\n        startMonth: e.startMonth,\n        endDay: e.endDay,\n        endMonth: e.endMonth,\n        year: e.year,\n        startISO: e.startISO,\n        sortKey: e.sortKey,\n      })),\n      eventsSummary,\n    },\n  },\n];\n"
      },
      "typeVersion": 2
    },
    {
      "id": "77115dfc-75c9-4966-ae34-b1ec6234a014",
      "name": "Combine 30-Day Rates",
      "type": "n8n-nodes-base.merge",
      "position": [
        912,
        -96
      ],
      "parameters": {
        "mode": "combine",
        "options": {},
        "combineBy": "combineByPosition"
      },
      "typeVersion": 3.2
    },
    {
      "id": "5f436d25-091c-4b4e-9eb7-15f90f402a64",
      "name": "Get Prev Rates",
      "type": "n8n-nodes-base.dataTable",
      "position": [
        1664,
        -48
      ],
      "parameters": {
        "limit": 1,
        "filters": {
          "conditions": [
            {
              "keyName": "rateKey",
              "keyValue": "={{$json.rateKey}}"
            }
          ]
        },
        "matchType": "allConditions",
        "operation": "get",
        "dataTableId": {
          "__rl": true,
          "mode": "list",
          "value": "qjXbEPpv1X8ECf6N",
          "cachedResultUrl": "/projects/HgU76KeJzQXHrABf/datatables/qjXbEPpv1X8ECf6N",
          "cachedResultName": "Hotel_Rates"
        }
      },
      "typeVersion": 1,
      "alwaysOutputData": true
    },
    {
      "id": "92e54bf4-dd04-4813-a83c-bd4db6e868ca",
      "name": "Upsert Latest Rates",
      "type": "n8n-nodes-base.dataTable",
      "position": [
        2256,
        -224
      ],
      "parameters": {
        "columns": {
          "value": {
            "hotelId": "={{ $json.hotelId }}",
            "rateKey": "={{ $json.rateKey }}",
            "currency": "={{ $json.currency }}",
            "timeSlot": "={{ $json.timeSlot }}",
            "bestTotal": "={{ $json.bestTotal }}",
            "hotelName": "={{ $json.hotelName }}",
            "checkInDate": "={{ $json.checkInDate }}"
          },
          "schema": [
            {
              "id": "rateKey",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "rateKey",
              "defaultMatch": false
            },
            {
              "id": "hotelId",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "hotelId",
              "defaultMatch": false
            },
            {
              "id": "hotelName",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "hotelName",
              "defaultMatch": false
            },
            {
              "id": "checkInDate",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "checkInDate",
              "defaultMatch": false
            },
            {
              "id": "timeSlot",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "timeSlot",
              "defaultMatch": false
            },
            {
              "id": "bestTotal",
              "type": "number",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "bestTotal",
              "defaultMatch": false
            },
            {
              "id": "currency",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "currency",
              "defaultMatch": false
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "filters": {
          "conditions": [
            {
              "keyName": "rateKey",
              "keyValue": "={{ $json.rateKey }}"
            }
          ]
        },
        "options": {},
        "matchType": "allConditions",
        "operation": "upsert",
        "dataTableId": {
          "__rl": true,
          "mode": "list",
          "value": "qjXbEPpv1X8ECf6N",
          "cachedResultUrl": "/projects/HgU76KeJzQXHrABf/datatables/qjXbEPpv1X8ECf6N",
          "cachedResultName": "Hotel_Rates"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "556269b7-e938-419e-9984-843abe76a9f8",
      "name": "Insert History Rates",
      "type": "n8n-nodes-base.dataTable",
      "position": [
        2256,
        -80
      ],
      "parameters": {
        "columns": {
          "value": {
            "hotelID": "={{ $json.hotelId }}",
            "currency": "={{ $json.currency }}",
            "timeSlot": "={{ $json.timeSlot }}",
            "bestTotal": "={{ $json.bestTotal }}",
            "hotelName": "={{ $json.hotelName }}",
            "historyKey": "={{ $json.historyKey }}",
            "checkInDate": "={{ $json.checkInDate }}",
            "observedAtUtc": "={{ $json.observedAtUtc }}",
            "observedAtLocal": "={{ $json.observedAtLocal }}"
          },
          "schema": [
            {
              "id": "historyKey",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "historyKey",
              "defaultMatch": false
            },
            {
              "id": "hotelID",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "hotelID",
              "defaultMatch": false
            },
            {
              "id": "hotelName",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "hotelName",
              "defaultMatch": false
            },
            {
              "id": "checkInDate",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "checkInDate",
              "defaultMatch": false
            },
            {
              "id": "bestTotal",
              "type": "number",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "bestTotal",
              "defaultMatch": false
            },
            {
              "id": "currency",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "currency",
              "defaultMatch": false
            },
            {
              "id": "timeSlot",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "timeSlot",
              "defaultMatch": false
            },
            {
              "id": "observedAtUtc",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "observedAtUtc",
              "defaultMatch": false
            },
            {
              "id": "observedAtLocal",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "observedAtLocal",
              "defaultMatch": false
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {},
        "dataTableId": {
          "__rl": true,
          "mode": "list",
          "value": "62p7j641jAbn46lU",
          "cachedResultUrl": "/projects/HgU76KeJzQXHrABf/datatables/62p7j641jAbn46lU",
          "cachedResultName": "Hotel_Rates_History"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "d97aba48-4dd9-407c-84d0-97341d4dfbcf",
      "name": "Combine New Rates vs Prev Rates",
      "type": "n8n-nodes-base.merge",
      "position": [
        2016,
        -80
      ],
      "parameters": {
        "mode": "combine",
        "options": {
          "clashHandling": {
            "values": {
              "mergeMode": "shallowMerge",
              "resolveClash": "preferInput1"
            }
          }
        },
        "joinMode": "enrichInput1",
        "fieldsToMatchString": "rateKey"
      },
      "typeVersion": 3.2
    },
    {
      "id": "9284f26f-e218-4418-8898-82bebcd5f004",
      "name": "Combine Competitor Rates vs Our Rates",
      "type": "n8n-nodes-base.merge",
      "position": [
        544,
        384
      ],
      "parameters": {
        "mode": "combine",
        "options": {
          "clashHandling": {
            "values": {
              "resolveClash": "preferInput1"
            }
          }
        },
        "combineBy": "combineByPosition"
      },
      "typeVersion": 3.2
    },
    {
      "id": "c7f2f0e4-e767-40ef-8d69-a3296b44ae8a",
      "name": "Structured Output Parser",
      "type": "@n8n/n8n-nodes-langchain.outputParserStructured",
      "position": [
        2128,
        592
      ],
      "parameters": {
        "schemaType": "manual",
        "inputSchema": "{\n  \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n  \"title\": \"RevenueManagerAlertAnalysis\",\n  \"type\": \"object\",\n  \"additionalProperties\": false,\n  \"required\": [\n    \"executiveSummary\",\n    \"alertsReviewed\",\n    \"findings\",\n    \"vccEventsUsedSummary\"\n  ],\n  \"properties\": {\n    \"executiveSummary\": {\n      \"type\": \"string\",\n      \"minLength\": 1\n    },\n    \"alertsReviewed\": {\n      \"type\": \"integer\",\n      \"minimum\": 0\n    },\n    \"findings\": {\n      \"type\": \"array\",\n      \"items\": {\n        \"type\": \"object\",\n        \"additionalProperties\": false,\n        \"required\": [\n          \"hotelName\",\n          \"checkInDate\",\n          \"competitorNow\",\n          \"competitorPrev\",\n          \"competitorAbsChange\",\n          \"competitorPctChangePct\",\n          \"ourHotelRate\",\n          \"priceGapOurMinusCompetitor\",\n          \"priceGapPct\",\n          \"vccEventOverlaps\",\n          \"interpretation\",\n          \"recommendation\"\n        ],\n        \"properties\": {\n          \"hotelName\": {\n            \"type\": \"string\",\n            \"minLength\": 1\n          },\n          \"checkInDate\": {\n            \"type\": \"string\",\n            \"pattern\": \"^\\\\d{4}-\\\\d{2}-\\\\d{2}$\"\n          },\n          \"competitorNow\": {\n            \"type\": [\"number\", \"null\"]\n          },\n          \"competitorPrev\": {\n            \"type\": [\"number\", \"null\"]\n          },\n          \"competitorAbsChange\": {\n            \"type\": [\"number\", \"null\"]\n          },\n          \"competitorPctChangePct\": {\n            \"type\": [\"number\", \"null\"]\n          },\n          \"ourHotelRate\": {\n            \"type\": [\"number\", \"null\"]\n          },\n          \"priceGapOurMinusCompetitor\": {\n            \"type\": [\"number\", \"null\"]\n          },\n          \"priceGapPct\": {\n            \"type\": [\"number\", \"null\"]\n          },\n          \"vccEventOverlaps\": {\n            \"type\": \"array\",\n            \"items\": {\n              \"type\": \"object\",\n              \"additionalProperties\": false,\n              \"required\": [\"title\", \"url\", \"startISO\"],\n              \"properties\": {\n                \"title\": {\n                  \"type\": \"string\",\n                  \"minLength\": 1\n                },\n                \"url\": {\n                  \"type\": \"string\",\n                  \"minLength\": 1\n                },\n                \"startISO\": {\n                  \"type\": \"string\",\n                  \"pattern\": \"^\\\\d{4}-\\\\d{2}-\\\\d{2}$\"\n                }\n              }\n            }\n          },\n          \"interpretation\": {\n            \"type\": \"string\",\n            \"minLength\": 1\n          },\n          \"recommendation\": {\n            \"type\": \"object\",\n            \"additionalProperties\": false,\n            \"required\": [\"priceAction\", \"targetRange\", \"tactics\", \"monitoring\"],\n            \"properties\": {\n              \"priceAction\": {\n                \"type\": \"string\",\n                \"enum\": [\"increase\", \"decrease\", \"hold\"]\n              },\n              \"targetRange\": {\n                \"type\": \"object\",\n                \"additionalProperties\": false,\n                \"required\": [\"min\", \"max\"],\n                \"properties\": {\n                  \"min\": { \"type\": \"number\" },\n                  \"max\": { \"type\": \"number\" }\n                }\n              },\n              \"tactics\": {\n                \"type\": \"array\",\n                \"items\": { \"type\": \"string\" }\n              },\n              \"monitoring\": {\n                \"type\": \"array\",\n                \"items\": { \"type\": \"string\" }\n              }\n            }\n          }\n        }\n      }\n    },\n    \"vccEventsUsedSummary\": {\n      \"type\": \"string\",\n      \"minLength\": 1\n    }\n  }\n}\n"
      },
      "typeVersion": 1.3
    },
    {
      "id": "3e29025d-940a-46db-bc92-6f10b688ba75",
      "name": "Normalize Whatsapp Input",
      "type": "n8n-nodes-base.set",
      "position": [
        960,
        880
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "6814165f-1ff0-4654-8ec5-0fad100c3c95",
              "name": "chatInput",
              "type": "string",
              "value": "={{\n  (\n    ($json.messages && $json.messages[0] && $json.messages[0].text && $json.messages[0].text.body) ||\n    ($json.value && $json.value.messages && $json.value.messages[0] && $json.value.messages[0].text && $json.value.messages[0].text.body) ||\n    ($json.entry && $json.entry[0] && $json.entry[0].changes && $json.entry[0].changes[0] && $json.entry[0].changes[0].value &&\n      $json.entry[0].changes[0].value.messages && $json.entry[0].changes[0].value.messages[0] &&\n      $json.entry[0].changes[0].value.messages[0].text && $json.entry[0].changes[0].value.messages[0].text.body) ||\n    \"\"\n  )\n}}\n"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "d99ca3f4-3c38-4240-8cb5-19c582b84df2",
      "name": "Prepare AI Agent Input",
      "type": "n8n-nodes-base.code",
      "position": [
        1824,
        400
      ],
      "parameters": {
        "jsCode": "// n8n Code node\n// Mode: Run Once for All Items\n// Input: items from a Merge(Append) node that outputs:\n//  - one or more \"alert\" items (hotel pricing change rows), and\n//  - one \"events bundle\" item (eventsCount/anchorYear/events/eventsSummary)\n\nfunction pad2(n) {\n  return String(n).padStart(2, '0');\n}\n\nconst monthMap = {\n  Jan: 1, Feb: 2, Mar: 3, Apr: 4, May: 5, Jun: 6,\n  Jul: 7, Aug: 8, Sep: 9, Oct: 10, Nov: 11, Dec: 12,\n};\n\nfunction toISODate(year, monAbbr, dayStr) {\n  const m = monthMap[monAbbr];\n  if (!year || !m || !dayStr) return null;\n  const d = Number(dayStr);\n  if (!Number.isFinite(d)) return null;\n  return `${year}-${pad2(m)}-${pad2(d)}`;\n}\n\nfunction safeDate(s) {\n  const d = new Date(s);\n  return isNaN(d.getTime()) ? null : d;\n}\n\nconst all = $input.all().map(i => i.json);\n\n// Identify events bundle vs alerts\nlet vccBundle = null;\nconst alertItems = [];\n\nfor (const obj of all) {\n  const looksLikeEventsBundle =\n    typeof obj?.eventsCount === 'number' &&\n    Array.isArray(obj?.events) &&\n    typeof obj?.eventsSummary === 'string';\n\n  const looksLikeAlertRow =\n    typeof obj?.bestTotal === 'number' &&\n    typeof obj?.hotelName === 'string' &&\n    typeof obj?.checkInDate === 'string';\n\n  const looksLikePreparedBundle =\n    typeof obj?.alertsCount === 'number' &&\n    Array.isArray(obj?.alerts);\n\n  if (looksLikeEventsBundle) {\n    vccBundle = obj;\n  } else if (looksLikePreparedBundle) {\n    // If an upstream node already bundled alerts, unpack them\n    for (const a of obj.alerts) alertItems.push(a);\n    // and also allow vcc fields if present\n    if (!vccBundle && Array.isArray(obj?.vccEvents) && typeof obj?.vccEventsSummary === 'string') {\n      // not expected here, but kept for resilience\n      vccBundle = {\n        eventsCount: obj.vccEventsCount ?? obj.vccEvents?.length ?? 0,\n        anchorYear: obj.vccAnchorYear ?? null,\n        events: obj.vccEvents ?? [],\n        eventsSummary: obj.vccEventsSummary ?? '',\n      };\n    }\n  } else if (looksLikeAlertRow) {\n    alertItems.push(obj);\n  }\n}\n\n// Slim alerts to only what the agent needs (keeps prompt small and consistent)\nconst alerts = alertItems.map(a => ({\n  rateKey: a.rateKey ?? null,\n  hotelId: a.hotelId ?? null,\n  hotelName: a.hotelName ?? null,\n  hotelRole: a.hotelRole ?? null,\n  checkInDate: a.checkInDate ?? null,\n  checkOutDate: a.checkOutDate ?? null,\n  timeSlot: a.timeSlot ?? null,\n  bestTotal: a.bestTotal ?? null,\n  currency: a.currency ?? null,\n  prevBestTotal: a.prevBestTotal ?? null,\n  prevCurrency: a.prevCurrency ?? null,\n  absChange: a.absChange ?? null,\n  pctChangePct: a.pctChangePct ?? null,\n  isSignificant: a.isSignificant ?? null,\n  ourHotelID: a.ourHotelID ?? null,\n  ourHotelName: a.ourHotelName ?? null,\n  ourHotelBestTotal: a.ourHotelBestTotal ?? null,\n  ourHotelCurrency: a.ourHotelCurrency ?? null,\n  observedAtLocal: a.observedAtLocal ?? null,\n}));\n\n// Optional: compute relevant events for each alert check-in date (still small)\n// We will NOT pass the full vcc events array downstream, only the overlaps.\nlet relevantEventsByAlert = [];\n\nif (vccBundle?.events?.length && alerts.length) {\n  const events = vccBundle.events.map(e => {\n    // Ensure we have startISO\n    const startISO = e.startISO ?? toISODate(e.year, e.startMonth, e.startDay);\n\n    // Compute endISO (handles \"Jan 31 \u2013 Feb 1\" by rolling year if month goes backwards)\n    const startMonthNum = monthMap[e.startMonth];\n    const endMonthNum = monthMap[e.endMonth];\n\n    let endYear = e.year ?? vccBundle.anchorYear ?? null;\n    if (endYear && startMonthNum && endMonthNum && endMonthNum < startMonthNum) {\n      endYear = endYear + 1;\n    }\n    const endISO = e.endISO ?? toISODate(endYear, e.endMonth, e.endDay);\n\n    return {\n      title: e.title ?? null,\n      url: e.url ?? null,\n      startISO,\n      endISO,\n    };\n  });\n\n  relevantEventsByAlert = alerts.map(a => {\n    const inD = safeDate(a.checkInDate);\n    if (!inD) return { checkInDate: a.checkInDate ?? null, relevantEvents: [] };\n\n    const hits = [];\n    for (const ev of events) {\n      const s = safeDate(ev.startISO);\n      const ed = safeDate(ev.endISO ?? ev.startISO);\n      if (!s || !ed) continue;\n\n      // inclusive overlap check: check-in falls within event date window\n      if (inD >= s && inD <= ed) {\n        hits.push({ title: ev.title, url: ev.url, startISO: ev.startISO, endISO: ev.endISO ?? ev.startISO });\n      }\n    }\n\n    return { checkInDate: a.checkInDate ?? null, relevantEvents: hits };\n  });\n}\n\nconst out = {\n  alertsCount: alerts.length,\n  alerts,\n  vccEventsCount: vccBundle?.eventsCount ?? 0,\n  vccAnchorYear: vccBundle?.anchorYear ?? null,\n  // Keep ONLY the summary to avoid prompt bloat/truncation:\n  vccEventsSummary: vccBundle?.eventsSummary ?? '',\n  // Optional but useful, and still compact:\n  relevantEventsByAlert,\n  // Convenience context for the agent:\n  context: {\n    checkInDate: alerts?.[0]?.checkInDate ?? null,\n    timeSlot: alerts?.[0]?.timeSlot ?? null,\n    observedAtLocal: alerts?.[0]?.observedAtLocal ?? null,\n    ourHotelName: alerts?.[0]?.ourHotelName ?? null,\n  },\n};\n\nreturn [{ json: out }];\n"
      },
      "typeVersion": 2
    },
    {
      "id": "1c659f17-e275-47e4-a8fa-888ba3f4e9d1",
      "name": "Get Hotel_Rates_History",
      "type": "n8n-nodes-base.dataTableTool",
      "position": [
        1584,
        1072
      ],
      "parameters": {
        "operation": "get",
        "dataTableId": {
          "__rl": true,
          "mode": "list",
          "value": "62p7j641jAbn46lU",
          "cachedResultUrl": "/projects/HgU76KeJzQXHrABf/datatables/62p7j641jAbn46lU",
          "cachedResultName": "#2 Hotel_Rates_History"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "3c88e845-7589-4b8c-bf66-a0b271098d20",
      "name": "WhatsApp Trigger",
      "type": "n8n-nodes-base.whatsAppTrigger",
      "position": [
        544,
        896
      ],
      "parameters": {
        "options": {},
        "updates": [
          "messages"
        ]
      },
      "credentials": {
        "whatsAppTriggerApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "8d395a5f-7f59-4e91-892b-041bf8af8223",
      "name": "Upsert Hotel Price Alerts",
      "type": "n8n-nodes-base.dataTable",
      "position": [
        2496,
        576
      ],
      "parameters": {
        "columns": {
          "value": {
            "key": "latest",
            "alertSummary": "={{ $json.output.executiveSummary }}",
            "eventSummary": "={{ $json.vccEventsSummary }}",
            "alertStrategy": "={{ $json.output.findings[0].recommendation.tactics }}{{ $json.output.findings[0].recommendation.monitoring }}"
          },
          "schema": [
            {
              "id": "key",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "key",
              "defaultMatch": false
            },
            {
              "id": "alertSummary",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "alertSummary",
              "defaultMatch": false
            },
            {
              "id": "eventSummary",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "eventSummary",
              "defaultMatch": false
            },
            {
              "id": "alertStrategy",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "alertStrategy",
              "defaultMatch": false
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "filters": {
          "conditions": [
            {
              "keyName": "key",
              "keyValue": "latest"
            }
          ]
        },
        "options": {},
        "operation": "upsert",
        "dataTableId": {
          "__rl": true,
          "mode": "list",
          "value": "YW03LGk1RjI01ihc",
          "cachedResultUrl": "/projects/HgU76KeJzQXHrABf/datatables/YW03LGk1RjI01ihc",
          "cachedResultName": "#2 Hotel_Price_Alert"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "263bd8c2-7db7-4ada-a955-994aa9e89c0c",
      "name": "Get Alert Summary",
      "type": "n8n-nodes-base.dataTable",
      "position": [
        1168,
        880
      ],
      "parameters": {
        "filters": {
          "conditions": [
            {
              "keyName": "key",
              "keyValue": "latest"
            }
          ]
        },
        "operation": "get",
        "dataTableId": {
          "__rl": true,
          "mode": "list",
          "value": "YW03LGk1RjI01ihc",
          "cachedResultUrl": "/projects/HgU76KeJzQXHrABf/datatables/YW03LGk1RjI01ihc",
          "cachedResultName": "#2 Hotel_Price_Alert"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "7c3561e8-f3bc-4d8b-a610-258c033717a7",
      "name": "Q&A",
      "type": "n8n-nodes-base.whatsApp",
      "position": [
        1712,
        880
      ],
      "parameters": {
        "textBody": "={{ $json.output }}",
        "operation": "send",
        "phoneNumberId": "=1234",
        "additionalFields": {},
        "recipientPhoneNumber": "x"
      },
      "credentials": {
        "whatsAppApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.1
    },
    {
      "id": "7148c03e-8074-4ffb-b44c-57b9bad6a9c0",
      "name": "AI Agent: Q&A",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "position": [
        1360,
        880
      ],
      "parameters": {
        "text": "={{ $('Normalize Whatsapp Input').item.json.chatInput }}",
        "options": {
          "systemMessage": "=You are the Revenue Manager and Market Intelligence lead for WESTIN BAYSHORE VANCOUVER.\n\nThe user is provided with an executive summary of the analysis on competitor pricing alerts versus our hotel.\nExecutive Summary: {{ $json.alertSummary }}\nEvent Summary: {{ $json.eventSummary }}\nAlert Strategy: {{ $json.alertStrategy }}\n\nAnswer user's follow up question with your tool access to the Hotel_Rates_History if needed when observing rate changes in other dates. "
        },
        "promptType": "define"
      },
      "typeVersion": 3
    },
    {
      "id": "cdd7cedb-b58c-429e-a7b4-f4e4e910c12a",
      "name": "OpenAI Chat Model2",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
      "position": [
        1264,
        1088
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "gpt-4.1-mini"
        },
        "options": {},
        "builtInTools": {}
      },
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "5b413733-d683-4f16-8479-a93c6167c29c",
      "name": "Combine Rates & Events",
      "type": "n8n-nodes-base.merge",
      "position": [
        1520,
        400
      ],
      "parameters": {},
      "typeVersion": 3.2
    },
    {
      "id": "43e07862-01f2-4c98-8c75-d366c8c97094",
      "name": "Combine Summary & Alert",
      "type": "n8n-nodes-base.merge",
      "position": [
        2304,
        576
      ],
      "parameters": {
        "mode": "combine",
        "options": {},
        "combineBy": "combineByPosition"
      },
      "typeVersion": 3.2
    },
    {
      "id": "0328da70-92cb-4864-b958-99a0a5c52f40",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -192,
        -1088
      ],
      "parameters": {
        "width": 1712,
        "height": 768,
        "content": "## How It Works \n***Top Branch Workflow***\n\n**1. Intraday Monitoring:** Runs on schedule to catch competitor hotel's price shifts throughout the day. It fetches rates for the next 30 days via Amadeus and compares them against the previous run's data.\n\n**2. Context Enrichment:** If a significant change is found, the workflow:\n   - Retrieves our own hotel's rates for comparison.\n   - Scrapes for event data that might explain demand spikes.\n\n\n**3. AI Analysis & Alerting:** AI Agent acts as a Revenue Manager. It reviews the price gaps, competitor moves, and event signals to draft a strategic Executive Summary. This summary is sent immediately via WhatsApp and saved to the database.\n\n\n***Bottom Branch Workflow***\n\n**Interactive Analyst:** AI Agent 2 handles the follow-up questions. It pulls the latest analysis context and checks historical rate data to give an informed answer.\n\n\n\n## Setup Steps\n1) Add your hotel + competitor hotels (IDs/names) in the Config node.\n2) Set how far ahead you want to monitor (e.g., next 30 nights).\n3) Set how sensitive alerts should be (e.g., alert only if competitor moves > 10%).\n4) Connect:\n   - Amadeus (to fetch hotel prices)\n   - WhatsApp (to send alerts)\n   - Data tables (to store price snapshots, history, summary)\n   - Event Sites (to web scrape local events)\n5) Run a test:\n   - Trigger Workflow A once and confirm you receive a WhatsApp alert.\n   - Send a WhatsApp message to test Workflow B (Q&A).\n"
      },
      "typeVersion": 1
    },
    {
      "id": "b18a26fa-7335-44a7-a39f-0a3018f184aa",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1584,
        -544
      ],
      "parameters": {
        "color": 3,
        "width": 496,
        "height": 176,
        "content": "## Prerequisites\n- OTA Platform API Key (e.g. Amadeus, Expedia)\n- LLM AI Model API Key (e.g. OpenAI, Gemini)\n- Database (e.g. n8n cloud, PostgreSQL)\n- Message Tool API Key (e.g. WhatsApp, Gmail)"
      },
      "typeVersion": 1
    },
    {
      "id": "fe1731dc-6b7d-426f-8630-5deede6e872f",
      "name": "Simple Memory",
      "type": "@n8n/n8n-nodes-langchain.memoryBufferWindow",
      "position": [
        1424,
        1088
      ],
      "parameters": {
        "sessionKey": "={{ $('WhatsApp Trigger').item.json.messages[0].from }}",
        "sessionIdType": "customKey"
      },
      "typeVersion": 1.3
    },
    {
      "id": "691e46ad-822e-46e7-88e8-0eb66269d2a1",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1584,
        -1024
      ],
      "parameters": {
        "color": 5,
        "width": 992,
        "height": 416,
        "content": "## Use Cases & Benefits\n**Revenue Managers:** \nAutomate the \"rate shop\" routine and catch competitor moves without opening a spreadsheet.\n\n**Sales & Marketing Teams:** \nGo beyond raw data. Pairing \"what changed\" with \"why changed\" instantly.\n\n**Hotel Leadership:** \nPerfect for GMs and division leaders who need instant, decision-ready alerts via WhatsApp.\n\n\n\u26a1 ***Zero-Touch Efficiency:*** Eliminates hours of manual searching by automating rate checks hourly.\n\n\ud83e\udde0 ***Contextual Intelligence:*** Track price AND explains why it moved by cross-referencing local events.\n\n\ud83e\udd16 ***Actionable Strategy:*** AI doesn't just report numbers; it recommends specific pricing tactics.\n\n\ud83d\udcc9 ***Long-Term Vision:*** Builds a permanent database of rate history, enabling the AI to answer complex trend questions over time."
      },
      "typeVersion": 1
    },
    {
      "id": "bf602fee-c0a4-4e73-96d9-7ea621804607",
      "name": "Sticky Note6",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1248,
        -288
      ],
      "parameters": {
        "color": 7,
        "width": 1440,
        "height": 480,
        "content": "## Data Consolidation & Archiving\nConsolidates the results with the previous pricing snapshot into a single dataset for further processing and archives into the data bases."
      },
      "typeVersion": 1
    },
    {
      "id": "c8fa37bf-c5df-43ee-8cb1-e0e8061845a1",
      "name": "Sticky Note7",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -272,
        -288
      ],
      "parameters": {
        "color": 7,
        "width": 1440,
        "height": 480,
        "content": "## Data Collection Engine\nThis section triggers on a schedule (09:00, 15:00, 21:00) to ensure intraday coverage. It generates 30 individual date windows and queries the Amadeus API to fetch live pricing for competitor hotels."
      },
      "typeVersion": 1
    },
    {
      "id": "4f7fc126-6624-4b3e-a0be-5369495bb0c2",
      "name": "Sticky Note8",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -272,
        256
      ],
      "parameters": {
        "color": 7,
        "width": 1008,
        "height": 480,
        "content": "## Change Detection & Comparison\nFilters for competitor's significant price changes (>10%) and fetch our hotel's prices for further analysis."
      },
      "typeVersion": 1
    },
    {
      "id": "80c6449d-728b-4d38-860b-1f242d0c337c",
      "name": "Sticky Note9",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        768,
        256
      ],
      "parameters": {
        "color": 7,
        "width": 928,
        "height": 480,
        "content": "## Market Context\nScrapes the Vancouver Convention Centre (VCC) website for the specific alert dates to identify if a conference or event is driving the competitor's price surge."
      },
      "typeVersion": 1
    },
    {
      "id": "0f63fdd0-10ee-429e-a990-6ac9f6733ca1",
      "name": "Sticky Note10",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1728,
        256
      ],
      "parameters": {
        "color": 7,
        "width": 960,
        "height": 480,
        "content": "## Analysis by Revenue AI Agent\nConducts analysis to generate a strategic \"Executive Summary\" recommending specific revenue actions. Delivers the result and archives into the data base."
      },
      "typeVersion": 1
    },
    {
      "id": "dd8d2e47-140d-463b-aceb-81e275d38331",
      "name": "Sticky Note11",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        496,
        768
      ],
      "parameters": {
        "color": 7,
        "width": 1440,
        "height": 480,
        "content": "## Follow-Up Q&A with Revenue AI Agent\nRetrieves the latest alert context and uses database tools to answer our follow-up questions with historical trend dat

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

Top Branch Workflow A The Market Intelligence: Patrols the Market: Runs hourly to scrape competitor rates for future days. Gathers Intel: If prices spike, it instantly checks event announcements to see if a major event is driving demand. Crunches Numbers: Calculates the exact…

Source: https://n8n.io/workflows/12246/ — 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

Who is this for? Agencies, consultants, and service providers who conduct discovery calls and need to quickly turn conversations into professional proposals.

Tool Think, Tool Calculator, Agent Tool +18
AI & RAG

This workflow serves as a comprehensive "Workflow Nodes SEO & Documentation Generator". It uses AI to analyze, rename, and document n8n workflows, offering a streamlined way to optimize workflow reada

Form Trigger, n8n, Output Parser Autofixing +11
AI & RAG

CashMate – Your AI-Powered WhatsApp Finance Agent Turn WhatsApp into a smart finance assistant that auto-registers you, logs transactions in natural language, extracts data from receipts and voice not

Tool Code, Output Parser Structured, Tool Calculator +6
AI & RAG

This workflow creates a complete AI-powered restaurant ordering system through WhatsApp. It receives customer messages, processes multimedia content (text, voice, images, PDFs, location), uses GPT-4 t

OpenAI Chat, Memory Postgres Chat, HTTP Request +6
AI & RAG

This template is designed for anyone who wants to use WhatsApp as a personal AI assistant hub. If you often juggle tasks, emails, calendars, and expenses across multiple tools, this workflow consolida

OpenAI Chat, Memory Buffer Window, Mcp Client Tool +12